diff --git a/config/settings.yml b/config/settings.yml index 6f4368343..9a82fbf96 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -165,7 +165,7 @@ memcached-local: maximum-weight: 104857600 # 100MiB upload-cache: - host: "_env:UPLOAD_S3_HOST:" + host: "_env:UPLOAD_S3_HOST:" # should be optional, but all file transfers will be empty without an S3 cache port: "_env:UPLOAD_S3_PORT:9000" access-key: "_env:UPLOAD_S3_KEY_ID:" secret-key: "_env:UPLOAD_S3_KEY" diff --git a/messages/uniworx/categories/qualification/de-de-formal.msg b/messages/uniworx/categories/qualification/de-de-formal.msg index 5ffa99c7e..642e9cdee 100644 --- a/messages/uniworx/categories/qualification/de-de-formal.msg +++ b/messages/uniworx/categories/qualification/de-de-formal.msg @@ -11,11 +11,12 @@ CsvColumnLmsPin: PIN des E-Lernen Zugangs CsvColumnLmsResetPin: Wird die PIN bei der nächsten Synchronisation zurückgesetzt? CsvColumnLmsDelete: Wird der Identifikator in der E-Lernen Plattform bei der nächsten Synchronisation gelöscht? CsvColumnLmsStaff: Handelt es sich um einen internen Mitarbeiter? (Aus historischen Gründen, wird momentan ignoriert.) -CsvColumnLmsSuccess: Zeitstempel der erfolgreichen Teilnahme +CsvColumnLmsSuccess: Zeitstempel der erfolgreichen Teilnahme (UTC) CsvColumnLmsFailed: User was blocked by LMS, usually due to too many attempts LmsUserlistInsert: Neuer LMS User LmsUserlistUpdate: LMS User aktualisierung LmsResultInsert: Neues LMS Ergebnis LmsResultUpdate: LMS Ergebnis aktualisierung LmsResultCsvExceptionDuplicatedKey: CSV Import fand uneindeutigen Schlüssel -LmsUserlistCsvExceptionDuplicatedKey: CSV Import fand uneindeutigen Schlüssel \ No newline at end of file +LmsUserlistCsvExceptionDuplicatedKey: CSV Import fand uneindeutigen Schlüssel +LmsDirectUpload: Direkter Upload für automatisierte Systeme \ No newline at end of file diff --git a/messages/uniworx/categories/qualification/en-eu.msg b/messages/uniworx/categories/qualification/en-eu.msg index 0eeca65f9..cf100eece 100644 --- a/messages/uniworx/categories/qualification/en-eu.msg +++ b/messages/uniworx/categories/qualification/en-eu.msg @@ -11,11 +11,12 @@ CsvColumnLmsPin: PIN for E-learning access CsvColumnLmsResetPin: Will the E-learning PIN be reset upon next synchronisation? CsvColumnLmsDelete: Will the identifier be deleted from the E-learning platfrom upon next synchronisation? CsvColumnLmsStaff: Is the user an internal staff member? (Legacy, currently ignored) -CsvColumnLmsSuccess: Timestamp of successful completion +CsvColumnLmsSuccess: Timestamp of successful completion (UTC) CsvColumnLmsFailed: Blockier durch LMS, üblicherweise wegen zu vieler Fehlversuche LmsUserlistInsert: New LMS User LmsUserlistUpdate: Update of LMS User LmsResultInsert: New LMS result LmsResultUpdate: Update of LMS result LmsResultCsvExceptionDuplicatedKey: CSV import with ambiguous key -LmsUserlistCsvExceptionDuplicatedKey: CSV import with ambiguous key \ No newline at end of file +LmsUserlistCsvExceptionDuplicatedKey: CSV import with ambiguous key +LmsDirectUpload: Direct upload for automated Systems \ No newline at end of file diff --git a/messages/uniworx/utils/navigation/menu/de-de-formal.msg b/messages/uniworx/utils/navigation/menu/de-de-formal.msg index 51d4765fc..44bf1006f 100644 --- a/messages/uniworx/utils/navigation/menu/de-de-formal.msg +++ b/messages/uniworx/utils/navigation/menu/de-de-formal.msg @@ -126,4 +126,5 @@ MenuLanguage: Sprache MenuLms: Schnittstelle E-Lernen MenuLmsUsers: Empfang E-Lernen Benutzer MenuLmsUserlist: Melden E-Lernen Benutzer -MenuLmsResult: Melden Ergebnisse E-Lernen \ No newline at end of file +MenuLmsResult: Melden Ergebnisse E-Lernen +MenuLmsUpload: Direkter Upload \ No newline at end of file diff --git a/messages/uniworx/utils/navigation/menu/en-eu.msg b/messages/uniworx/utils/navigation/menu/en-eu.msg index 255a07c22..8c46e72da 100644 --- a/messages/uniworx/utils/navigation/menu/en-eu.msg +++ b/messages/uniworx/utils/navigation/menu/en-eu.msg @@ -127,4 +127,5 @@ MenuLanguage: Language MenuLms: Interface E-Learning MenuLmsUsers: Download E-Learning Users MenuLmsUserlist: Upload E-Learning Users -MenuLmsResult: Upload E-Learning Results \ No newline at end of file +MenuLmsResult: Upload E-Learning Results +MenuLmsUpload: Direct Upload \ No newline at end of file diff --git a/models/lms.model b/models/lms.model index c04e02404..1463a6b9d 100644 --- a/models/lms.model +++ b/models/lms.model @@ -10,8 +10,8 @@ Qualification -- elearningOnly Bool -- successful E-learing automatically increases validity. NO! -- refreshInvitation StoredMarkup -- hard-coded I18N-MSGs used instead, but displayed on qualification page NO! -- expiryNotification StoredMarkup Maybe -- configurable user-profile-notifcations are used instead NO! - UniqueSchoolShort school shorthand -- must be unique per school and shorthand - UniqueSchoolName school name -- must be unique per school and name + UniqueQualificationSchoolShort school shorthand -- must be unique per school and shorthand + UniqueQualificationSchoolName school name -- must be unique per school and name deriving Generic -- TODOs: @@ -99,7 +99,7 @@ LmsUser started UTCTime default=now() received UTCTime Maybe -- last acknowledgement by LMS ended UTCTime Maybe -- ident was deleted from LMS - UniqueLmsUser qualification ident + UniqueLmsUser ident -- idents must be unique accross all qualifications, since idents are global within LMS! deriving Generic -- LmsUserlist stores LMS upload for later processing only @@ -117,7 +117,7 @@ LmsResult ident LmsIdent success Day timestamp UTCTime default=now() - UniqueLmsResult qualification ident success -- required by DBTable + UniqueLmsResult qualification ident -- required by DBTable deriving Generic -- Logs all processed rows from LmsUserlist and LmsResult diff --git a/nix/docker/default.nix b/nix/docker/default.nix index ec4960492..f03f950ea 100644 --- a/nix/docker/default.nix +++ b/nix/docker/default.nix @@ -27,6 +27,7 @@ let curl wget netcat # just for manual testing within the pod, remove for production! openldap # just for manual testing within the pod, remove for production! iana-etc + unixtools.netstat htop gnugrep ] ++ optionals isDemo [ postgresql_12 memcached uniworx.uniworx.components.exes.uniworxdb ]; runAsRoot = '' diff --git a/routes b/routes index b340da62e..f76b98d79 100644 --- a/routes +++ b/routes @@ -255,8 +255,12 @@ !/*WellKnownFileName WellKnownR GET !free -- OSIS CSV Export Demo -/lms/#SchoolId/#QualificationShorthand LmsR GET POST -/lms/#SchoolId/#QualificationShorthand/users LmsUsersR GET POST -/lms/#SchoolId/#QualificationShorthand/userlist LmsUserlistR GET POST -/lms/#SchoolId/#QualificationShorthand/result LmsResultR GET POST - \ No newline at end of file +/lms/#SchoolId/#QualificationShorthand LmsR GET POST +/lms/#SchoolId/#QualificationShorthand/users LmsUsersR GET +/lms/#SchoolId/#QualificationShorthand/users/direct LmsUsersDirectR GET +/lms/#SchoolId/#QualificationShorthand/userlist LmsUserlistR GET POST +/lms/#SchoolId/#QualificationShorthand/userliss/upload LmsUserlistUploadR GET POST +/lms/#SchoolId/#QualificationShorthand/userlist/direct LmsUserlistDirectR POST +/lms/#SchoolId/#QualificationShorthand/result LmsResultR GET POST +/lms/#SchoolId/#QualificationShorthand/result/upload LmsResultUploadR GET POST +/lms/#SchoolId/#QualificationShorthand/result/direct LmsResultDirectR POST diff --git a/src/Foundation/Navigation.hs b/src/Foundation/Navigation.hs index f9b973078..39aed96e6 100644 --- a/src/Foundation/Navigation.hs +++ b/src/Foundation/Navigation.hs @@ -133,10 +133,16 @@ breadcrumb HealthR = i18nCrumb MsgMenuHealth Nothing breadcrumb InstanceR = i18nCrumb MsgMenuInstance Nothing breadcrumb StatusR = i18nCrumb MsgMenuHealth Nothing -- never displayed -breadcrumb (LmsR _sid _qsh) = i18nCrumb MsgMenuLms Nothing -breadcrumb (LmsUsersR sid qsh) = i18nCrumb MsgMenuLmsUsers $ Just $ LmsR sid qsh -breadcrumb (LmsUserlistR sid qsh) = i18nCrumb MsgMenuLmsUserlist $ Just $ LmsR sid qsh -breadcrumb (LmsResultR sid qsh) = i18nCrumb MsgMenuLmsResult $ Just $ LmsR sid qsh +breadcrumb (LmsR _sid _qsh) = i18nCrumb MsgMenuLms Nothing +breadcrumb (LmsUsersR sid qsh) = i18nCrumb MsgMenuLmsUsers $ Just $ LmsR sid qsh +breadcrumb (LmsUsersDirectR sid qsh) = i18nCrumb MsgMenuLmsUsers $ Just $ LmsUsersR sid qsh -- never displayed, TypedContent +breadcrumb (LmsUserlistR sid qsh) = i18nCrumb MsgMenuLmsUserlist $ Just $ LmsR sid qsh +breadcrumb (LmsUserlistUploadR sid qsh) = i18nCrumb MsgMenuLmsUpload $ Just $ LmsUserlistR sid qsh +breadcrumb (LmsUserlistDirectR sid qsh) = i18nCrumb MsgMenuLmsUpload $ Just $ LmsUserlistR sid qsh -- never displayed +breadcrumb (LmsResultR sid qsh) = i18nCrumb MsgMenuLmsResult $ Just $ LmsR sid qsh +breadcrumb (LmsResultUploadR sid qsh) = i18nCrumb MsgMenuLmsUpload $ Just $ LmsResultR sid qsh +breadcrumb (LmsResultDirectR sid qsh) = i18nCrumb MsgMenuLmsUpload $ Just $ LmsResultR sid qsh -- never displayed + breadcrumb ProfileR = i18nCrumb MsgBreadcrumbProfile Nothing breadcrumb SetDisplayEmailR = i18nCrumb MsgUserDisplayEmail $ Just ProfileR diff --git a/src/Handler/LMS.hs b/src/Handler/LMS.hs index 263d97cfc..a2bbbc88c 100644 --- a/src/Handler/LMS.hs +++ b/src/Handler/LMS.hs @@ -5,10 +5,12 @@ module Handler.LMS - ( getLmsR , postLmsR - , getLmsUsersR , postLmsUsersR - , getLmsUserlistR, postLmsUserlistR - , getLmsResultR , postLmsResultR + ( getLmsR , postLmsR + , getLmsUsersR , getLmsUsersDirectR + , getLmsUserlistR , postLmsUserlistR + , getLmsUserlistUploadR , postLmsUserlistUploadR, postLmsUserlistDirectR + , getLmsResultR , postLmsResultR + , getLmsResultUploadR , postLmsResultUploadR , postLmsResultDirectR ) where @@ -61,7 +63,7 @@ resultUser = _dbrOutput . _2 getLmsR, postLmsR:: SchoolId -> QualificationShorthand -> Handler Html getLmsR = postLmsR postLmsR sid qsh = do - _qid <- runDB . getKeyBy404 $ UniqueSchoolShort sid qsh + _qid <- runDB . getKeyBy404 $ UniqueQualificationSchoolShort sid qsh -- TODO !!! filter table by qid !!! dbtCsvName <- csvLmsUserFilename @@ -265,7 +267,7 @@ mkLmsTable sid qsh qid = do [ prismAForm (singletonFilter csvLmsIdent . maybePrism _PathPiece) mPrev $ aopt (hoistField lift textField) (fslI MsgTableLmsIdent) , prismAForm (singletonFilter csvLmsSuccess . maybePrism _PathPiece) mPrev $ aopt (hoistField lift checkBoxField) (fslI MsgTableLmsSuccess) ] - dbtStyle = def + dbtStyle = def { dbsFilterLayout = defaultDBSFilterLayout } dbtParams = def dbtIdent :: Text dbtIdent = "lms-result" @@ -285,7 +287,7 @@ mkLmsTable sid qsh qid = do dbtCsvDecode = Just DBTCsvDecode -- Just save to DB; Job will process data later { dbtCsvRowKey = \LmsResultTableCsv{..} -> - fmap E.Value . MaybeT . getKeyBy $ UniqueLmsResult qid csvLRTident csvLRTsuccess + fmap E.Value . MaybeT . getKeyBy $ UniqueLmsResult qid csvLRTident , dbtCsvComputeActions = \case -- purpose is to show a diff to the user first DBCsvDiffNew{dbCsvNewKey = Nothing, dbCsvNew} -> do yield $ LmsResultInsertData @@ -333,7 +335,7 @@ getLmsR, postLmsR :: SchoolId -> QualificationShorthand -> Handler Html getLmsR = postLmsR postLmsR sid qsh = do lmsTable <- runDB $ do - qid <- getKeyBy404 $ UniqueSchoolShort sid qsh + qid <- getKeyBy404 $ UniqueQualificationSchoolShort sid qsh view _2 <$> mkLmsTable sid qsh qid siteLayoutMsg MsgMenuLmsResult $ do setTitleI MsgMenuLmsResult diff --git a/src/Handler/LMS/Result.hs b/src/Handler/LMS/Result.hs index 66c3a7588..502a4a5a5 100644 --- a/src/Handler/LMS/Result.hs +++ b/src/Handler/LMS/Result.hs @@ -1,8 +1,9 @@ {-# OPTIONS_GHC -fno-warn-orphans #-} -- needed for HasEntity instances -{-# OPTIONS -Wno-unused-top-binds #-} -- TODO: remove me, for debugging only module Handler.LMS.Result ( getLmsResultR, postLmsResultR + , getLmsResultUploadR, postLmsResultUploadR + , postLmsResultDirectR ) where @@ -21,7 +22,7 @@ import qualified Database.Esqueleto.Utils as E data LmsResultTableCsv = LmsResultTableCsv { csvLRTident :: LmsIdent - , csvLRTsuccess :: Day + , csvLRTsuccess :: LmsDay } deriving Generic makeLenses_ ''LmsResultTableCsv @@ -66,7 +67,7 @@ data LmsResultCsvAction = LmsResultInsertData { lmsResultInsertIdent :: LmsIdent deriveJSON defaultOptions { constructorTagModifier = camelToPathPiece'' 2 1 -- LmsResultInsertData -> insert - , fieldLabelModifier = camelToPathPiece' 2 -- lmsResultInsertIdent -> insert-ident | lmsResultInsertSuccess -> insert-success + , fieldLabelModifier = camelToPathPiece' 2 -- lmsResultInsertIdent -> insert-ident | lmsResultInsertSuccess -> insert-success , sumEncoding = TaggedObject "action" "data" } ''LmsResultCsvAction @@ -79,12 +80,12 @@ embedRenderMessage ''UniWorX ''LmsResultCsvException id mkResultTable :: SchoolId -> QualificationShorthand -> QualificationId -> DB (Any, Widget) mkResultTable sid qsh qid = do + now_day <- utctDay <$> liftIO getCurrentTime dbtCsvName <- csvFilenameLmsResult qsh let dbtCsvSheetName = dbtCsvName let resultDBTable = DBTable{..} where - dbtSQLQuery lmsresult = do E.where_ $ lmsresult E.^. LmsResultQualification E.==. E.val qid return lmsresult @@ -108,38 +109,43 @@ mkResultTable sid qsh qid = do [ prismAForm (singletonFilter csvLmsIdent . maybePrism _PathPiece) mPrev $ aopt (hoistField lift textField) (fslI MsgTableLmsIdent) , prismAForm (singletonFilter csvLmsSuccess . maybePrism _PathPiece) mPrev $ aopt (hoistField lift dayField) (fslI MsgTableLmsSuccess) ] - dbtStyle = def + dbtStyle = def { dbsFilterLayout = defaultDBSFilterLayout } dbtParams = def dbtIdent :: Text dbtIdent = "lms-result" - dbtCsvEncode = Just DBTCsvEncode + dbtCsvEncode = Just DBTCsvEncode { dbtCsvExportForm = pure () , dbtCsvDoEncode = \() -> C.map (doEncode' . view _2) , dbtCsvName , dbtCsvSheetName , dbtCsvNoExportData = Just id , dbtCsvHeader = const $ return lmsResultTableCsvHeader - , dbtCsvExampleData = Nothing + , dbtCsvExampleData = Just + [ LmsResultTableCsv{csvLRTident = LmsIdent lid, csvLRTsuccess = LmsDay $ addDays (-dos) now_day } + | (lid,dos) <- zip ["abcdefgh", "12345678", "ident8ch"] [1..] + ] } where doEncode' = LmsResultTableCsv <$> view (_dbrOutput . _entityVal . _lmsResultIdent) - <*> view (_dbrOutput . _entityVal . _lmsResultSuccess) + <*> view (_dbrOutput . _entityVal . _lmsResultSuccess . _lmsDay) dbtCsvDecode = Just DBTCsvDecode -- Just save to DB; Job will process data later { dbtCsvRowKey = \LmsResultTableCsv{..} -> - fmap E.Value . MaybeT . getKeyBy $ UniqueLmsResult qid csvLRTident csvLRTsuccess + fmap E.Value . MaybeT . getKeyBy $ UniqueLmsResult qid csvLRTident , dbtCsvComputeActions = \case -- purpose is to show a diff to the user first DBCsvDiffNew{dbCsvNewKey = Nothing, dbCsvNew} -> do yield $ LmsResultInsertData { lmsResultInsertIdent = csvLRTident dbCsvNew - , lmsResultInsertSuccess = csvLRTsuccess dbCsvNew + , lmsResultInsertSuccess = csvLRTsuccess dbCsvNew & lms2day } DBCsvDiffNew{dbCsvNewKey = Just _, dbCsvNew = _} -> error "UniqueLmsResult was found, but the key no longer exists." -- TODO: how can this ever happen? Check Pagination-Code - DBCsvDiffExisting{dbCsvNew = LmsResultTableCsv{..}} -> do - yield $ LmsResultUpdateData - { lmsResultInsertIdent = csvLRTident - , lmsResultInsertSuccess = csvLRTsuccess - } + DBCsvDiffExisting{dbCsvNew = LmsResultTableCsv{..}, dbCsvOld} -> do + let successDay = lms2day csvLRTsuccess + when (successDay /= dbCsvOld ^. _dbrOutput . _entityVal . _lmsResultSuccess) $ + yield $ LmsResultUpdateData + { lmsResultInsertIdent = csvLRTident + , lmsResultInsertSuccess = successDay + } DBCsvDiffMissing{} -> return () -- no deletion , dbtCsvClassifyAction = \case LmsResultInsertData{} -> LmsResultInsert @@ -189,9 +195,77 @@ mkResultTable sid qsh qid = do getLmsResultR, postLmsResultR :: SchoolId -> QualificationShorthand -> Handler Html getLmsResultR = postLmsResultR postLmsResultR sid qsh = do + let directUploadLink = LmsResultUploadR sid qsh lmsTable <- runDB $ do - qid <- getKeyBy404 $ UniqueSchoolShort sid qsh + qid <- getKeyBy404 $ UniqueQualificationSchoolShort sid qsh view _2 <$> mkResultTable sid qsh qid siteLayoutMsg MsgMenuLmsResult $ do setTitleI MsgMenuLmsResult $(widgetFile "lms-result") + + +-- Direct File Upload/Download + +--saveResultCsv :: (PersistUniqueWrite backend, MonadIO m, BaseBackend backend ~ SqlBackend) => +-- Key Qualification -> LmsResultTableCsv -> ReaderT backend m () +saveResultCsv :: QualificationId -> Int -> LmsResultTableCsv -> DB Int +saveResultCsv qid i LmsResultTableCsv{..} = do + now <- liftIO getCurrentTime + void $ upsert + LmsResult + { lmsResultQualification = qid + , lmsResultIdent = csvLRTident + , lmsResultSuccess = csvLRTsuccess & lms2day + , lmsResultTimestamp = now + } + [ LmsResultSuccess =. (csvLRTsuccess & lms2day) + , LmsResultTimestamp =. now + ] + return $ succ i + +makeResultUploadForm :: Form FileInfo +makeResultUploadForm = renderAForm FormStandard $ fileAFormReq "Result CSV" + +getLmsResultUploadR, postLmsResultUploadR :: SchoolId -> QualificationShorthand -> Handler Html +getLmsResultUploadR = postLmsResultUploadR +postLmsResultUploadR sid qsh = do + ((result,widget), enctype) <- runFormPost makeResultUploadForm + case result of + FormSuccess file -> do + -- content <- fileSourceByteString file + -- return $ Just (fileName file, content) + nr <- runDB $ do + qid <- getKeyBy404 $ UniqueQualificationSchoolShort sid qsh + runConduit $ fileSource file + .| decodeCsv + .| foldMC (saveResultCsv qid) 0 + addMessage Success $ toHtml $ pack "Erfolgreicher Upload der Datei " <> fileName file <> pack (" mit " <> show nr <> " Zeilen") + redirect $ LmsResultR sid qsh + FormFailure errs -> do + forM_ errs $ addMessage Error . toHtml + redirect $ LmsResultUploadR sid qsh + FormMissing -> + siteLayoutMsg MsgMenuLmsResult $ do + setTitleI MsgMenuLmsUpload + [whamlet|$newline never +