diff --git a/frontend/src/app.sass b/frontend/src/app.sass index 3af029a3c..6013ff38e 100644 --- a/frontend/src/app.sass +++ b/frontend/src/app.sass @@ -264,6 +264,9 @@ button, .buttongroup > & min-width: 0 + + &.btn-danger + background-color: var(--color-error-dark) .buttongroup display: grid @@ -284,6 +287,9 @@ button:not([disabled]):hover, background-color: var(--color-light) color: white + &.btn-danger + background-color: var(--color-error) + .btn-primary background-color: var(--color-primary) @@ -1220,3 +1226,9 @@ a.breadcrumbs__home .course__registration-status margin-bottom: 12px + +.csv-parse-error + white-space: pre-wrap + font-family: monospace + overflow: auto + max-height: 75vh diff --git a/messages/uniworx/de-de-formal.msg b/messages/uniworx/de-de-formal.msg index 802f1bc62..cbdda090b 100644 --- a/messages/uniworx/de-de-formal.msg +++ b/messages/uniworx/de-de-formal.msg @@ -1756,6 +1756,7 @@ CsvDeleteMissing: Fehlende Einträge entfernen BtnCsvExport: CSV-Datei exportieren BtnCsvImport: CSV-Datei importieren BtnCsvImportConfirm: CSV-Import abschließen +BtnCsvImportAbort: Abbrechen CsvImportNotConfigured: CSV-Import nicht vorgesehen CsvImportConfirmationHeading: CSV-Import Vorschau (noch keine Änderungen importiert) @@ -1830,6 +1831,8 @@ DBCsvDuplicateKey: Zwei Zeilen der CSV-Dateien referenzieren den selben internen DBCsvDuplicateKeyTip: Entfernen Sie eine der unten aufgeführten Zeilen aus Ihren CSV-Dateien und versuchen Sie es erneut. DBCsvKeyException: Für eine Zeile der CSV-Dateien konnte nicht festgestellt werden, ob sie zu einem bestehenden internen Datensatz korrespondieren. DBCsvException: Bei der Berechnung der auszuführenden Aktionen für einen Datensatz ist ein Fehler aufgetreten. +DBCsvParseError: Eine hochgeladene Datei konnte nicht korrekt als CSV-Datei im erwarteten Format interpretiert werden. +DBCsvParseErrorTip: Die Uni2work-Komponente, die für das Interpretieren von CSV-Dateien zuständig ist, hat folgende Fehlermeldung produziert: ExamUserCsvCourseRegister: Benutzer zum Kurs und zur Prüfung anmelden ExamUserCsvRegister: Kursteilnehmer zur Prüfung anmelden @@ -1846,9 +1849,9 @@ ExamBonusNone: Keine Bonuspunkte ExamUserCsvCourseNoteDeleted: Notiz wird gelöscht -ExamUserCsvExceptionNoMatchingUser: Kursteilnehmer konnte nicht eindeutig identifiziert werden -ExamUserCsvExceptionNoMatchingStudyFeatures: Das angegebene Studienfach konnte keinem Studienfach des Kursteilnehmers zugeordnet werden -ExamUserCsvExceptionNoMatchingOccurrence: Raum/Termin konnte nicht eindeutig identifiziert werden +ExamUserCsvExceptionNoMatchingUser: Benutzer konnte nicht eindeutig identifiziert werden. Alle Identifikatoren des Benutzers (Vorname(n), Nachname, Voller Name, Matrikelnummer, ...) müssen exakt übereinstimmen. Sie können versuchen für diese Zeile manche der Identifikatoren zu entfernen (also z.B. nur eine Matrikelnummer angeben) um dem System zu erlauben nur Anhand der verbleibenden Identifikatoren zu suchen. Sie sollten dann natürlich besonders kontrollieren, dass das System den fraglichen Benutzer korrekt identifiziert hat. +ExamUserCsvExceptionNoMatchingStudyFeatures: Das angegebene Studienfach konnte keinem Studienfach des Benutzers zugeordnet werden. Sie können versuchen für diese Zeile die Studiengangsdaten zu entfernen um das System automatisch ein Studienfach wählen zu lassen. +ExamUserCsvExceptionNoMatchingOccurrence: Raum/Termin konnte nicht eindeutig identifiziert werden. Überprüfen Sie, dass diese Zeile nur interne Raumbezeichnungen enthält, wie sie auch für die Prüfung konfiguriert wurden. ExamUserCsvExceptionMismatchedGradingMode expectedGradingMode@ExamGradingMode actualGradingMode@ExamGradingMode: Es wurde versucht eine Prüfungsleistung einzutragen, die zwar vom System interpretiert werden konnte, aber nicht dem für diese Prüfung erwarteten Modus entspricht. Der erwartete Bewertungsmodus kann unter "Prüfung bearbeiten" angepasst werden ("Bestanden/Nicht Bestanden", "Numerische Noten" oder "Gemischt"). ExamUserCsvExceptionNoOccurrenceTime: Es wurde versucht eine Prüfungsleistung ohne einen zugehörigen Zeitpunkt einzutragen. Sie können entweder einen Zeitpunkt pro Student in der entsprechenden Spalte hinterlegen, oder einen voreingestellten Zeitpunkt unter "Bearbeiten" angeben. diff --git a/messages/uniworx/en-eu.msg b/messages/uniworx/en-eu.msg index 984c4ea25..9b7872b1c 100644 --- a/messages/uniworx/en-eu.msg +++ b/messages/uniworx/en-eu.msg @@ -1755,6 +1755,7 @@ CsvDeleteMissing: Delete missing entries BtnCsvExport: Export CSV file BtnCsvImport: Import CSV file BtnCsvImportConfirm: Finalise CSV import +BtnCsvImportAbort: Abort CsvImportNotConfigured: CSV import not configured CsvImportConfirmationHeading: CSV import preview (no changes have been made yet) @@ -1829,6 +1830,8 @@ DBCsvDuplicateKey: Two rows in the CSV file reference the same database entry an DBCsvDuplicateKeyTip: Please remove one of the lines listed below and try again. DBCsvKeyException: For a row in the CSV file it could not be determined whether it references any database entry. DBCsvException: An error occurred hile computing the set of edits this CSV import corresponds to. +DBCsvParseError: An uploaded file could not be interpreted as CSV of the expected format. +DBCsvParseErrorTip: The Uni2work-component that handles CSV decoding has reported the following error: ExamUserCsvCourseRegister: Register users for the exam and enroll them in the course ExamUserCsvRegister: Register users for the exam @@ -1845,9 +1848,9 @@ ExamBonusNone: No bonus points ExamUserCsvCourseNoteDeleted: Course note will be deleted -ExamUserCsvExceptionNoMatchingUser: Course participant could not be identified uniquely -ExamUserCsvExceptionNoMatchingStudyFeatures: The specified field did not match with any of the participant's fields of study -ExamUserCsvExceptionNoMatchingOccurrence: Occurrence/room could not be identified uniquely +ExamUserCsvExceptionNoMatchingUser: Course participant could not be identified uniquely. All identifiers (given name(s), surname, display name, matriculation, ..) must match exactly. You can try to remove some of the identifiers for the given line (i.e. all but matriculation). Uni2work will then search for users using only the remaining identifiers. In this case special care should be taken that Uni2work correctly identifies the intended user. +ExamUserCsvExceptionNoMatchingStudyFeatures: The specified field did not match with any of the participant's fields of study. You can try to remove the field of study for the given line. Uni2work will then automatically choose a field of study. +ExamUserCsvExceptionNoMatchingOccurrence: Occurrence/room could not be identified uniquely. Please ensure that the given line only contains internal room identifiers exactly as they have been configured for this exam. ExamUserCsvExceptionMismatchedGradingMode expectedGradingMode actualGradingMode: The imported data contained an exam achievement which does not match the grading mode for this exam. The expected grading mode can be changed at "Edit exam" ("Passed/Failed", "Numeric grades", or "Mixed"). ExamUserCsvExceptionNoOccurrenceTime: The imported data contained an exam achievement without an associated time. You can either enter a time for each student in the appropriate column or you can set a default time for the entire exam under "Edit". diff --git a/src/Handler/Utils/Csv.hs b/src/Handler/Utils/Csv.hs index 97090b54f..57465d311 100644 --- a/src/Handler/Utils/Csv.hs +++ b/src/Handler/Utils/Csv.hs @@ -39,6 +39,8 @@ import qualified Data.Attoparsec.ByteString.Lazy as A import Handler.Utils.DateTime import Data.Time.Format (iso8601DateFormat) +import qualified Data.Char as Char + decodeCsv :: (MonadHandler m, HandlerSite m ~ UniWorX, MonadThrow m, FromNamedRecord csv) => ConduitT ByteString csv m () decodeCsv = decodeCsv' fromNamedCsv @@ -69,6 +71,7 @@ decodeCsv' fromCsv' = do let decodeOptions = defaultDecodeOptions & guessDelimiter testBuffer + & noAlphaNumDelimiters $logInfoS "decodeCsv" [st|Guessed Csv.DecodeOptions from buffer of size #{tshow (LBS.length testBuffer)}/#{tshow testBufferSize}: #{tshow decodeOptions}|] fromCsv' decodeOptions @@ -104,6 +107,12 @@ decodeCsv' fromCsv' = do | otherwise = id + noAlphaNumDelimiters opts + | Char.isAlphaNum . Char.chr . fromIntegral $ decDelimiter opts + = opts { decDelimiter = decDelimiter defaultDecodeOptions } + | otherwise + = opts + quotedField :: A.Parser () -- We don't care about the return value quotedField = void . Csv.field $ Csv.decDelimiter defaultDecodeOptions -- We can use comma as a separator, because we know that the field we're trying to parse is quoted and so does not rely on the delimiter diff --git a/src/Handler/Utils/Table/Pagination.hs b/src/Handler/Utils/Table/Pagination.hs index dd07ad173..816521ec0 100644 --- a/src/Handler/Utils/Table/Pagination.hs +++ b/src/Handler/Utils/Table/Pagination.hs @@ -345,7 +345,7 @@ deriveJSON defaultOptions } ''DBCsvActionMode -data ButtonCsvMode = BtnCsvExport | BtnCsvImport | BtnCsvImportConfirm +data ButtonCsvMode = BtnCsvExport | BtnCsvImport | BtnCsvImportConfirm | BtnCsvImportAbort deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic, Typeable) instance Universe ButtonCsvMode instance Finite ButtonCsvMode @@ -363,6 +363,13 @@ instance Button UniWorX ButtonCsvMode where |] btnLabel x = [whamlet|_{x}|] + btnClasses BtnCsvImportAbort = [BCIsButton, BCDanger] + btnClasses BtnCsvImportConfirm = [BCIsButton, BCPrimary] + btnClasses _ = [BCIsButton] + + btnValidate _ BtnCsvImportAbort = False + btnValidate _ _ = True + data DBCsvMode = DBCsvNormal @@ -373,6 +380,7 @@ data DBCsvMode { dbCsvFiles :: [FileInfo] } | DBCsvExportExample + | DBCsvAbort data DBCsvDiff r' csv k' = DBCsvDiffNew @@ -942,7 +950,15 @@ dbTable PSValidator{..} dbtable@DBTable{ dbtIdent = dbtIdent'@(toPathPiece -> db -> toDyn <$> dbtCsvExportForm Nothing -> pure $ toDyn () - ((csvImportRes, csvImportWdgt), csvImportEnctype) <- lift . runFormPost . identifyForm FIDDBTableCsvImport . renderAForm FormDBTableCsvImport $ DBCsvImport + let importButtons prevRes = do + isReImport <- hasGlobalPostParamForm PostDBCsvReImport + if | is _FormSuccess prevRes || isReImport + -> return [BtnCsvImport, BtnCsvImportAbort] + | otherwise + -> return [BtnCsvImport] + handleBtnAbort _ (FormSuccess BtnCsvImportAbort) = pure DBCsvAbort + handleBtnAbort x btn = x <* btn + ((csvImportRes, csvImportWdgt), csvImportEnctype) <- lift . runFormPost . withGlobalPostParam PostDBCsvReImport () . withButtonFormCombM' handleBtnAbort importButtons . identifyForm FIDDBTableCsvImport . renderAForm FormDBTableCsvImport $ DBCsvImport <$> areq fileFieldMultiple (fslI MsgCsvFile) Nothing exportExampleRes <- guardOn <$> hasGlobalGetParam GetCsvExampleData <*> pure DBCsvExportExample @@ -961,12 +977,12 @@ dbTable PSValidator{..} dbtable@DBTable{ dbtIdent = dbtIdent'@(toPathPiece -> db , formSubmit = FormSubmit , formAnchor = Just $ wIdent "csv-export" } - csvImportWdgt' = wrapForm' BtnCsvImport csvImportWdgt FormSettings + csvImportWdgt' = wrapForm csvImportWdgt FormSettings { formMethod = POST , formAction = Just $ tblLink id , formEncoding = csvImportEnctype , formAttrs = [] - , formSubmit = FormSubmit + , formSubmit = FormNoSubmit , formAnchor = Just $ wIdent "csv-import" } csvImportExplanation :: Widget @@ -1049,6 +1065,9 @@ dbTable PSValidator{..} dbtable@DBTable{ dbtIdent = dbtIdent'@(toPathPiece -> db return $(widgetFile "table/csv-example") formResult csvMode $ \case + DBCsvAbort{} -> do + addMessageI Info MsgCsvImportAborted + redirect $ tblLink id DBCsvExportExample{} | Just DBTCsvEncode{..} <- dbtCsvEncode , Just exData <- dbtCsvExampleData -> do @@ -1113,6 +1132,8 @@ dbTable PSValidator{..} dbtable@DBTable{ dbtIdent = dbtIdent'@(toPathPiece -> db addMessageI Info MsgCsvImportUnnecessary redirect $ tblLink id + E.transactionSave -- If dbtCsvComputeActions has side-effects, commit those + liftHandler . (>>= sendResponse) $ siteLayoutMsg MsgCsvImportConfirmationHeading $ do setTitleI MsgCsvImportConfirmationHeading @@ -1136,18 +1157,20 @@ dbTable PSValidator{..} dbtable@DBTable{ dbtIdent = dbtIdent'@(toPathPiece -> db |] fieldView sJsonField (actionIdent act) (toPathPiece PostDBCsvImportAction) vAttrs (Right act) False - (csvImportConfirmForm', csvImportConfirmEnctype) <- liftHandler . generateFormPost . identifyForm FIDDBTableCsvImportConfirm $ \csrf -> return (error "No meaningful FormResult", $(widgetFile "csv-import-confirmation")) - let csvImportConfirmForm = wrapForm' BtnCsvImportConfirm csvImportConfirmForm' FormSettings + (csvImportConfirmForm', csvImportConfirmEnctype) <- liftHandler . generateFormPost . withButtonForm' [BtnCsvImportConfirm, BtnCsvImportAbort] . identifyForm FIDDBTableCsvImportConfirm $ \csrf -> return (error "No meaningful FormResult", $(widgetFile "csv-import-confirmation")) + let csvImportConfirmForm = wrapForm csvImportConfirmForm' FormSettings { formMethod = POST , formAction = Just $ tblLink id , formEncoding = csvImportConfirmEnctype , formAttrs = [] - , formSubmit = FormSubmit + , formSubmit = FormNoSubmit , formAnchor = Nothing :: Maybe Text } $(widgetFile "csv-import-confirmation-wrapper") + csvReImport = $(widgetFile "table/csv-reimport") + hdr <- dbtCsvHeader Nothing catches importCsv [ Catch.Handler $ \case @@ -1161,9 +1184,13 @@ dbTable PSValidator{..} dbtable@DBTable{ dbtIdent = dbtIdent'@(toPathPiece -> db siteLayoutMsg heading $ do setTitleI heading [whamlet| -
_{MsgDBCsvDuplicateKey} -
_{MsgDBCsvDuplicateKeyTip}
- ^{offendingCsv}
+ $newline never
+ _{MsgDBCsvDuplicateKey}
+ _{MsgDBCsvDuplicateKeyTip}
+ ^{offendingCsv}
+ _{MsgDBCsvException}
- $if not (Text.null dbCsvException)
- #{dbCsvException}
- ^{ offendingCsv}
+ $newline never
+ _{MsgDBCsvException}
+ $if not (Text.null dbCsvException)
+ #{dbCsvException}
+ ^{offendingCsv}
+ _{MsgDBCsvParseErrorTip}
+ ^{csvImportExplanation}
+ ^{csvColExplanations'}
+$maybe wgt <- csvExample
+
+ ^{modal (i18n MsgCsvExampleData) (Right wgt)}
+
+ ^{modal (i18n MsgCsvChangeOptionsLabel) (Left (SomeRoute CsvOptionsR))}
+^{csvImportWdgt'}
+ $case csvParseError
+ $of CsvParseError _ errMsg
+ #{errMsg}
+ $of IncrementalError errMsg
+ #{errMsg}
+ _{MsgCsvColumnsExplanationsTip}
$forall (colName, colExplanation) <- csvColExplanations''
diff --git a/templates/table/csv-reimport.hamlet b/templates/table/csv-reimport.hamlet
new file mode 100644
index 000000000..0de054b07
--- /dev/null
+++ b/templates/table/csv-reimport.hamlet
@@ -0,0 +1,9 @@
+$newline never
+