fix(csv-import): major usability improvements
This commit is contained in:
parent
1770526723
commit
2dc6641e68
@ -264,6 +264,9 @@ button,
|
|||||||
|
|
||||||
.buttongroup > &
|
.buttongroup > &
|
||||||
min-width: 0
|
min-width: 0
|
||||||
|
|
||||||
|
&.btn-danger
|
||||||
|
background-color: var(--color-error-dark)
|
||||||
|
|
||||||
.buttongroup
|
.buttongroup
|
||||||
display: grid
|
display: grid
|
||||||
@ -284,6 +287,9 @@ button:not([disabled]):hover,
|
|||||||
background-color: var(--color-light)
|
background-color: var(--color-light)
|
||||||
color: white
|
color: white
|
||||||
|
|
||||||
|
&.btn-danger
|
||||||
|
background-color: var(--color-error)
|
||||||
|
|
||||||
.btn-primary
|
.btn-primary
|
||||||
background-color: var(--color-primary)
|
background-color: var(--color-primary)
|
||||||
|
|
||||||
@ -1220,3 +1226,9 @@ a.breadcrumbs__home
|
|||||||
|
|
||||||
.course__registration-status
|
.course__registration-status
|
||||||
margin-bottom: 12px
|
margin-bottom: 12px
|
||||||
|
|
||||||
|
.csv-parse-error
|
||||||
|
white-space: pre-wrap
|
||||||
|
font-family: monospace
|
||||||
|
overflow: auto
|
||||||
|
max-height: 75vh
|
||||||
|
|||||||
@ -1756,6 +1756,7 @@ CsvDeleteMissing: Fehlende Einträge entfernen
|
|||||||
BtnCsvExport: CSV-Datei exportieren
|
BtnCsvExport: CSV-Datei exportieren
|
||||||
BtnCsvImport: CSV-Datei importieren
|
BtnCsvImport: CSV-Datei importieren
|
||||||
BtnCsvImportConfirm: CSV-Import abschließen
|
BtnCsvImportConfirm: CSV-Import abschließen
|
||||||
|
BtnCsvImportAbort: Abbrechen
|
||||||
|
|
||||||
CsvImportNotConfigured: CSV-Import nicht vorgesehen
|
CsvImportNotConfigured: CSV-Import nicht vorgesehen
|
||||||
CsvImportConfirmationHeading: CSV-Import Vorschau (noch keine Änderungen importiert)
|
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.
|
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.
|
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.
|
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
|
ExamUserCsvCourseRegister: Benutzer zum Kurs und zur Prüfung anmelden
|
||||||
ExamUserCsvRegister: Kursteilnehmer zur Prüfung anmelden
|
ExamUserCsvRegister: Kursteilnehmer zur Prüfung anmelden
|
||||||
@ -1846,9 +1849,9 @@ ExamBonusNone: Keine Bonuspunkte
|
|||||||
|
|
||||||
ExamUserCsvCourseNoteDeleted: Notiz wird gelöscht
|
ExamUserCsvCourseNoteDeleted: Notiz wird gelöscht
|
||||||
|
|
||||||
ExamUserCsvExceptionNoMatchingUser: Kursteilnehmer 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 Kursteilnehmers zugeordnet werden
|
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
|
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").
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@ -1755,6 +1755,7 @@ CsvDeleteMissing: Delete missing entries
|
|||||||
BtnCsvExport: Export CSV file
|
BtnCsvExport: Export CSV file
|
||||||
BtnCsvImport: Import CSV file
|
BtnCsvImport: Import CSV file
|
||||||
BtnCsvImportConfirm: Finalise CSV import
|
BtnCsvImportConfirm: Finalise CSV import
|
||||||
|
BtnCsvImportAbort: Abort
|
||||||
|
|
||||||
CsvImportNotConfigured: CSV import not configured
|
CsvImportNotConfigured: CSV import not configured
|
||||||
CsvImportConfirmationHeading: CSV import preview (no changes have been made yet)
|
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.
|
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.
|
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.
|
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
|
ExamUserCsvCourseRegister: Register users for the exam and enroll them in the course
|
||||||
ExamUserCsvRegister: Register users for the exam
|
ExamUserCsvRegister: Register users for the exam
|
||||||
@ -1845,9 +1848,9 @@ ExamBonusNone: No bonus points
|
|||||||
|
|
||||||
ExamUserCsvCourseNoteDeleted: Course note will be deleted
|
ExamUserCsvCourseNoteDeleted: Course note will be deleted
|
||||||
|
|
||||||
ExamUserCsvExceptionNoMatchingUser: Course participant 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
|
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
|
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").
|
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".
|
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".
|
||||||
|
|
||||||
|
|||||||
@ -39,6 +39,8 @@ import qualified Data.Attoparsec.ByteString.Lazy as A
|
|||||||
import Handler.Utils.DateTime
|
import Handler.Utils.DateTime
|
||||||
import Data.Time.Format (iso8601DateFormat)
|
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 :: (MonadHandler m, HandlerSite m ~ UniWorX, MonadThrow m, FromNamedRecord csv) => ConduitT ByteString csv m ()
|
||||||
decodeCsv = decodeCsv' fromNamedCsv
|
decodeCsv = decodeCsv' fromNamedCsv
|
||||||
@ -69,6 +71,7 @@ decodeCsv' fromCsv' = do
|
|||||||
|
|
||||||
let decodeOptions = defaultDecodeOptions
|
let decodeOptions = defaultDecodeOptions
|
||||||
& guessDelimiter testBuffer
|
& guessDelimiter testBuffer
|
||||||
|
& noAlphaNumDelimiters
|
||||||
$logInfoS "decodeCsv" [st|Guessed Csv.DecodeOptions from buffer of size #{tshow (LBS.length testBuffer)}/#{tshow testBufferSize}: #{tshow decodeOptions}|]
|
$logInfoS "decodeCsv" [st|Guessed Csv.DecodeOptions from buffer of size #{tshow (LBS.length testBuffer)}/#{tshow testBufferSize}: #{tshow decodeOptions}|]
|
||||||
|
|
||||||
fromCsv' decodeOptions
|
fromCsv' decodeOptions
|
||||||
@ -104,6 +107,12 @@ decodeCsv' fromCsv' = do
|
|||||||
| otherwise
|
| otherwise
|
||||||
= id
|
= 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 :: 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
|
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
|
||||||
|
|||||||
@ -345,7 +345,7 @@ deriveJSON defaultOptions
|
|||||||
} ''DBCsvActionMode
|
} ''DBCsvActionMode
|
||||||
|
|
||||||
|
|
||||||
data ButtonCsvMode = BtnCsvExport | BtnCsvImport | BtnCsvImportConfirm
|
data ButtonCsvMode = BtnCsvExport | BtnCsvImport | BtnCsvImportConfirm | BtnCsvImportAbort
|
||||||
deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic, Typeable)
|
deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic, Typeable)
|
||||||
instance Universe ButtonCsvMode
|
instance Universe ButtonCsvMode
|
||||||
instance Finite ButtonCsvMode
|
instance Finite ButtonCsvMode
|
||||||
@ -363,6 +363,13 @@ instance Button UniWorX ButtonCsvMode where
|
|||||||
|]
|
|]
|
||||||
btnLabel x = [whamlet|_{x}|]
|
btnLabel x = [whamlet|_{x}|]
|
||||||
|
|
||||||
|
btnClasses BtnCsvImportAbort = [BCIsButton, BCDanger]
|
||||||
|
btnClasses BtnCsvImportConfirm = [BCIsButton, BCPrimary]
|
||||||
|
btnClasses _ = [BCIsButton]
|
||||||
|
|
||||||
|
btnValidate _ BtnCsvImportAbort = False
|
||||||
|
btnValidate _ _ = True
|
||||||
|
|
||||||
|
|
||||||
data DBCsvMode
|
data DBCsvMode
|
||||||
= DBCsvNormal
|
= DBCsvNormal
|
||||||
@ -373,6 +380,7 @@ data DBCsvMode
|
|||||||
{ dbCsvFiles :: [FileInfo]
|
{ dbCsvFiles :: [FileInfo]
|
||||||
}
|
}
|
||||||
| DBCsvExportExample
|
| DBCsvExportExample
|
||||||
|
| DBCsvAbort
|
||||||
|
|
||||||
data DBCsvDiff r' csv k'
|
data DBCsvDiff r' csv k'
|
||||||
= DBCsvDiffNew
|
= DBCsvDiffNew
|
||||||
@ -942,7 +950,15 @@ dbTable PSValidator{..} dbtable@DBTable{ dbtIdent = dbtIdent'@(toPathPiece -> db
|
|||||||
-> toDyn <$> dbtCsvExportForm
|
-> toDyn <$> dbtCsvExportForm
|
||||||
Nothing
|
Nothing
|
||||||
-> pure $ toDyn ()
|
-> 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
|
<$> areq fileFieldMultiple (fslI MsgCsvFile) Nothing
|
||||||
exportExampleRes <- guardOn <$> hasGlobalGetParam GetCsvExampleData <*> pure DBCsvExportExample
|
exportExampleRes <- guardOn <$> hasGlobalGetParam GetCsvExampleData <*> pure DBCsvExportExample
|
||||||
|
|
||||||
@ -961,12 +977,12 @@ dbTable PSValidator{..} dbtable@DBTable{ dbtIdent = dbtIdent'@(toPathPiece -> db
|
|||||||
, formSubmit = FormSubmit
|
, formSubmit = FormSubmit
|
||||||
, formAnchor = Just $ wIdent "csv-export"
|
, formAnchor = Just $ wIdent "csv-export"
|
||||||
}
|
}
|
||||||
csvImportWdgt' = wrapForm' BtnCsvImport csvImportWdgt FormSettings
|
csvImportWdgt' = wrapForm csvImportWdgt FormSettings
|
||||||
{ formMethod = POST
|
{ formMethod = POST
|
||||||
, formAction = Just $ tblLink id
|
, formAction = Just $ tblLink id
|
||||||
, formEncoding = csvImportEnctype
|
, formEncoding = csvImportEnctype
|
||||||
, formAttrs = []
|
, formAttrs = []
|
||||||
, formSubmit = FormSubmit
|
, formSubmit = FormNoSubmit
|
||||||
, formAnchor = Just $ wIdent "csv-import"
|
, formAnchor = Just $ wIdent "csv-import"
|
||||||
}
|
}
|
||||||
csvImportExplanation :: Widget
|
csvImportExplanation :: Widget
|
||||||
@ -1049,6 +1065,9 @@ dbTable PSValidator{..} dbtable@DBTable{ dbtIdent = dbtIdent'@(toPathPiece -> db
|
|||||||
return $(widgetFile "table/csv-example")
|
return $(widgetFile "table/csv-example")
|
||||||
|
|
||||||
formResult csvMode $ \case
|
formResult csvMode $ \case
|
||||||
|
DBCsvAbort{} -> do
|
||||||
|
addMessageI Info MsgCsvImportAborted
|
||||||
|
redirect $ tblLink id
|
||||||
DBCsvExportExample{}
|
DBCsvExportExample{}
|
||||||
| Just DBTCsvEncode{..} <- dbtCsvEncode
|
| Just DBTCsvEncode{..} <- dbtCsvEncode
|
||||||
, Just exData <- dbtCsvExampleData -> do
|
, Just exData <- dbtCsvExampleData -> do
|
||||||
@ -1113,6 +1132,8 @@ dbTable PSValidator{..} dbtable@DBTable{ dbtIdent = dbtIdent'@(toPathPiece -> db
|
|||||||
addMessageI Info MsgCsvImportUnnecessary
|
addMessageI Info MsgCsvImportUnnecessary
|
||||||
redirect $ tblLink id
|
redirect $ tblLink id
|
||||||
|
|
||||||
|
E.transactionSave -- If dbtCsvComputeActions has side-effects, commit those
|
||||||
|
|
||||||
liftHandler . (>>= sendResponse) $
|
liftHandler . (>>= sendResponse) $
|
||||||
siteLayoutMsg MsgCsvImportConfirmationHeading $ do
|
siteLayoutMsg MsgCsvImportConfirmationHeading $ do
|
||||||
setTitleI MsgCsvImportConfirmationHeading
|
setTitleI MsgCsvImportConfirmationHeading
|
||||||
@ -1136,18 +1157,20 @@ dbTable PSValidator{..} dbtable@DBTable{ dbtIdent = dbtIdent'@(toPathPiece -> db
|
|||||||
<input id=#{theId} *{attrs} type=checkbox name=#{name} value=#{either id id val} :defaultChecked (dbtCsvClassifyAction act):checked>
|
<input id=#{theId} *{attrs} type=checkbox name=#{name} value=#{either id id val} :defaultChecked (dbtCsvClassifyAction act):checked>
|
||||||
|]
|
|]
|
||||||
fieldView sJsonField (actionIdent act) (toPathPiece PostDBCsvImportAction) vAttrs (Right act) False
|
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"))
|
(csvImportConfirmForm', csvImportConfirmEnctype) <- liftHandler . generateFormPost . withButtonForm' [BtnCsvImportConfirm, BtnCsvImportAbort] . identifyForm FIDDBTableCsvImportConfirm $ \csrf -> return (error "No meaningful FormResult", $(widgetFile "csv-import-confirmation"))
|
||||||
let csvImportConfirmForm = wrapForm' BtnCsvImportConfirm csvImportConfirmForm' FormSettings
|
let csvImportConfirmForm = wrapForm csvImportConfirmForm' FormSettings
|
||||||
{ formMethod = POST
|
{ formMethod = POST
|
||||||
, formAction = Just $ tblLink id
|
, formAction = Just $ tblLink id
|
||||||
, formEncoding = csvImportConfirmEnctype
|
, formEncoding = csvImportConfirmEnctype
|
||||||
, formAttrs = []
|
, formAttrs = []
|
||||||
, formSubmit = FormSubmit
|
, formSubmit = FormNoSubmit
|
||||||
, formAnchor = Nothing :: Maybe Text
|
, formAnchor = Nothing :: Maybe Text
|
||||||
}
|
}
|
||||||
|
|
||||||
$(widgetFile "csv-import-confirmation-wrapper")
|
$(widgetFile "csv-import-confirmation-wrapper")
|
||||||
|
|
||||||
|
csvReImport = $(widgetFile "table/csv-reimport")
|
||||||
|
|
||||||
hdr <- dbtCsvHeader Nothing
|
hdr <- dbtCsvHeader Nothing
|
||||||
catches importCsv
|
catches importCsv
|
||||||
[ Catch.Handler $ \case
|
[ Catch.Handler $ \case
|
||||||
@ -1161,9 +1184,13 @@ dbTable PSValidator{..} dbtable@DBTable{ dbtIdent = dbtIdent'@(toPathPiece -> db
|
|||||||
siteLayoutMsg heading $ do
|
siteLayoutMsg heading $ do
|
||||||
setTitleI heading
|
setTitleI heading
|
||||||
[whamlet|
|
[whamlet|
|
||||||
<p>_{MsgDBCsvDuplicateKey}
|
$newline never
|
||||||
<p>_{MsgDBCsvDuplicateKeyTip}
|
<section>
|
||||||
^{offendingCsv}
|
<p>_{MsgDBCsvDuplicateKey}
|
||||||
|
<p>_{MsgDBCsvDuplicateKeyTip}
|
||||||
|
^{offendingCsv}
|
||||||
|
<section>
|
||||||
|
^{csvReImport}
|
||||||
|]
|
|]
|
||||||
(DBCsvException{..} :: DBCsvException k')
|
(DBCsvException{..} :: DBCsvException k')
|
||||||
-> liftHandler $ sendResponseStatus badRequest400 =<< do
|
-> liftHandler $ sendResponseStatus badRequest400 =<< do
|
||||||
@ -1175,11 +1202,36 @@ dbTable PSValidator{..} dbtable@DBTable{ dbtIdent = dbtIdent'@(toPathPiece -> db
|
|||||||
siteLayoutMsg heading $ do
|
siteLayoutMsg heading $ do
|
||||||
setTitleI heading
|
setTitleI heading
|
||||||
[whamlet|
|
[whamlet|
|
||||||
<p>_{MsgDBCsvException}
|
$newline never
|
||||||
$if not (Text.null dbCsvException)
|
<section>
|
||||||
<p>#{dbCsvException}
|
<p>_{MsgDBCsvException}
|
||||||
^{ offendingCsv}
|
$if not (Text.null dbCsvException)
|
||||||
|
<p>#{dbCsvException}
|
||||||
|
^{offendingCsv}
|
||||||
|
<section>
|
||||||
|
^{csvReImport}
|
||||||
|]
|
|]
|
||||||
|
, Catch.Handler $ \(csvParseError :: CsvParseError)
|
||||||
|
-> liftHandler $ sendResponseStatus badRequest400 =<< do
|
||||||
|
mr <- getMessageRender
|
||||||
|
|
||||||
|
let heading = ErrorResponseTitle $ InvalidArgs [mr MsgDBCsvParseError]
|
||||||
|
|
||||||
|
siteLayoutMsg heading $ do
|
||||||
|
setTitleI heading
|
||||||
|
[whamlet|
|
||||||
|
$newline never
|
||||||
|
<section>
|
||||||
|
<p>_{MsgDBCsvParseErrorTip}
|
||||||
|
<pre .csv-parse-error>
|
||||||
|
$case csvParseError
|
||||||
|
$of CsvParseError _ errMsg
|
||||||
|
#{errMsg}
|
||||||
|
$of IncrementalError errMsg
|
||||||
|
#{errMsg}
|
||||||
|
<section>
|
||||||
|
^{csvReImport}
|
||||||
|
|]
|
||||||
]
|
]
|
||||||
_other -> return ()
|
_other -> return ()
|
||||||
|
|
||||||
@ -1260,11 +1312,11 @@ dbTable PSValidator{..} dbtable@DBTable{ dbtIdent = dbtIdent'@(toPathPiece -> db
|
|||||||
return . dbInvalidateResult dbtParams . DBTIRowsMissing $ length previousKeys - length currentKeys
|
return . dbInvalidateResult dbtParams . DBTIRowsMissing $ length previousKeys - length currentKeys
|
||||||
]
|
]
|
||||||
|
|
||||||
((csvImportConfirmRes, ()), _enctype) <- case dbtCsvDecode of
|
((csvImportConfirmRes, _confirmView), _enctype) <- case dbtCsvDecode of
|
||||||
Just (DBTCsvDecode{dbtCsvExecuteActions} :: DBTCsvDecode r' k' csv) -> do
|
Just (DBTCsvDecode{dbtCsvExecuteActions} :: DBTCsvDecode r' k' csv) -> do
|
||||||
lift . runFormPost . identifyForm FIDDBTableCsvImportConfirm $ \_csrf -> do
|
lift . runFormPost . withButtonForm' [BtnCsvImportConfirm, BtnCsvImportAbort] . identifyForm FIDDBTableCsvImportConfirm $ \_csrf -> do
|
||||||
acts <- globalPostParamFields PostDBCsvImportAction secretJsonField
|
acts <- globalPostParamFields PostDBCsvImportAction secretJsonField
|
||||||
return . (, ()) $ if
|
return . (, mempty) $ if
|
||||||
| null acts -> FormSuccess $ do
|
| null acts -> FormSuccess $ do
|
||||||
addMessageI Info MsgCsvImportAborted
|
addMessageI Info MsgCsvImportAborted
|
||||||
redirect $ tblLink id
|
redirect $ tblLink id
|
||||||
@ -1273,8 +1325,12 @@ dbTable PSValidator{..} dbtable@DBTable{ dbtIdent = dbtIdent'@(toPathPiece -> db
|
|||||||
addMessageI Success . MsgCsvImportSuccessful $ length acts
|
addMessageI Success . MsgCsvImportSuccessful $ length acts
|
||||||
E.transactionSave
|
E.transactionSave
|
||||||
redirect finalDest
|
redirect finalDest
|
||||||
_other -> return ((FormMissing, ()), mempty)
|
_other -> return ((FormMissing, mempty), mempty)
|
||||||
formResult csvImportConfirmRes id
|
formResult csvImportConfirmRes $ \case
|
||||||
|
(_, BtnCsvImportAbort) -> do
|
||||||
|
addMessageI Info MsgCsvImportAborted
|
||||||
|
redirect $ tblLink id
|
||||||
|
(act, _) -> act
|
||||||
|
|
||||||
dbInvalidateResult' <=< bool (dbHandler (Proxy @m) (Proxy @x) $ (\table -> $(widgetFile "table/layout-wrapper")) . uiLayout) (sendResponse <=< tblLayout . uiLayout <=< dbWidget (Proxy @m) (Proxy @x)) psShortcircuit <=< runDBTable dbtable paginationInput currentKeys . fmap swap $ runWriterT table'
|
dbInvalidateResult' <=< bool (dbHandler (Proxy @m) (Proxy @x) $ (\table -> $(widgetFile "table/layout-wrapper")) . uiLayout) (sendResponse <=< tblLayout . uiLayout <=< dbWidget (Proxy @m) (Proxy @x)) psShortcircuit <=< runDBTable dbtable paginationInput currentKeys . fmap swap $ runWriterT table'
|
||||||
where
|
where
|
||||||
|
|||||||
@ -2,6 +2,8 @@ module Handler.Utils.Users
|
|||||||
( computeUserAuthenticationDigest
|
( computeUserAuthenticationDigest
|
||||||
, Digest, SHA3_256
|
, Digest, SHA3_256
|
||||||
, constEq
|
, constEq
|
||||||
|
, NameMatchQuality(..)
|
||||||
|
, matchesName
|
||||||
, GuessUserInfo(..)
|
, GuessUserInfo(..)
|
||||||
, guessUser
|
, guessUser
|
||||||
) where
|
) where
|
||||||
@ -21,6 +23,10 @@ import qualified Data.CaseInsensitive as CI
|
|||||||
import qualified Database.Esqueleto as E
|
import qualified Database.Esqueleto as E
|
||||||
import qualified Database.Esqueleto.Utils as E
|
import qualified Database.Esqueleto.Utils as E
|
||||||
|
|
||||||
|
import qualified Data.MultiSet as MultiSet
|
||||||
|
import qualified Data.Map as Map
|
||||||
|
import qualified Data.Text as Text
|
||||||
|
|
||||||
|
|
||||||
computeUserAuthenticationDigest :: AuthenticationMode -> Digest SHA3_256
|
computeUserAuthenticationDigest :: AuthenticationMode -> Digest SHA3_256
|
||||||
computeUserAuthenticationDigest = hashlazy . JSON.encode
|
computeUserAuthenticationDigest = hashlazy . JSON.encode
|
||||||
@ -36,14 +42,44 @@ instance Binary GuessUserInfo
|
|||||||
|
|
||||||
makeLenses_ ''GuessUserInfo
|
makeLenses_ ''GuessUserInfo
|
||||||
|
|
||||||
|
data NameMatchQuality
|
||||||
|
= NameMatchSuffix
|
||||||
|
| NameMatchPrefix
|
||||||
|
| NameMatchPermutation
|
||||||
|
| NameMatchEqual
|
||||||
|
deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic, Typeable)
|
||||||
|
|
||||||
|
matchesName :: Textual t
|
||||||
|
=> t -- ^ haystack
|
||||||
|
-> t -- ^ needle
|
||||||
|
-> Maybe NameMatchQuality
|
||||||
|
matchesName (repack -> haystack) (repack -> needle)
|
||||||
|
= fmap (view _1) . Map.lookupMax $ Map.filter id tests
|
||||||
|
where
|
||||||
|
asWords :: Text -> [CI Text]
|
||||||
|
asWords = map CI.mk . filter (not . Text.null) . Text.words . Text.strip
|
||||||
|
|
||||||
|
tests :: Map NameMatchQuality Bool
|
||||||
|
tests = mconcat
|
||||||
|
[ singletonMap NameMatchEqual $ asWords needle == asWords haystack
|
||||||
|
, singletonMap NameMatchPrefix $ asWords needle `isPrefixOf` asWords haystack
|
||||||
|
, singletonMap NameMatchSuffix $ asWords needle `isSuffixOf` asWords haystack
|
||||||
|
, singletonMap NameMatchPermutation $ ((==) `on` MultiSet.fromList) (asWords needle) (asWords haystack)
|
||||||
|
]
|
||||||
|
|
||||||
guessUser :: Set GuessUserInfo -> DB (Maybe UserId)
|
guessUser :: Set GuessUserInfo -> DB (Maybe UserId)
|
||||||
guessUser (Set.toList -> criteria) = $cachedHereBinary criteria $ go False
|
guessUser (Set.toList -> criteria) = $cachedHereBinary criteria $ go False
|
||||||
where
|
where
|
||||||
|
asWords :: Text -> [Text]
|
||||||
|
asWords = filter (not . Text.null) . Text.words . Text.strip
|
||||||
|
|
||||||
|
containsAsSet x y = E.and . map (\y' -> x `E.hasInfix` E.val y') $ asWords y
|
||||||
|
|
||||||
toSql user = \case
|
toSql user = \case
|
||||||
GuessUserMatrikelnummer userMatriculation' -> user E.^. UserMatrikelnummer E.==. E.val (Just userMatriculation')
|
GuessUserMatrikelnummer userMatriculation' -> user E.^. UserMatrikelnummer E.==. E.val (Just userMatriculation')
|
||||||
GuessUserDisplayName userDisplayName' -> user E.^. UserDisplayName `E.hasInfix` E.val userDisplayName'
|
GuessUserDisplayName userDisplayName' -> user E.^. UserDisplayName `containsAsSet` userDisplayName'
|
||||||
GuessUserSurname userSurname' -> user E.^. UserSurname `E.hasInfix` E.val userSurname'
|
GuessUserSurname userSurname' -> user E.^. UserSurname `containsAsSet` userSurname'
|
||||||
GuessUserFirstName userFirstName' -> user E.^. UserFirstName `E.hasInfix` E.val userFirstName'
|
GuessUserFirstName userFirstName' -> user E.^. UserFirstName `containsAsSet` userFirstName'
|
||||||
|
|
||||||
go didLdap = do
|
go didLdap = do
|
||||||
let retrieveUsers = E.select . E.from $ \user -> do
|
let retrieveUsers = E.select . E.from $ \user -> do
|
||||||
@ -58,9 +94,9 @@ guessUser (Set.toList -> criteria) = $cachedHereBinary criteria $ go False
|
|||||||
closeness :: Entity User -> Entity User -> Ordering
|
closeness :: Entity User -> Entity User -> Ordering
|
||||||
closeness = mconcat $ concat
|
closeness = mconcat $ concat
|
||||||
[ pure $ comparing (fmap Down . matchesMatriculation)
|
[ pure $ comparing (fmap Down . matchesMatriculation)
|
||||||
, (criteria ^.. folded . _guessUserSurname) <&> \surn -> comparing (preview $ _entityVal . _userSurname . to CI.mk . only (CI.mk surn))
|
, (criteria ^.. folded . _guessUserSurname) <&> \surn -> comparing (view $ _entityVal . _userSurname . to (`matchesName` surn))
|
||||||
, (criteria ^.. folded . _guessUserFirstName) <&> \firstn -> comparing (preview $ _entityVal . _userFirstName . to CI.mk . only (CI.mk firstn))
|
, (criteria ^.. folded . _guessUserFirstName) <&> \firstn -> comparing (view $ _entityVal . _userFirstName . to (`matchesName` firstn))
|
||||||
, (criteria ^.. folded . _guessUserDisplayName) <&> \dispn -> comparing (preview $ _entityVal . _userDisplayName . to CI.mk . only (CI.mk dispn))
|
, (criteria ^.. folded . _guessUserDisplayName) <&> \dispn -> comparing (view $ _entityVal . _userDisplayName . to (`matchesName` dispn))
|
||||||
]
|
]
|
||||||
|
|
||||||
doLdap userMatr = do
|
doLdap userMatr = do
|
||||||
@ -70,19 +106,17 @@ guessUser (Set.toList -> criteria) = $cachedHereBinary criteria $ go False
|
|||||||
ldapData <- campusUserMatr' ldapConf ldapPool userMatr
|
ldapData <- campusUserMatr' ldapConf ldapPool userMatr
|
||||||
for ldapData $ upsertCampusUser UpsertCampusUser
|
for ldapData $ upsertCampusUser UpsertCampusUser
|
||||||
|
|
||||||
|
if
|
||||||
case users' of
|
| x@(Entity pid _) : [] <- users'
|
||||||
x@(Entity pid _) : xs
|
, fromMaybe False (matchesMatriculation x) || didLdap
|
||||||
| [] <- xs
|
-> return $ Just pid
|
||||||
, fromMaybe False (matchesMatriculation x) || didLdap
|
| x@(Entity pid _) : x' : _ <- users'
|
||||||
-> return $ Just pid
|
, fromMaybe False (matchesMatriculation x) || didLdap
|
||||||
| x' : _ <- xs
|
, GT <- x `closeness` x'
|
||||||
, fromMaybe False (matchesMatriculation x) || didLdap
|
-> return $ Just pid
|
||||||
, GT <- x `closeness` x'
|
| not didLdap
|
||||||
-> return $ Just pid
|
, userMatr : userMatrs' <- criteria ^.. folded . _guessUserMatrikelnummer
|
||||||
| not didLdap
|
, all (== userMatr) userMatrs'
|
||||||
, userMatr : userMatrs' <- criteria ^.. folded . _guessUserMatrikelnummer
|
-> doLdap userMatr >>= maybe (go True) (return . Just)
|
||||||
, all (== userMatr) userMatrs'
|
| otherwise
|
||||||
-> doLdap userMatr >>= maybe (go True) (return . Just)
|
|
||||||
_other
|
|
||||||
-> return Nothing
|
-> return Nothing
|
||||||
|
|||||||
@ -393,11 +393,11 @@ buttonView btn = do
|
|||||||
|
|
||||||
|
|
||||||
-- | generate a form that only shows a finite amount of buttons
|
-- | generate a form that only shows a finite amount of buttons
|
||||||
buttonForm :: (Button site a, Finite a) => Html -> MForm (HandlerT site IO) (FormResult a, WidgetT site IO ())
|
buttonForm :: (MonadHandler m, Button (HandlerSite m) a, Finite a) => Html -> MForm m (FormResult a, WidgetT (HandlerSite m) IO ())
|
||||||
buttonForm = buttonForm' universeF
|
buttonForm = buttonForm' universeF
|
||||||
|
|
||||||
-- | like `buttonForm`, but for a given list of buttons, i.e. a subset or for buttons outside the Finite typeclass
|
-- | like `buttonForm`, but for a given list of buttons, i.e. a subset or for buttons outside the Finite typeclass
|
||||||
buttonForm' :: Button site a => [a] -> Html -> MForm (HandlerT site IO) (FormResult a, WidgetT site IO ())
|
buttonForm' :: (MonadHandler m, Button (HandlerSite m) a) => [a] -> Html -> MForm m (FormResult a, WidgetT (HandlerSite m) IO ())
|
||||||
buttonForm' btns csrf = do
|
buttonForm' btns csrf = do
|
||||||
(res, ($ []) -> fViews) <- aFormToForm . disambiguateButtons $ combinedButtonField btns ""
|
(res, ($ []) -> fViews) <- aFormToForm . disambiguateButtons $ combinedButtonField btns ""
|
||||||
return (res, [whamlet|
|
return (res, [whamlet|
|
||||||
@ -407,6 +407,36 @@ buttonForm' btns csrf = do
|
|||||||
^{fvInput bView}
|
^{fvInput bView}
|
||||||
|])
|
|])
|
||||||
|
|
||||||
|
withButtonForm' :: (MonadHandler m, Button (HandlerSite m) btn)
|
||||||
|
=> [btn]
|
||||||
|
-> (Html -> MForm m (FormResult a, WidgetT (HandlerSite m) IO ()))
|
||||||
|
-> (Html -> MForm m (FormResult (a, btn), WidgetT (HandlerSite m) IO ()))
|
||||||
|
withButtonForm' = withButtonFormM' . const . return
|
||||||
|
|
||||||
|
withButtonFormComb' :: (MonadHandler m, Button (HandlerSite m) btn)
|
||||||
|
=> (FormResult a -> FormResult btn -> FormResult b)
|
||||||
|
-> [btn]
|
||||||
|
-> (Html -> MForm m (FormResult a, WidgetT (HandlerSite m) IO ()))
|
||||||
|
-> (Html -> MForm m (FormResult b, WidgetT (HandlerSite m) IO ()))
|
||||||
|
withButtonFormComb' f = withButtonFormCombM' f . const . return
|
||||||
|
|
||||||
|
withButtonFormM' :: (MonadHandler m, Button (HandlerSite m) btn)
|
||||||
|
=> (FormResult a -> MForm m [btn])
|
||||||
|
-> (Html -> MForm m (FormResult a, WidgetT (HandlerSite m) IO ()))
|
||||||
|
-> (Html -> MForm m (FormResult (a, btn), WidgetT (HandlerSite m) IO ()))
|
||||||
|
withButtonFormM' = withButtonFormCombM' (\x btn -> (,) <$> x <*> btn)
|
||||||
|
|
||||||
|
withButtonFormCombM' :: (MonadHandler m, Button (HandlerSite m) btn)
|
||||||
|
=> (FormResult a -> FormResult btn -> FormResult b)
|
||||||
|
-> (FormResult a -> MForm m [btn])
|
||||||
|
-> (Html -> MForm m (FormResult a, WidgetT (HandlerSite m) IO ()))
|
||||||
|
-> (Html -> MForm m (FormResult b, WidgetT (HandlerSite m) IO ()))
|
||||||
|
withButtonFormCombM' combF mkBtns innerForm csrf = do
|
||||||
|
(innerRes, innerView) <- innerForm csrf
|
||||||
|
btns <- mkBtns innerRes
|
||||||
|
(buttonRes, btnView) <- buttonForm' btns mempty
|
||||||
|
return (combF innerRes buttonRes, innerView <> btnView)
|
||||||
|
|
||||||
-- | buttons-only form complete with widget-generation and evaluation; return type determines buttons shown.
|
-- | buttons-only form complete with widget-generation and evaluation; return type determines buttons shown.
|
||||||
runButtonForm ::(PathPiece ident, Eq ident, RenderMessage site FormMessage,
|
runButtonForm ::(PathPiece ident, Eq ident, RenderMessage site FormMessage,
|
||||||
Button site ButtonSubmit, Button site a, Finite a)
|
Button site ButtonSubmit, Button site a, Finite a)
|
||||||
|
|||||||
@ -7,6 +7,7 @@ module Utils.Parameters
|
|||||||
, lookupGlobalPostParam, hasGlobalPostParam, lookupGlobalPostParams
|
, lookupGlobalPostParam, hasGlobalPostParam, lookupGlobalPostParams
|
||||||
, lookupGlobalPostParamForm, hasGlobalPostParamForm
|
, lookupGlobalPostParamForm, hasGlobalPostParamForm
|
||||||
, globalPostParamField, globalPostParamFields
|
, globalPostParamField, globalPostParamFields
|
||||||
|
, withGlobalPostParam
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import ClassyPrelude.Yesod
|
import ClassyPrelude.Yesod
|
||||||
@ -56,6 +57,7 @@ data GlobalPostParam = PostFormIdentifier
|
|||||||
| PostMassInputShape
|
| PostMassInputShape
|
||||||
| PostBearer
|
| PostBearer
|
||||||
| PostDBCsvImportAction
|
| PostDBCsvImportAction
|
||||||
|
| PostDBCsvReImport
|
||||||
| PostLoginDummy
|
| PostLoginDummy
|
||||||
| PostExamAutoOccurrencePrevious
|
| PostExamAutoOccurrencePrevious
|
||||||
| PostLanguage
|
| PostLanguage
|
||||||
@ -94,3 +96,7 @@ globalPostParamFields ident Field{fieldParse} = fmap (fromMaybe []) . runMaybeT
|
|||||||
ts <- fromMaybe [] . Map.lookup (toPathPiece ident) <$> MaybeT askParams
|
ts <- fromMaybe [] . Map.lookup (toPathPiece ident) <$> MaybeT askParams
|
||||||
fs <- fromMaybe [] . Map.lookup (toPathPiece ident) <$> MaybeT askFiles
|
fs <- fromMaybe [] . Map.lookup (toPathPiece ident) <$> MaybeT askFiles
|
||||||
forM ((Left <$> fs) ++ (Right <$> ts)) $ \inp -> MaybeT $ either (const Nothing) id <$> lift (either (\f -> fieldParse [] [f]) (\t -> fieldParse [t] []) inp)
|
forM ((Left <$> fs) ++ (Right <$> ts)) $ \inp -> MaybeT $ either (const Nothing) id <$> lift (either (\f -> fieldParse [] [f]) (\t -> fieldParse [t] []) inp)
|
||||||
|
|
||||||
|
withGlobalPostParam :: PathPiece result => GlobalPostParam -> result -> (Html -> MForm m a) -> (Html -> MForm m a)
|
||||||
|
withGlobalPostParam (toPathPiece -> ident) (toPathPiece -> res) f csrf
|
||||||
|
= f $ csrf <> [shamlet|<input type=hidden name=#{ident} value=#{res}>|]
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
$newline never
|
||||||
<h3>_{MsgCsvColumnsExplanationsTip}
|
<h3>_{MsgCsvColumnsExplanationsTip}
|
||||||
<dl .deflist>
|
<dl .deflist>
|
||||||
$forall (colName, colExplanation) <- csvColExplanations''
|
$forall (colName, colExplanation) <- csvColExplanations''
|
||||||
|
|||||||
9
templates/table/csv-reimport.hamlet
Normal file
9
templates/table/csv-reimport.hamlet
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
$newline never
|
||||||
|
<p>^{csvImportExplanation}
|
||||||
|
<p>^{csvColExplanations'}
|
||||||
|
$maybe wgt <- csvExample
|
||||||
|
<p>
|
||||||
|
^{modal (i18n MsgCsvExampleData) (Right wgt)}
|
||||||
|
<p>
|
||||||
|
^{modal (i18n MsgCsvChangeOptionsLabel) (Left (SomeRoute CsvOptionsR))}
|
||||||
|
^{csvImportWdgt'}
|
||||||
Loading…
Reference in New Issue
Block a user