diff --git a/CHANGELOG.md b/CHANGELOG.md index 3660df080..8bf5c370c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,40 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## [4.6.0](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/compare/v4.5.0...v4.6.0) (2019-07-26) + + +### Features + +* **exam-users:** allow missing columns in csv import ([e242013](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/e242013)) + + + +## [4.5.0](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/compare/v4.4.0...v4.5.0) (2019-07-26) + + +### Bug Fixes + +* fix merge ([38afa90](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/38afa90)) +* **csv-import:** fix incorrect map merge ([0d283fd](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/0d283fd)) +* **dbtable-ui:** fix position of submit button for pagesize ([cf35118](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/cf35118)) +* **merge:** fix build ([0bd0260](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/0bd0260)) + + +### Features + +* **alert-icons:** add custom icons for alerts ([bc67500](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/bc67500)) +* **alerticons:** allow alerts to have custom icons ([d70a958](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/d70a958)) +* **alerts js:** support custom icons in Alerts HTTP-Header ([8833cb5](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/8833cb5)) +* **corrections assignment:** add convenience to table header ([56c2fcc](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/56c2fcc)) +* **course enrolement:** show proper icons in alerts ([b2b3895](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/b2b3895)) +* **exam-users:** provide better table defaults ([a689d19](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/a689d19)) +* **exams:** csv-based grade upload ([932145c](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/932145c)) +* **exams:** show exam results ([b8b308d](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/b8b308d)) +* **users:** store first names and titles ([ceed070](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/ceed070)) + + + ## [4.4.0](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/compare/v4.3.0...v4.4.0) (2019-07-24) diff --git a/is-clean.sh b/is-clean.sh new file mode 100755 index 000000000..b63b54f46 --- /dev/null +++ b/is-clean.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +set -e + +if [ -n "$(git status --porcelain)" ]; then + echo "Working directory isn't clean" >&2 + exit 1 +fi + +if [ "$(git rev-parse --abbrev-ref HEAD)" != "master" ]; then + echo "Not on master" >&2 + exit 1 +fi + +ourHash=$(git rev-parse HEAD) +theirHash=$(git ls-remote origin -h refs/heads/master | awk '{ print $1; }') + +if [ "$theirHash" != "$ourHash" ]; then + echo "Local HEAD is not up to date with remote master" >&2 + exit 1 +fi diff --git a/messages/uniworx/de.msg b/messages/uniworx/de.msg index 7ee1af4aa..833df71e9 100644 --- a/messages/uniworx/de.msg +++ b/messages/uniworx/de.msg @@ -312,6 +312,10 @@ UnauthorizedTutorialTutor: Sie sind nicht Tutor für dieses Tutorium. UnauthorizedCourseTutor: Sie sind nicht Tutor für diesen Kurs. UnauthorizedTutor: Sie sind nicht Tutor. UnauthorizedTutorialRegisterGroup: Sie sind bereits in einem Tutorium mit derselben Registrierungs-Gruppe. +UnauthorizedLDAP: Angegebener Nutzer meldet sich nicht mit Campus-Kennung an. +UnauthorizedPWHash: Angegebener Nutzer meldet sich nicht mit Uni2work-Kennung an. + +UnauthorizedPasswordResetToken: Dieses Authorisierungs-Token kann nicht mehr zum Passwort ändern benutzt werden EMail: E-Mail EMailUnknown email@UserEmail: E-Mail #{email} gehört zu keinem bekannten Benutzer. @@ -502,7 +506,9 @@ NoUpcomingExams: In den nächsten 14 Tagen gibt es keine Klausur mit offener Reg AdminHeading: Administration AdminUserHeading: Benutzeradministration -AccessRightsFor: Berechtigungen für +AdminUserRightsHeading: Benutzerrechte +AdminUserAuthHeading: Benutzer-Authentifizierung +AdminUserHeadingFor: Benuterprofil für AdminFor: Administrator LecturerFor: Dozent LecturersFor: Dozenten @@ -651,6 +657,13 @@ MailUserRightsIntro name@Text email@UserEmail: #{name} <#{email}> hat folgende U MailNoLecturerRights: Sie haben derzeit keine Dozenten-Rechte. MailLecturerRights n@Int: Als Dozent dürfen Sie Veranstaltungen innerhalb #{pluralDE n "Ihres Instituts" "Ihrer Institute"} anlegen. +MailSubjectUserAuthModeUpdate: Änderung Ihres Uni2work-Anmeldemodus +UserAuthModePWHashChangedToLDAP: Sie melden sich nun mit Ihrer Campus-Kennung an +UserAuthModeLDAPChangedToPWHash: Sie melden sich nun mit einer Uni2work-internen Kennung an +NewPasswordLinkTip: Das Passwort Ihrer Uni2work-internen Kennung können Sie auf der folgenden Seite setzen: +NewPasswordLink: Neues Passwort setzen +AuthPWHashTip: Sie müssen nun das mit "Uni2work-Login" beschriftete Login-Formular verwenden. Stellen Sie bitte sicher, dass Sie ein Passwort gesetzt haben, bevor Sie versuchen sich anzumelden. +PasswordResetEmailIncoming: Einen Link um ihr Passwort zu setzen bzw. zu ändern bekommen Sie, aus Sicherheitsgründen, in einer separaten E-Mail. MailEditNotifications: Benachrichtigungen ein-/ausschalten MailSubjectSupport: Supportanfrage @@ -672,6 +685,8 @@ MailSubjectExamRegistrationInvitation tid@TermId ssh@SchoolId csh@CourseShorthan MailSubjectSubmissionUserInvitation tid@TermId ssh@SchoolId csh@CourseShorthand shn@SheetName: [#{tid}-#{ssh}-#{csh}] Einladung zu einer Abgabe für #{shn} +MailSubjectPasswordReset: Uni2work-Passwort ändern bzw. setzen + SheetGrading: Bewertung SheetGradingPoints maxPoints@Points: #{maxPoints} Punkte SheetGradingPassPoints maxPoints@Points passingPoints@Points: Bestanden ab #{passingPoints} von #{maxPoints} Punkten @@ -720,6 +735,13 @@ NotificationTriggerSheetInactive: Abgabezeitraum eines meiner Übungsblätter is NotificationTriggerCorrectionsAssigned: Mir wurden Abgaben zur Korrektur zugeteilt NotificationTriggerCorrectionsNotDistributed: Nicht alle Abgaben eines meiner Übungsblätter konnten einem Korrektor zugeteilt werden NotificationTriggerUserRightsUpdate: Meine Berechtigungen wurden geändert +NotificationTriggerUserAuthModeUpdate: Mein Anmelde-Modus wurde geändert + +NotificationTriggerKindAll: Für alle Benutzer +NotificationTriggerKindCourseParticipant: Für Kursteilnehmer +NotificationTriggerKindCorrector: Für Korrektoren +NotificationTriggerKindLecturer: Für Dozenten +NotificationTriggerKindAdmin: Für Administratoren CorrCreate: Abgaben erstellen UnknownPseudonymWord pseudonymWord@Text: Unbekanntes Pseudonym-Wort "#{pseudonymWord}" @@ -836,6 +858,7 @@ MenuTermShow: Semester MenuSubmissionDelete: Abgabe löschen MenuUsers: Benutzer MenuUserNotifications: Benachrichtigungs-Einstellungen +MenuUserPassword: Passwort MenuAdminTest: Admin-Demo MenuMessageList: Systemnachrichten MenuAdminErrMsg: Fehlermeldung entschlüsseln @@ -882,6 +905,7 @@ MenuExamNew: Neue Klausur anlegen MenuExamEdit: Bearbeiten MenuExamUsers: Teilnehmer MenuExamAddMembers: Klausurteilnehmer hinzufügen +MenuLecturerInvite: Dozenten hinzufügen AuthPredsInfo: Um eigene Veranstaltungen aus Sicht der Teilnehmer anzusehen, können Veranstalter und Korrektoren hier die Prüfung ihrer erweiterten Berechtigungen temporär deaktivieren. Abgewählte Prädikate schlagen immer fehl. Abgewählte Prädikate werden also nicht geprüft um Zugriffe zu gewähren, welche andernfalls nicht erlaubt wären. Diese Einstellungen gelten nur temporär bis Ihre Sitzung abgelaufen ist, d.h. bis ihr Browser-Cookie abgelaufen ist. Durch Abwahl von Prädikaten kann man sich höchstens temporär aussperren. AuthPredsActive: Aktive Authorisierungsprädikate @@ -909,6 +933,8 @@ AuthTagRated: Korrektur ist bewertet AuthTagUserSubmissions: Abgaben erfolgen durch Kursteilnehmer AuthTagCorrectorSubmissions: Abgaben erfolgen durch Korrektoren AuthTagSelf: Nutzer greift nur auf eigene Daten zu +AuthTagIsLDAP: Nutzer meldet sich mit Campus-Kennung an +AuthTagIsPWHash: Nutzer meldet sich mit Uni2work-Kennung an AuthTagAuthentication: Nutzer ist angemeldet, falls erforderlich AuthTagRead: Zugriff ist nur lesend AuthTagWrite: Zugriff ist i.A. schreibend @@ -1239,8 +1265,9 @@ Proportion c@Text of@Text prop@Rational: #{c}/#{of} (#{rationalToFixed2 (100 * p CsvColumnsExplanationsLabel: Spalten CsvColumnsExplanationsTip: Bedeutung der in der CSV-Datei enthaltenen Spalten -CsvColumnExamUserSurname: Nachname des Teilnehmers -CsvColumnExamUserName: Voller Name des Teilnehmers (inkl. Nachname) +CsvColumnExamUserSurname: Nachname(n) des Teilnehmers +CsvColumnExamUserFirstName: Vorname(n) des Teilnehmers +CsvColumnExamUserName: Voller Name des Teilnehmers (gewöhnlicherweise inkl. Vor- und Nachname(n)) CsvColumnExamUserMatriculation: Matrikelnummer des Teilnehmers CsvColumnExamUserField: Hauptfach, mit dem der Teilnehmer seine Kursanmeldung assoziiert hat CsvColumnExamUserDegree: Abschluss, den der Teilnehmer im assoziierten Hauptfach anstrebt @@ -1250,6 +1277,7 @@ CsvColumnExamUserExercisePoints: Anzahl von Punkten, die der Teilnehmer im Übun CsvColumnExamUserExercisePointsMax: Maximale Anzahl von Punkten, die der Teilnehmer im Übungsbetrieb bis zu seinem Klausurtermin erreichen hätte können CsvColumnExamUserExercisePasses: Anzahl von Übungsblättern, die der Teilnehmer bestanden hat CsvColumnExamUserExercisePassesMax: Maximale Anzahl von Übungsblättern, die der Teilnehmer bis zu seinem Klausurtermin bestehen hätte können +CsvColumnExamUserResult: Erreichte Klausurleistung; "passed", "failed", "no-show", "voided", oder eine Note ("1.0", "1.3", "1.7", ..., "4.0", "5.0") Action: Aktion @@ -1263,6 +1291,7 @@ ExamUserCsvRegister: Kursteilnehmer zur Klausur anmelden ExamUserCsvAssignOccurrence: Teilnehmern einen anderen Termin/Raum zuweisen ExamUserCsvDeregister: Teilnehmer von der Klausur abmelden ExamUserCsvSetCourseField: Kurs-assoziiertes Hauptfach ändern +ExamUserCsvSetResult: Ergebnis eintragen ExamUserCsvExceptionNoMatchingUser: Kursteilnehmer konnte nicht eindeutig identifiziert werden ExamUserCsvExceptionNoMatchingStudyFeatures: Das angegebene Studienfach konnte keinem Hauptfach des Kursteilnehmers zugeordnet werden @@ -1270,4 +1299,47 @@ ExamUserCsvExceptionNoMatchingOccurrence: Raum/Termin konnte nicht eindeutig ide TableHeadingFilter: Filter TableHeadingCsvImport: CSV-Import -TableHeadingCsvExport: CSV-Export \ No newline at end of file +TableHeadingCsvExport: CSV-Export + +ExamResultAttended: Teilgenommen +ExamResultNoShow: Nicht erschienen +ExamResultVoided: Entwertet +ExamResultNone: Kein Klausurergebnis + +BtnAuthLDAP: Auf Campus-Kennung umstellen +BtnAuthPWHash: Auf Uni2work-Kennung umstellen +BtnPasswordReset: Passwort zurücksetzen + +AuthLDAPLookupFailed: Nutzer konnte aufgrund eines LDAP-Fehlers nicht nachgeschlagen werden +AuthLDAPInvalidLookup: Bestehender Nutzer konnte nicht eindeutig einem LDAP-Eintrag zugeordnet werden +AuthLDAPAlreadyConfigured: Nutzer meldet sich bereits per Campus-Kennung an +AuthLDAPConfigured: Nutzer meldet sich nun per Campus-Kennung an + +AuthPWHashAlreadyConfigured: Nutzer meldet sich bereits per Uni2work-Kennung an +AuthPWHashConfigured: Nutzer meldet sich nun per Uni2work-Kennung an + +PasswordResetQueued: Link zum Passwort-Zurücksetzen versandt +ResetPassword: Uni2work-Passwort ändern bzw. setzen + +AuthMode: Authentifizierung +AuthLDAP: Campus +AuthPWHash pwHash@Text: Uni2work +CurrentPassword: Aktuelles Passwort +NewPassword: Neues Passwort +NewPasswordRepeat: Wiederholung +CurrentPasswordInvalid: Aktuelles Passwort ist inkorrekt +PasswordRepeatInvalid: Wiederholung stimmt nicht mit neuem Passwort überein +UserPasswordHeadingFor: Passwort ändern für +PasswordChangedSuccess: Passwort erfolgreich geändert + +LecturerInviteSchool: Institut +LecturerInviteField: Einzuladende EMail Addressen +LecturerInviteHeading: Dozenten hinzufügen + +LecturersInvited n@Int: #{n} #{pluralDE n "Dozent" "Dozenten"} per EMail eingeladen +LecturersAdded n@Int: #{n} #{pluralDE n "Dozent" "Dozenten"} eingetragen + +MailSubjectSchoolLecturerInvitation school@SchoolName: Einladung zum Dozent für „#{school}“ +MailSchoolLecturerInviteHeading school@SchoolName: Einladung zum Dozent für „#{school}“ +SchoolLecturerInviteExplanation: Sie wurden eingeladen, Dozent für ein Institut zu sein. Sie können, nachdem Sie die Einladung annehmen, eigenständig neue Kurse anlegen. +SchoolLecturerInvitationAccepted school@SchoolName: Einladung zum Dozent für „#{school}“ angenommen diff --git a/models/users b/models/users index f0b3e683e..33a92adf1 100644 --- a/models/users +++ b/models/users @@ -16,6 +16,8 @@ User json -- Each Uni2work user has a corresponding row in this table; create email (CI Text) -- Case-insensitive eMail address displayName Text -- we only show LDAP-DisplayName, and highlight LDAP-Surname within (appended if not contained) surname Text -- Display user names always through 'nameWidget displayName surname' + firstName Text -- For export in tables, pre-split firstName from displayName + title Text Maybe -- For upcoming name customisation maxFavourites Int default=12 -- max number of rows with this userId in table "CourseFavourite"; for convenience links; user-defined theme Theme default='Default' -- Color-theme of the frontend; user-defined dateTimeFormat DateTimeFormat "default='%a %d %b %Y %R'" -- preferred Date+Time display format for user; user-defined diff --git a/package-lock.json b/package-lock.json index a25132e3e..ff669ca94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "uni2work", - "version": "4.4.0", + "version": "4.6.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 4a1187eba..de7966735 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "uni2work", - "version": "4.4.0", + "version": "4.6.0", "description": "", "keywords": [], "author": "", @@ -20,7 +20,7 @@ "frontend:test:watch": "karma start --conf karma.conf.js --single-run false", "frontend:build": "webpack", "frontend:build:watch": "webpack --watch", - "prerelease": "npm run test", + "prerelease": "./is-clean.sh && npm run test", "release": "standard-version -a", "postrelease": "git push --follow-tags origin master" }, diff --git a/package.yaml b/package.yaml index 1acd174c5..a935edb8d 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: uniworx -version: 4.4.0 +version: 4.6.0 dependencies: # Due to a bug in GHC 8.0.1, we block its usage @@ -133,6 +133,7 @@ dependencies: - cassava - cassava-conduit - constraints + - memory other-extensions: - GeneralizedNewtypeDeriving diff --git a/routes b/routes index 9629b00d1..46dd746fc 100644 --- a/routes +++ b/routes @@ -24,6 +24,9 @@ -- !capacity -- course this route is associated with has at least one unit of participant capacity -- !empty -- course this route is associated with has no participants whatsoever -- +-- !is-ldap -- user has authentication mode set to LDAP +-- !is-pw-hash -- user has authentication mode set to PWHash +-- -- !materials -- only if course allows all materials to be free (no meaning outside of courses) -- !time -- access depends on time somehow -- !read -- only if it is read-only access (i.e. GET but not POST) @@ -45,6 +48,9 @@ /users/#CryptoUUIDUser/delete AdminUserDeleteR POST /users/#CryptoUUIDUser/hijack AdminHijackUserR POST !adminANDno-escalation /users/#CryptoUUIDUser/notifications UserNotificationR GET POST !self +/users/#CryptoUUIDUser/password UserPasswordR GET POST !selfANDis-pw-hash +!/users/lecturer-invite/new AdminNewLecturerInviteR GET POST +!/users/lecturer-invite AdminLecturerInviteR GET POST /admin AdminR GET /admin/features AdminFeaturesR GET POST /admin/test AdminTestR GET POST diff --git a/src/Control/Concurrent/Async/Lifted/Safe/Utils.hs b/src/Control/Concurrent/Async/Lifted/Safe/Utils.hs index f7f395b64..27dc86127 100644 --- a/src/Control/Concurrent/Async/Lifted/Safe/Utils.hs +++ b/src/Control/Concurrent/Async/Lifted/Safe/Utils.hs @@ -1,15 +1,17 @@ module Control.Concurrent.Async.Lifted.Safe.Utils - ( allocateLinkedAsync + ( allocateAsync, allocateLinkedAsync ) where import ClassyPrelude hiding (cancel) +import Control.Lens import Control.Concurrent.Async.Lifted.Safe import Control.Monad.Trans.Resource -allocateLinkedAsync :: forall m a. - MonadResource m - => IO a -> m (Async a) -allocateLinkedAsync act = allocate (async act) cancel >>= (\(_k, a) -> a <$ link a) +allocateLinkedAsync, allocateAsync :: forall m a. + MonadResource m + => IO a -> m (Async a) +allocateAsync = fmap (view _2) . flip allocate cancel . async +allocateLinkedAsync = uncurry (<$) . (id &&& link) <=< allocateAsync diff --git a/src/Database/Esqueleto/Utils.hs b/src/Database/Esqueleto/Utils.hs index 323137301..5a032a6de 100644 --- a/src/Database/Esqueleto/Utils.hs +++ b/src/Database/Esqueleto/Utils.hs @@ -11,8 +11,10 @@ module Database.Esqueleto.Utils , mkContainsFilter, mkContainsFilterWith , mkExistsFilter , anyFilter, allFilter + , orderByList , orderByOrd, orderByEnum , lower, ciEq + , selectExists ) where @@ -171,12 +173,16 @@ allFilter fltrs needle criterias = F.foldr aux true fltrs aux fltr acc = fltr needle criterias E.&&. acc +orderByList :: PersistField a => [a] -> E.SqlExpr (E.Value a) -> E.SqlExpr (E.Value Int) +orderByList vals + = let sortUni = zip [1..] vals -- memoize this, might not work due to polymorphism + in \x -> E.case_ [ (x E.==. E.val u, E.val i) | (i,u) <- sortUni ] (E.val . succ $ List.length vals) + orderByOrd :: (Ord a, Finite a, PersistField a) => E.SqlExpr (E.Value a) -> E.SqlExpr (E.Value Int) -orderByOrd = let sortUni = zip [1..] $ List.sort universeF in -- memoize this, might not work due to polymorphism - \x -> E.case_ [ (x E.==. E.val u, E.val i) | (i,u) <- sortUni ] (E.val (-1)) +orderByOrd = orderByList $ List.sort universeF orderByEnum :: (Enum a, Finite a, PersistField a) => E.SqlExpr (E.Value a) -> E.SqlExpr (E.Value Int) -orderByEnum x = E.case_ [ (x E.==. E.val u, E.val $ fromEnum u) | u <- universeF ] (E.val (-1)) +orderByEnum = orderByList $ List.sortOn fromEnum universeF lower :: E.SqlString s => E.SqlExpr (E.Value s) -> E.SqlExpr (E.Value s) @@ -184,3 +190,12 @@ lower = E.unsafeSqlFunction "LOWER" ciEq :: E.SqlString s => E.SqlExpr (E.Value s) -> E.SqlExpr (E.Value s) -> E.SqlExpr (E.Value Bool) ciEq a b = lower a E.==. lower b + + +selectExists :: forall m a. MonadIO m => E.SqlQuery a -> E.SqlReadT m Bool +selectExists query = do + res <- E.select . return . E.exists $ void query + + case res of + [E.Value b] -> return b + _other -> error "SELECT EXISTS ... returned zero or more than one rows" diff --git a/src/Foundation.hs b/src/Foundation.hs index 6f1d4681b..8998e23cc 100644 --- a/src/Foundation.hs +++ b/src/Foundation.hs @@ -33,6 +33,7 @@ import qualified Data.ByteString.Base64.URL as Base64 (encode) import Data.ByteString (ByteString) import qualified Data.ByteString.Lazy as Lazy.ByteString +import qualified Data.ByteString as ByteString import qualified Data.Text as Text import qualified Data.Text.Encoding as Text @@ -295,6 +296,7 @@ embedRenderMessage ''UniWorX ''UploadModeDescr id embedRenderMessage ''UniWorX ''SecretJSONFieldException id embedRenderMessage ''UniWorX ''AFormMessage $ concat . drop 2 . splitCamel +embedRenderMessage ''UniWorX ''AuthenticationMode id newtype SheetTypeHeader = SheetTypeHeader SheetType embedRenderMessageVariant ''UniWorX ''SheetTypeHeader ("SheetType" <>) @@ -334,6 +336,23 @@ instance RenderMessage UniWorX StudyDegreeTerm where instance RenderMessage UniWorX ExamGrade where renderMessage _ _ = pack . (showFixed False :: Deci -> String) . fromRational . review numberGrade +instance RenderMessage UniWorX ExamPassed where + renderMessage foundation ls = \case + ExamPassed True -> mr MsgExamPassed + ExamPassed False -> mr MsgExamNotPassed + where + mr :: RenderMessage UniWorX msg => msg -> Text + mr = renderMessage foundation ls + +instance RenderMessage UniWorX a => RenderMessage UniWorX (ExamResult' a) where + renderMessage foundation ls = \case + ExamAttended{..} -> mr examResult + ExamNoShow -> mr MsgExamResultNoShow + ExamVoided -> mr MsgExamResultVoided + where + mr :: RenderMessage UniWorX msg => msg -> Text + mr = renderMessage foundation ls + -- ToMessage instances for converting raw numbers to Text (no internationalization) @@ -983,6 +1002,7 @@ tagAccessPredicate AuthSelf = APHandler $ \mAuthId route _ -> exceptT return ret AdminUserDeleteR cID -> return cID AdminHijackUserR cID -> return cID UserNotificationR cID -> return cID + UserPasswordR cID -> return cID CourseR _ _ _ (CUserR cID) -> return cID _other -> throwError =<< $unsupportedAuthPredicate AuthSelf route referencedUser' <- decrypt referencedUser @@ -991,6 +1011,34 @@ tagAccessPredicate AuthSelf = APHandler $ \mAuthId route _ -> exceptT return ret | uid == referencedUser' -> return Authorized Nothing -> return AuthenticationRequired _other -> unauthorizedI MsgUnauthorizedSelf +tagAccessPredicate AuthIsLDAP = APDB $ \_ route _ -> exceptT return return $ do + referencedUser <- case route of + AdminUserR cID -> return cID + AdminUserDeleteR cID -> return cID + AdminHijackUserR cID -> return cID + UserNotificationR cID -> return cID + UserPasswordR cID -> return cID + CourseR _ _ _ (CUserR cID) -> return cID + _other -> throwError =<< $unsupportedAuthPredicate AuthIsLDAP route + referencedUser' <- decrypt referencedUser + maybeTMExceptT (unauthorizedI MsgUnauthorizedLDAP) $ do + User{..} <- MaybeT $ get referencedUser' + guard $ userAuthentication == AuthLDAP + return Authorized +tagAccessPredicate AuthIsPWHash = APDB $ \_ route _ -> exceptT return return $ do + referencedUser <- case route of + AdminUserR cID -> return cID + AdminUserDeleteR cID -> return cID + AdminHijackUserR cID -> return cID + UserNotificationR cID -> return cID + UserPasswordR cID -> return cID + CourseR _ _ _ (CUserR cID) -> return cID + _other -> throwError =<< $unsupportedAuthPredicate AuthIsPWHash route + referencedUser' <- decrypt referencedUser + maybeTMExceptT (unauthorizedI MsgUnauthorizedPWHash) $ do + User{..} <- MaybeT $ get referencedUser' + guard $ is _AuthPWHash userAuthentication + return Authorized tagAccessPredicate AuthAuthentication = APDB $ \mAuthId route _ -> case route of MessageR cID -> maybeT (unauthorizedI MsgUnauthorizedSystemMessageAuth) $ do smId <- decrypt cID @@ -1784,8 +1832,18 @@ pageActions (AdminR) = , menuItemAccessCallback' = return True } ] -pageActions (AdminUserR cID) = [ - MenuItem +pageActions (UsersR) = + [ MenuItem + { menuItemType = PageActionPrime + , menuItemLabel = MsgMenuLecturerInvite + , menuItemIcon = Nothing + , menuItemRoute = SomeRoute AdminNewLecturerInviteR + , menuItemModal = True + , menuItemAccessCallback' = return True + } + ] +pageActions (AdminUserR cID) = + [ MenuItem { menuItemType = PageActionPrime , menuItemLabel = MsgMenuUserNotifications , menuItemIcon = Nothing @@ -1793,6 +1851,17 @@ pageActions (AdminUserR cID) = [ , menuItemModal = True , menuItemAccessCallback' = return True } + , MenuItem + { menuItemType = PageActionPrime + , menuItemLabel = MsgMenuUserPassword + , menuItemIcon = Nothing + , menuItemRoute = SomeRoute $ UserPasswordR cID + , menuItemModal = True + , menuItemAccessCallback' = do + uid <- decrypt cID + User{userAuthentication} <- runDB $ get404 uid + return $ is _AuthPWHash userAuthentication + } ] pageActions (InfoR) = [ MenuItem @@ -2731,7 +2800,9 @@ instance YesodAuth UniWorX where userMatrikelnummer' = lookup (Attr "LMU-Stud-Matrikelnummer") ldapData userEmail' = lookup (Attr "mail") ldapData userDisplayName' = lookup (Attr "displayName") ldapData + userFirstName' = lookup (Attr "givenName") ldapData userSurname' = lookup (Attr "sn") ldapData + userTitle' = lookup (Attr "title") ldapData userAuthentication | isPWHash = error "PWHash should only work for users that are already known" @@ -2750,12 +2821,26 @@ instance YesodAuth UniWorX where -> return userDisplayName | otherwise -> throwError $ ServerError "Could not retrieve user name" + userFirstName <- if + | Just [bs] <- userFirstName' + , Right userFirstName <- Text.decodeUtf8' bs + -> return userFirstName + | otherwise + -> throwError $ ServerError "Could not retrieve user given name" userSurname <- if | Just [bs] <- userSurname' , Right userSurname <- Text.decodeUtf8' bs -> return userSurname | otherwise -> throwError $ ServerError "Could not retrieve user surname" + userTitle <- if + | maybe True (all ByteString.null) userTitle' + -> return Nothing + | Just [bs] <- userTitle' + , Right userTitle <- Text.decodeUtf8' bs + -> return $ Just userTitle + | otherwise + -> throwError $ ServerError "Could not retrieve user title" userMatrikelnummer <- if | Just [bs] <- userMatrikelnummer' , Right userMatrikelnummer <- Text.decodeUtf8' bs diff --git a/src/Handler/Course.hs b/src/Handler/Course.hs index f7c226ee7..c7e8c7cb6 100644 --- a/src/Handler/Course.hs +++ b/src/Handler/Course.hs @@ -1,1450 +1,20 @@ -{-# OPTIONS_GHC -fno-warn-orphans #-} - -module Handler.Course where +module Handler.Course + ( module Handler.Course + ) where import Import -import Utils.Lens -import Utils.Form --- import Utils.DB -import Handler.Utils -import Handler.Utils.Course -import Handler.Utils.Tutorial -import Handler.Utils.Communication -import Handler.Utils.Delete -import Handler.Utils.Database -import Handler.Utils.Table.Cells -import Handler.Utils.Table.Columns -import Handler.Utils.Invitations -import Database.Persist.Sql (deleteWhereCount) -import qualified Database.Esqueleto.Utils as E -import Database.Esqueleto.Utils.TH --- import Data.Time -import qualified Data.CaseInsensitive as CI -import Data.Function ((&)) --- import Yesod.Form.Bootstrap3 - -import Data.Maybe (fromJust) -import qualified Data.Set as Set -import Data.Map ((!)) -import qualified Data.Map as Map - -import qualified Database.Esqueleto as E - -import Text.Blaze.Html.Renderer.Text (renderHtml) - -import Jobs.Queue - -import Data.Aeson hiding (Result(..)) - -import Text.Hamlet (ihamlet) - -import Control.Monad.Trans.Writer (WriterT, execWriterT) -import Control.Monad.Except (MonadError(..)) - -import Generics.Deriving.Monoid (memptydefault, mappenddefault) - - --- Dedicated CourseRegistrationButton -data ButtonCourseRegister = BtnCourseRegister | BtnCourseDeregister - deriving (Enum, Eq, Ord, Bounded, Read, Show, Generic, Typeable) -instance Universe ButtonCourseRegister -instance Finite ButtonCourseRegister -nullaryPathPiece ''ButtonCourseRegister $ camelToPathPiece' 1 -embedRenderMessage ''UniWorX ''ButtonCourseRegister id -instance Button UniWorX ButtonCourseRegister where - btnClasses BtnCourseRegister = [BCIsButton, BCPrimary] - btnClasses BtnCourseDeregister = [BCIsButton, BCDanger] - - btnLabel BtnCourseRegister = [whamlet|#{iconEnrol True} _{MsgBtnCourseRegister}|] - btnLabel BtnCourseDeregister = [whamlet|#{iconEnrol False} _{MsgBtnCourseDeregister}|] - - --- NOTE: Outdated way to use dbTable; see ProfileDataR Handler for a more recent method. -type CourseTableData = DBRow (Entity Course, Int, Bool, Entity School) - -colCourse :: IsDBTable m a => Colonnade Sortable CourseTableData (DBCell m a) -colCourse = sortable (Just "course") (i18nCell MsgCourse) - $ \DBRow{ dbrOutput=(Entity _ Course{..}, _, _, _) } -> - anchorCell (CourseR courseTerm courseSchool courseShorthand CShowR) - [whamlet|_{courseName}|] - --- colCourseDescr :: IsDBTable m a => Colonnade Sortable CourseTableData (DBCell m a) --- colCourseDescr = sortable (Just "course") (i18nCell MsgCourse) $ do --- course <- view $ _dbrOutput . _1 . _entityVal --- return $ courseCell course - -colDescription :: IsDBTable m a => Colonnade Sortable CourseTableData (DBCell m a) -colDescription = sortable Nothing mempty - $ \DBRow{ dbrOutput=(Entity _ Course{..}, _, _, _) } -> - case courseDescription of - Nothing -> mempty - (Just descr) -> cell $ modal (toWidget $ hasComment True) (Right $ toWidget descr) - -colCShort :: IsDBTable m a => Colonnade Sortable CourseTableData (DBCell m a) -colCShort = sortable (Just "cshort") (i18nCell MsgCourseShort) - $ \DBRow{ dbrOutput=(Entity _ Course{..}, _, _, _) } -> - anchorCell (CourseR courseTerm courseSchool courseShorthand CShowR) [whamlet|_{courseShorthand}|] - --- colCShortDescr :: IsDBTable m a => Colonnade Sortable CourseTableData (DBCell m a) --- colCShortDescr = sortable (Just "cshort") (i18nCell MsgCourseShort) --- $ \DBRow{ dbrOutput=(Entity _ Course{..}, _, _, _) } -> mappend --- ( anchorCell (CourseR courseTerm courseSchool courseShorthand CShowR) [whamlet|#{display courseShorthand}|] ) --- ( case courseDescription of --- Nothing -> mempty --- (Just descr) -> cell --- [whamlet| --- $newline never ---
- $if isRegistered - _{MsgExamRegistered} - $else - _{MsgExamNotRegistered} - |] - wrapForm examRegisterForm def - { formAction = Just . SomeRoute $ CExamR tid ssh csh examName ERegisterR - , formEncoding = examRegisterEnctype - , formSubmit = FormNoSubmit - } - | fromMaybe False registered = Just [whamlet|_{MsgExamRegistered}|] - | otherwise = Nothing - - let heading = prependCourseTitle tid ssh csh $ CI.original examName - - siteLayoutMsg heading $ do - setTitleI heading - let - gradingKeyW :: [Points] -> Widget - gradingKeyW bounds - = let boundWidgets :: [Widget] - boundWidgets = toWidget . (pack :: String -> Text) . showFixed True <$> 0 : bounds - grades :: [ExamGrade] - grades = universeF - in $(widgetFile "widgets/gradingKey") - - examBonusW :: ExamBonusRule -> Widget - examBonusW bonusRule = $(widgetFile "widgets/bonusRule") - $(widgetFile "exam-show") - -type ExamUserTableExpr = (E.SqlExpr (Entity ExamRegistration) `E.InnerJoin` E.SqlExpr (Entity User)) `E.LeftOuterJoin` E.SqlExpr (Maybe (Entity ExamOccurrence)) `E.LeftOuterJoin` (E.SqlExpr (Maybe (Entity CourseParticipant)) `E.LeftOuterJoin` (E.SqlExpr (Maybe (Entity StudyFeatures)) `E.InnerJoin` E.SqlExpr (Maybe (Entity StudyDegree)) `E.InnerJoin` E.SqlExpr (Maybe (Entity StudyTerms)))) -type ExamUserTableData = DBRow (Entity ExamRegistration, Entity User, Maybe (Entity ExamOccurrence), Maybe (Entity StudyFeatures), Maybe (Entity StudyDegree), Maybe (Entity StudyTerms)) - -instance HasEntity ExamUserTableData User where - hasEntity = _dbrOutput . _2 - -instance HasUser ExamUserTableData where - hasUser = _dbrOutput . _2 . _entityVal - -_userTableOccurrence :: Lens' ExamUserTableData (Maybe (Entity ExamOccurrence)) -_userTableOccurrence = _dbrOutput . _3 - -queryUser :: ExamUserTableExpr -> E.SqlExpr (Entity User) -queryUser = $(sqlIJproj 2 2) . $(sqlLOJproj 3 1) - -queryStudyFeatures :: ExamUserTableExpr -> E.SqlExpr (Maybe (Entity StudyFeatures)) -queryStudyFeatures = $(sqlIJproj 3 1) . $(sqlLOJproj 2 2) . $(sqlLOJproj 3 3) - -queryExamRegistration :: ExamUserTableExpr -> E.SqlExpr (Entity ExamRegistration) -queryExamRegistration = $(sqlIJproj 2 1) . $(sqlLOJproj 3 1) - -queryExamOccurrence :: ExamUserTableExpr -> E.SqlExpr (Maybe (Entity ExamOccurrence)) -queryExamOccurrence = $(sqlLOJproj 3 2) - -queryStudyDegree :: ExamUserTableExpr -> E.SqlExpr (Maybe (Entity StudyDegree)) -queryStudyDegree = $(sqlIJproj 3 2) . $(sqlLOJproj 2 2) . $(sqlLOJproj 3 3) - -queryStudyField :: ExamUserTableExpr -> E.SqlExpr (Maybe (Entity StudyTerms)) -queryStudyField = $(sqlIJproj 3 3) . $(sqlLOJproj 2 2) . $(sqlLOJproj 3 3) - -resultExamRegistration :: Lens' ExamUserTableData (Entity ExamRegistration) -resultExamRegistration = _dbrOutput . _1 - -resultUser :: Lens' ExamUserTableData (Entity User) -resultUser = _dbrOutput . _2 - -resultStudyFeatures :: Traversal' ExamUserTableData (Entity StudyFeatures) -resultStudyFeatures = _dbrOutput . _4 . _Just - -resultStudyDegree :: Traversal' ExamUserTableData (Entity StudyDegree) -resultStudyDegree = _dbrOutput . _5 . _Just - -resultStudyField :: Traversal' ExamUserTableData (Entity StudyTerms) -resultStudyField = _dbrOutput . _6 . _Just - -resultExamOccurrence :: Traversal' ExamUserTableData (Entity ExamOccurrence) -resultExamOccurrence = _dbrOutput . _3 . _Just - -data ExamUserTableCsv = ExamUserTableCsv - { csvEUserSurname :: Maybe Text - , csvEUserName :: Maybe Text - , csvEUserMatriculation :: Maybe Text - , csvEUserField :: Maybe Text - , csvEUserDegree :: Maybe Text - , csvEUserSemester :: Maybe Int - , csvEUserOccurrence :: Maybe (CI Text) - , csvEUserExercisePoints :: Maybe Points - , csvEUserExercisePasses :: Maybe Int - , csvEUserExercisePointsMax :: Maybe Points - , csvEUserExercisePassesMax :: Maybe Int - } - deriving (Generic) - -examUserTableCsvOptions :: Csv.Options -examUserTableCsvOptions = Csv.defaultOptions { Csv.fieldLabelModifier = camelToPathPiece' 3 } - -instance ToNamedRecord ExamUserTableCsv where - toNamedRecord = Csv.genericToNamedRecord examUserTableCsvOptions - -instance FromNamedRecord ExamUserTableCsv where - parseNamedRecord = Csv.genericParseNamedRecord examUserTableCsvOptions - -instance DefaultOrdered ExamUserTableCsv where - headerOrder = Csv.genericHeaderOrder examUserTableCsvOptions - -instance CsvColumnsExplained ExamUserTableCsv where - csvColumnsExplanations = genericCsvColumnsExplanations examUserTableCsvOptions $ Map.fromList - [ ('csvEUserSurname , MsgCsvColumnExamUserSurname ) - , ('csvEUserName , MsgCsvColumnExamUserName ) - , ('csvEUserMatriculation , MsgCsvColumnExamUserMatriculation ) - , ('csvEUserField , MsgCsvColumnExamUserField ) - , ('csvEUserDegree , MsgCsvColumnExamUserDegree ) - , ('csvEUserSemester , MsgCsvColumnExamUserSemester ) - , ('csvEUserOccurrence , MsgCsvColumnExamUserOccurrence ) - , ('csvEUserExercisePoints , MsgCsvColumnExamUserExercisePoints ) - , ('csvEUserExercisePasses , MsgCsvColumnExamUserExercisePasses ) - , ('csvEUserExercisePointsMax, MsgCsvColumnExamUserExercisePointsMax ) - , ('csvEUserExercisePassesMax, MsgCsvColumnExamUserExercisePassesMax ) - ] - -data ExamUserAction = ExamUserDeregister - | ExamUserAssignOccurrence - deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic, Typeable) - -instance Universe ExamUserAction -instance Finite ExamUserAction -nullaryPathPiece ''ExamUserAction $ camelToPathPiece' 2 -embedRenderMessage ''UniWorX ''ExamUserAction id - -data ExamUserActionData = ExamUserDeregisterData - | ExamUserAssignOccurrenceData (Maybe ExamOccurrenceId) - -data ExamUserCsvActionClass - = ExamUserCsvCourseRegister - | ExamUserCsvRegister - | ExamUserCsvAssignOccurrence - | ExamUserCsvSetCourseField - | ExamUserCsvDeregister - deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic, Typeable) -embedRenderMessage ''UniWorX ''ExamUserCsvActionClass id - -data ExamUserCsvAction - = ExamUserCsvCourseRegisterData - { examUserCsvActUser :: UserId - , examUserCsvActCourseField :: Maybe StudyFeaturesId - , examUserCsvActOccurrence :: Maybe ExamOccurrenceId - } - | ExamUserCsvRegisterData - { examUserCsvActUser :: UserId - , examUserCsvActOccurrence :: Maybe ExamOccurrenceId - } - | ExamUserCsvAssignOccurrenceData - { examUserCsvActRegistration :: ExamRegistrationId - , examUserCsvActOccurrence :: Maybe ExamOccurrenceId - } - | ExamUserCsvSetCourseFieldData - { examUserCsvActCourseParticipant :: CourseParticipantId - , examUserCsvActCourseField :: Maybe StudyFeaturesId - } - | ExamUserCsvDeregisterData - { examUserCsvActRegistration :: ExamRegistrationId - } - deriving (Eq, Ord, Read, Show, Generic, Typeable) -deriveJSON defaultOptions - { constructorTagModifier = over Text.packed $ Text.intercalate "-" . map Text.toLower . drop 3 . dropEnd 1 . splitCamel - , fieldLabelModifier = camelToPathPiece' 3 - , sumEncoding = TaggedObject "action" "data" - } ''ExamUserCsvAction - -data ExamUserCsvException - = ExamUserCsvExceptionNoMatchingUser - | ExamUserCsvExceptionNoMatchingStudyFeatures - | ExamUserCsvExceptionNoMatchingOccurrence - deriving (Show, Generic, Typeable) - -instance Exception ExamUserCsvException - -embedRenderMessage ''UniWorX ''ExamUserCsvException id - -getEUsersR, postEUsersR :: TermId -> SchoolId -> CourseShorthand -> ExamName -> Handler Html -getEUsersR = postEUsersR -postEUsersR tid ssh csh examn = do - (registrationResult, examUsersTable) <- runDB $ do - exam@(Entity eid Exam{..}) <- fetchExam tid ssh csh examn - bonus <- examBonus exam - - let - allBoni = (mappend <$> normalSummary <*> bonusSummary) $ fold bonus - showPasses = numSheetsPasses allBoni /= 0 - showPoints = getSum (numSheetsPoints allBoni) /= 0 - - let - examUsersDBTable = DBTable{..} - where - dbtSQLQuery ((examRegistration `E.InnerJoin` user) `E.LeftOuterJoin` occurrence `E.LeftOuterJoin` (courseParticipant `E.LeftOuterJoin` (studyFeatures `E.InnerJoin` studyDegree `E.InnerJoin` studyField))) = do - E.on $ studyField E.?. StudyTermsId E.==. studyFeatures E.?. StudyFeaturesField - E.on $ studyDegree E.?. StudyDegreeId E.==. studyFeatures E.?. StudyFeaturesDegree - E.on $ studyFeatures E.?. StudyFeaturesId E.==. E.joinV (courseParticipant E.?. CourseParticipantField) - E.on $ courseParticipant E.?. CourseParticipantCourse E.==. E.just (E.val examCourse) - E.&&. courseParticipant E.?. CourseParticipantUser E.==. E.just (user E.^. UserId) - E.on $ occurrence E.?. ExamOccurrenceExam E.==. E.just (E.val eid) - E.&&. occurrence E.?. ExamOccurrenceId E.==. examRegistration E.^. ExamRegistrationOccurrence - E.on $ examRegistration E.^. ExamRegistrationUser E.==. user E.^. UserId - E.where_ $ examRegistration E.^. ExamRegistrationExam E.==. E.val eid - return (examRegistration, user, occurrence, studyFeatures, studyDegree, studyField) - dbtRowKey = queryExamRegistration >>> (E.^. ExamRegistrationId) - dbtProj = return - dbtColonnade = mconcat $ catMaybes - [ pure $ dbSelect (applying _2) id $ return . view (resultExamRegistration . _entityKey) - , pure $ colUserNameLink (CourseR tid ssh csh . CUserR) - , pure colUserMatriclenr - , pure $ colField resultStudyField - , pure $ colDegreeShort resultStudyDegree - , pure $ colFeaturesSemester resultStudyFeatures - , pure $ sortable (Just "occurrence") (i18nCell MsgExamOccurrence) $ maybe mempty (anchorCell' (\n -> CExamR tid ssh csh examn EShowR :#: [st|exam-occurrence__#{n}|]) id . examOccurrenceName . entityVal) . view _userTableOccurrence - , guardOn showPasses $ sortable Nothing (i18nCell MsgAchievedPasses) $ \(view $ resultUser . _entityKey -> uid) -> fromMaybe mempty $ do - SheetGradeSummary{achievedPasses} <- examBonusAchieved uid bonus - SheetGradeSummary{numSheetsPasses} <- examBonusPossible uid bonus - return $ propCell (getSum achievedPasses) (getSum numSheetsPasses) - , guardOn showPoints $ sortable Nothing (i18nCell MsgAchievedPoints) $ \(view $ resultUser . _entityKey -> uid) -> fromMaybe mempty $ do - SheetGradeSummary{achievedPoints} <- examBonusAchieved uid bonus - SheetGradeSummary{sumSheetsPoints} <- examBonusPossible uid bonus - return $ propCell (getSum achievedPoints) (getSum sumSheetsPoints) - ] - dbtSorting = Map.fromList - [ sortUserNameLink queryUser - , sortUserSurname queryUser - , sortUserDisplayName queryUser - , sortUserMatriclenr queryUser - , sortField queryStudyField - , sortDegreeShort queryStudyDegree - , sortFeaturesSemester queryStudyFeatures - , ("occurrence", SortColumn $ queryExamOccurrence >>> (E.?. ExamOccurrenceName)) - ] - dbtFilter = Map.fromList - [ fltrUserNameEmail queryUser - , fltrUserMatriclenr queryUser - , fltrField queryStudyField - , fltrDegree queryStudyDegree - , fltrFeaturesSemester queryStudyFeatures - , ("occurrence", FilterColumn . E.mkContainsFilterWith Just $ queryExamOccurrence >>> (E.?. ExamOccurrenceName)) - ] - dbtFilterUI mPrev = mconcat - [ fltrUserNameEmailUI mPrev - , fltrUserMatriclenrUI mPrev - , fltrFieldUI mPrev - , fltrDegreeUI mPrev - , fltrFeaturesSemesterUI mPrev - , prismAForm (singletonFilter "occurrence") mPrev $ aopt textField (fslI MsgExamOccurrence) - ] - dbtStyle = def { dbsFilterLayout = defaultDBSFilterLayout } - dbtParams = DBParamsForm - { dbParamsFormMethod = POST - , dbParamsFormAction = Just . SomeRoute $ CExamR tid ssh csh examn EUsersR - , dbParamsFormAttrs = [] - , dbParamsFormSubmit = FormSubmit - , dbParamsFormAdditional = \csrf -> do - let - actionMap :: Map ExamUserAction (AForm Handler ExamUserActionData) - actionMap = Map.fromList - [ ( ExamUserDeregister - , pure ExamUserDeregisterData - ) - , ( ExamUserAssignOccurrence - , ExamUserAssignOccurrenceData - <$> aopt (examOccurrenceField eid) (fslI MsgExamOccurrence) (Just Nothing) - ) - ] - (res, formWgt) <- multiActionM actionMap (fslI MsgAction) Nothing csrf - let formRes = (, mempty) . First . Just <$> res - return (formRes, formWgt) - , dbParamsFormEvaluate = liftHandlerT . runFormPost - , dbParamsFormResult = id - , dbParamsFormIdent = def - } - dbtIdent :: Text - dbtIdent = "exam-users" - dbtCsvEncode :: DBTCsvEncode ExamUserTableData ExamUserTableCsv - dbtCsvEncode = DictJust . C.map $ ExamUserTableCsv - <$> view (resultUser . _entityVal . _userSurname . to Just) - <*> view (resultUser . _entityVal . _userDisplayName . to Just) - <*> view (resultUser . _entityVal . _userMatrikelnummer) - <*> preview (resultStudyField . _entityVal . to (\StudyTerms{..} -> studyTermsName <|> studyTermsShorthand <|> Just (tshow studyTermsKey)) . _Just) - <*> preview (resultStudyDegree . _entityVal . to (\StudyDegree{..} -> studyDegreeName <|> studyDegreeShorthand <|> Just (tshow studyDegreeKey)) . _Just) - <*> preview (resultStudyFeatures . _entityVal . _studyFeaturesSemester) - <*> preview (resultExamOccurrence . _entityVal . _examOccurrenceName) - <*> preview (resultUser . _entityKey . to (examBonusAchieved ?? bonus) . _Just . _achievedPoints . _Wrapped) - <*> preview (resultUser . _entityKey . to (examBonusAchieved ?? bonus) . _Just . _achievedPasses . _Wrapped . integral) - <*> preview (resultUser . _entityKey . to (examBonusPossible ?? bonus) . _Just . _sumSheetsPoints . _Wrapped) - <*> preview (resultUser . _entityKey . to (examBonusPossible ?? bonus) . _Just . _numSheetsPasses . _Wrapped . integral) - dbtCsvDecode = Just DBTCsvDecode - { dbtCsvRowKey = \csv -> do - uid <- lift $ view _2 <$> guessUser csv - fmap E.Value . MaybeT . getKeyBy $ UniqueExamRegistration eid uid - , dbtCsvComputeActions = \case - DBCsvDiffMissing{dbCsvOldKey} - -> yield . ExamUserCsvDeregisterData $ E.unValue dbCsvOldKey - DBCsvDiffNew{dbCsvNewKey = Just _} - -> fail "An UniqueExamRegistration could be found, but the ExamRegistrationKey is not among the existing keys" - DBCsvDiffNew{dbCsvNewKey = Nothing, dbCsvNew} -> do - (isPart, uid) <- lift $ guessUser dbCsvNew - if - | isPart -> do - yieldM $ ExamUserCsvRegisterData <$> pure uid <*> lookupOccurrence dbCsvNew - newFeatures <- lift $ lookupStudyFeatures dbCsvNew - Just (Entity cpId CourseParticipant{ courseParticipantField = oldFeatures }) <- lift . getBy $ UniqueParticipant uid examCourse - when (newFeatures /= oldFeatures) $ - yield $ ExamUserCsvSetCourseFieldData cpId newFeatures - | otherwise -> - yieldM $ ExamUserCsvCourseRegisterData uid <$> lookupStudyFeatures dbCsvNew <*> lookupOccurrence dbCsvNew - DBCsvDiffExisting{..} -> do - newOccurrence <- lift $ lookupOccurrence dbCsvNew - when (newOccurrence /= dbCsvOld ^? resultExamOccurrence . _entityKey) $ - yield $ ExamUserCsvAssignOccurrenceData (E.unValue dbCsvOldKey) newOccurrence - - newFeatures <- lift $ lookupStudyFeatures dbCsvNew - when (newFeatures /= dbCsvOld ^? resultStudyFeatures . _entityKey) $ do - Just (Entity cpId _) <- lift . getBy . flip UniqueParticipant examCourse $ dbCsvOld ^. resultUser . _entityKey - yield $ ExamUserCsvSetCourseFieldData cpId newFeatures - , dbtCsvClassifyAction = \case - ExamUserCsvCourseRegisterData{} -> ExamUserCsvCourseRegister - ExamUserCsvRegisterData{} -> ExamUserCsvRegister - ExamUserCsvDeregisterData{} -> ExamUserCsvDeregister - ExamUserCsvAssignOccurrenceData{} -> ExamUserCsvAssignOccurrence - ExamUserCsvSetCourseFieldData{} -> ExamUserCsvSetCourseField - , dbtCsvCoarsenActionClass = \case - ExamUserCsvCourseRegister -> DBCsvActionNew - ExamUserCsvRegister -> DBCsvActionNew - ExamUserCsvDeregister -> DBCsvActionMissing - _other -> DBCsvActionExisting - , dbtCsvExecuteActions = do - C.mapM_ $ \case - ExamUserCsvCourseRegisterData{..} -> do - now <- liftIO getCurrentTime - insert_ CourseParticipant - { courseParticipantCourse = examCourse - , courseParticipantUser = examUserCsvActUser - , courseParticipantRegistration = now - , courseParticipantField = examUserCsvActCourseField - } - User{userIdent} <- getJust examUserCsvActUser - audit' $ TransactionExamRegister (unTermKey tid) (unSchoolKey ssh) csh examn userIdent - insert_ ExamRegistration - { examRegistrationExam = eid - , examRegistrationUser = examUserCsvActUser - , examRegistrationOccurrence = examUserCsvActOccurrence - , examRegistrationTime = now - } - ExamUserCsvRegisterData{..} -> do - examRegistrationTime <- liftIO getCurrentTime - insert_ ExamRegistration - { examRegistrationExam = eid - , examRegistrationUser = examUserCsvActUser - , examRegistrationOccurrence = examUserCsvActOccurrence - , .. - } - ExamUserCsvAssignOccurrenceData{..} -> - update examUserCsvActRegistration [ ExamRegistrationOccurrence =. examUserCsvActOccurrence ] - ExamUserCsvSetCourseFieldData{..} -> - update examUserCsvActCourseParticipant [ CourseParticipantField =. examUserCsvActCourseField ] - ExamUserCsvDeregisterData{..} -> do - ExamRegistration{examRegistrationUser} <- getJust examUserCsvActRegistration - User{userIdent} <- getJust examRegistrationUser - audit' $ TransactionExamDeregister (unTermKey tid) (unSchoolKey ssh) csh examn userIdent - delete examUserCsvActRegistration - return $ CExamR tid ssh csh examn EUsersR - , dbtCsvRenderKey = \(registeredUserName -> registeredUserName') -> \case - ExamUserCsvCourseRegisterData{..} -> do - (User{..}, occ) <- liftHandlerT . runDB $ (,) <$> getJust examUserCsvActUser <*> for examUserCsvActOccurrence getJust - [whamlet| - $newline never - ^{nameWidget userDisplayName userSurname} - $maybe features <- examUserCsvActCourseField - , ^{studyFeaturesWidget features} - $nothing - , _{MsgCourseStudyFeatureNone} - $maybe ExamOccurrence{examOccurrenceName} <- occ - \ (#{examOccurrenceName}) - $nothing - \ (_{MsgExamNoOccurrence}) - |] - ExamUserCsvRegisterData{..} -> do - (User{..}, occ) <- liftHandlerT . runDB $ (,) <$> getJust examUserCsvActUser <*> for examUserCsvActOccurrence getJust - [whamlet| - $newline never - ^{nameWidget userDisplayName userSurname} - $maybe ExamOccurrence{examOccurrenceName} <- occ - \ (#{examOccurrenceName}) - $nothing - \ (_{MsgExamNoOccurrence}) - |] - ExamUserCsvAssignOccurrenceData{..} -> do - occ <- for examUserCsvActOccurrence $ liftHandlerT . runDB . getJust - [whamlet| - $newline never - ^{registeredUserName' examUserCsvActRegistration} - $maybe ExamOccurrence{examOccurrenceName} <- occ - \ (#{examOccurrenceName}) - $nothing - \ (_{MsgExamNoOccurrence}) - |] - ExamUserCsvSetCourseFieldData{..} -> do - User{..} <- liftHandlerT . runDB $ getJust . courseParticipantUser =<< getJust examUserCsvActCourseParticipant - [whamlet| - $newline never - ^{nameWidget userDisplayName userSurname} - $maybe features <- examUserCsvActCourseField - , ^{studyFeaturesWidget features} - $nothing - , _{MsgCourseStudyFeatureNone} - |] - ExamUserCsvDeregisterData{..} - -> registeredUserName' examUserCsvActRegistration - , dbtCsvRenderActionClass = toWidget <=< ap getMessageRender . pure - , dbtCsvRenderException = ap getMessageRender . pure :: ExamUserCsvException -> DB Text - } - where - studyFeaturesWidget :: StudyFeaturesId -> Widget - studyFeaturesWidget featId = do - (StudyFeatures{studyFeaturesSemester}, (degree, terms)) <- liftHandlerT . runDB . ($ featId) . runKleisli $ Kleisli getJust >>> Kleisli return &&& Kleisli (getJust . studyFeaturesDegree) &&& Kleisli (getJust . studyFeaturesField) - [whamlet| - $newline never - _{StudyDegreeTerm degree terms}, _{MsgStudyFeatureAge} #{studyFeaturesSemester} - |] - - registeredUserName :: Map (E.Value ExamRegistrationId) ExamUserTableData -> ExamRegistrationId -> Widget - registeredUserName existing (E.Value -> registration) = nameWidget userDisplayName userSurname - where - Entity _ User{..} = view resultUser $ existing ! registration - - guessUser :: ExamUserTableCsv -> DB (Bool, UserId) - guessUser ExamUserTableCsv{..} = $cachedHereBinary (csvEUserMatriculation, csvEUserName, csvEUserSurname) $ do - users <- E.select . E.from $ \user -> do - E.where_ . E.and $ catMaybes - [ (user E.^. UserMatrikelnummer E.==.) . E.val . Just <$> csvEUserMatriculation - , (user E.^. UserDisplayName E.==.) . E.val <$> csvEUserName - , (user E.^. UserSurname E.==.) . E.val <$> csvEUserSurname - ] - let isCourseParticipant = E.exists . E.from $ \courseParticipant -> - E.where_ $ courseParticipant E.^. CourseParticipantCourse E.==. E.val examCourse - E.&&. courseParticipant E.^. CourseParticipantUser E.==. user E.^. UserId - E.limit 2 - return $ (isCourseParticipant, user E.^. UserId) - case users of - (filter . view $ _1 . _Value -> [(E.Value isPart, E.Value uid)]) - -> return (isPart, uid) - [(E.Value isPart, E.Value uid)] - -> return (isPart, uid) - _other - -> throwM ExamUserCsvExceptionNoMatchingUser - - lookupOccurrence :: ExamUserTableCsv -> DB (Maybe ExamOccurrenceId) - lookupOccurrence ExamUserTableCsv{..} = $cachedHereBinary (CI.foldedCase <$> csvEUserOccurrence) . for csvEUserOccurrence $ \occName -> do - occIds <- selectKeysList [ ExamOccurrenceName ==. occName, ExamOccurrenceExam ==. eid ] [] - case occIds of - [occId] -> return occId - _other -> throwM ExamUserCsvExceptionNoMatchingOccurrence - - lookupStudyFeatures :: ExamUserTableCsv -> DB (Maybe StudyFeaturesId) - lookupStudyFeatures csv@ExamUserTableCsv{..} = do - uid <- view _2 <$> guessUser csv - studyFeatures <- E.select . E.from $ \(studyFeatures `E.InnerJoin` studyDegree `E.InnerJoin` studyTerms) -> do - E.on $ studyTerms E.^. StudyTermsId E.==. studyFeatures E.^. StudyFeaturesField - E.on $ studyDegree E.^. StudyDegreeId E.==. studyFeatures E.^. StudyFeaturesDegree - E.where_ . E.and $ catMaybes - [ do - field <- csvEUserField - return . E.or $ catMaybes - [ Just $ studyTerms E.^. StudyTermsName `E.ciEq` E.just (E.val field) - , Just $ studyTerms E.^. StudyTermsShorthand `E.ciEq` E.just (E.val field) - , (studyTerms E.^. StudyTermsKey E.==.) . E.val <$> readMay field - ] - , do - degree <- csvEUserDegree - return . E.or $ catMaybes - [ Just $ studyDegree E.^. StudyDegreeName `E.ciEq` E.just (E.val degree) - , Just $ studyDegree E.^. StudyDegreeShorthand `E.ciEq` E.just (E.val degree) - , (studyDegree E.^. StudyDegreeKey E.==.) . E.val <$> readMay degree - ] - , (studyFeatures E.^. StudyFeaturesSemester E.==.) . E.val <$> csvEUserSemester - ] - E.where_ $ studyFeatures E.^. StudyFeaturesUser E.==. E.val uid - E.&&. studyFeatures E.^. StudyFeaturesType E.==. E.val FieldPrimary - E.&&. studyFeatures E.^. StudyFeaturesValid E.==. E.val True - E.limit 2 - return $ studyFeatures E.^. StudyFeaturesId - case studyFeatures of - [E.Value fid] -> return $ Just fid - _other - | is _Nothing csvEUserField - , is _Nothing csvEUserDegree - , is _Nothing csvEUserSemester - -> return Nothing - _other -> throwM ExamUserCsvExceptionNoMatchingStudyFeatures - - examUsersDBTableValidator = def - - postprocess :: FormResult (First ExamUserActionData, DBFormResult ExamRegistrationId Bool ExamUserTableData) -> FormResult (ExamUserActionData, Set ExamRegistrationId) - postprocess inp = do - (First (Just act), regMap) <- inp - let regSet = Map.keysSet . Map.filter id $ getDBFormResult (const False) regMap - return (act, regSet) - over _1 postprocess <$> dbTable examUsersDBTableValidator examUsersDBTable - - formResult registrationResult $ \case - (ExamUserDeregisterData, selectedRegistrations) -> do - nrDel <- runDB $ deleteWhereCount - [ ExamRegistrationId <-. Set.toList selectedRegistrations - ] - addMessageI Success $ MsgExamUsersDeregistered nrDel - redirect $ CExamR tid ssh csh examn EUsersR - (ExamUserAssignOccurrenceData occId, selectedRegistrations) -> do - nrUpdated <- runDB $ updateWhereCount - [ ExamRegistrationId <-. Set.toList selectedRegistrations - ] - [ ExamRegistrationOccurrence =. occId - ] - addMessageI Success $ MsgExamUsersOccurrenceUpdated nrUpdated - redirect $ CExamR tid ssh csh examn EUsersR - - siteLayoutMsg (prependCourseTitle tid ssh csh MsgExamUsersHeading) $ do - setTitleI $ prependCourseTitle tid ssh csh MsgExamUsersHeading - $(widgetFile "exam-users") - - -instance IsInvitableJunction ExamRegistration where - type InvitationFor ExamRegistration = Exam - data InvitableJunction ExamRegistration = JunctionExamRegistration - { jExamRegistrationOccurrence :: Maybe ExamOccurrenceId - , jExamRegistrationTime :: UTCTime - } deriving (Eq, Ord, Read, Show, Generic, Typeable) - data InvitationDBData ExamRegistration = InvDBDataExamRegistration - { invDBExamRegistrationOccurrence :: Maybe ExamOccurrenceId - , invDBExamRegistrationDeadline :: UTCTime - , invDBExamRegistrationCourseRegister :: Bool - } deriving (Eq, Ord, Read, Show, Generic, Typeable) - data InvitationTokenData ExamRegistration = InvTokenDataExamRegistration - deriving (Eq, Ord, Read, Show, Generic, Typeable) - - _InvitableJunction = iso - (\ExamRegistration{..} -> (examRegistrationUser, examRegistrationExam, JunctionExamRegistration examRegistrationOccurrence examRegistrationTime)) - (\(examRegistrationUser, examRegistrationExam, JunctionExamRegistration examRegistrationOccurrence examRegistrationTime) -> ExamRegistration{..}) - -instance ToJSON (InvitableJunction ExamRegistration) where - toJSON = genericToJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 1 } - toEncoding = genericToEncoding defaultOptions { fieldLabelModifier = camelToPathPiece' 1 } -instance FromJSON (InvitableJunction ExamRegistration) where - parseJSON = genericParseJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 1 } - -instance ToJSON (InvitationDBData ExamRegistration) where - toJSON = genericToJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 4 } - toEncoding = genericToEncoding defaultOptions { fieldLabelModifier = camelToPathPiece' 4 } -instance FromJSON (InvitationDBData ExamRegistration) where - parseJSON = genericParseJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 4 } - -instance ToJSON (InvitationTokenData ExamRegistration) where - toJSON = genericToJSON defaultOptions { constructorTagModifier = camelToPathPiece' 4 } - toEncoding = genericToEncoding defaultOptions { constructorTagModifier = camelToPathPiece' 4 } -instance FromJSON (InvitationTokenData ExamRegistration) where - parseJSON = genericParseJSON defaultOptions { constructorTagModifier = camelToPathPiece' 4 } - -examRegistrationInvitationConfig :: InvitationConfig ExamRegistration -examRegistrationInvitationConfig = InvitationConfig{..} - where - invitationRoute (Entity _ Exam{..}) _ = do - Course{..} <- get404 examCourse - return $ CExamR courseTerm courseSchool courseShorthand examName EInviteR - invitationResolveFor = do - Just (CExamR tid csh ssh examn EInviteR) <- getCurrentRoute - fetchExamId tid csh ssh examn - invitationSubject (Entity _ Exam{..}) _ = do - Course{..} <- get404 examCourse - return . SomeMessage $ MsgMailSubjectExamRegistrationInvitation courseTerm courseSchool courseShorthand examName - invitationHeading (Entity _ Exam{..}) _ = return . SomeMessage $ MsgExamRegistrationInviteHeading examName - invitationExplanation _ _ = [ihamlet|_{SomeMessage MsgExamRegistrationInviteExplanation}|] - invitationTokenConfig _ (InvDBDataExamRegistration{..}, _) = do - itAuthority <- liftHandlerT requireAuthId - let itExpiresAt = Just $ Just invDBExamRegistrationDeadline - itAddAuth - | not invDBExamRegistrationCourseRegister - = Just . PredDNF . Set.singleton . impureNonNull . Set.singleton $ PLVariable AuthCourseRegistered - | otherwise - = Nothing - itStartsAt = Nothing - return $ InvitationTokenConfig{..} - invitationRestriction _ _ = return Authorized - invitationForm (Entity _ Exam{..}) (InvDBDataExamRegistration{..}, _) uid = hoistAForm liftHandlerT . wFormToAForm $ do - isRegistered <- fmap (is _Just) . liftHandlerT . runDB . getBy $ UniqueParticipant uid examCourse - now <- liftIO getCurrentTime - - case (isRegistered, invDBExamRegistrationCourseRegister) of - (False, False) -> permissionDeniedI MsgUnauthorizedParticipant - (False, True ) -> do - fieldRes <- wreq (studyFeaturesPrimaryFieldFor False [] $ Just uid) (fslI MsgCourseStudyFeature) Nothing - return $ (JunctionExamRegistration invDBExamRegistrationOccurrence now, ) . Just <$> fieldRes - (True , _ ) -> return $ pure (JunctionExamRegistration invDBExamRegistrationOccurrence now, Nothing) - invitationInsertHook (Entity _ Exam{..}) _ ExamRegistration{..} mField act = do - whenIsJust mField $ - insert_ . CourseParticipant examCourse examRegistrationUser examRegistrationTime - - Course{..} <- get404 examCourse - User{..} <- get404 examRegistrationUser - let doAudit = audit' $ TransactionExamRegister (unTermKey courseTerm) (unSchoolKey courseSchool) courseShorthand examName userIdent - act <* doAudit - invitationSuccessMsg (Entity _ Exam{..}) _ = return . SomeMessage $ MsgExamRegistrationInvitationAccepted examName - invitationUltDest (Entity _ Exam{..}) _ = do - Course{..} <- get404 examCourse - return . SomeRoute $ CExamR courseTerm courseSchool courseShorthand examName EShowR - -data AddRecipientsResult = AddRecipientsResult - { aurAlreadyRegistered - , aurNoUniquePrimaryField - , aurNoCourseRegistration - , aurSuccess :: [UserEmail] - } deriving (Read, Show, Generic, Typeable) - -instance Monoid AddRecipientsResult where - mempty = memptydefault - mappend = mappenddefault - - -getEAddUserR, postEAddUserR :: TermId -> SchoolId -> CourseShorthand -> ExamName -> Handler Html -getEAddUserR = postEAddUserR -postEAddUserR tid ssh csh examn = do - eEnt@(Entity eid Exam{..}) <- runDB $ fetchExam tid ssh csh examn - ((usersToEnlist,formWgt),formEncoding) <- runFormPost . renderWForm FormStandard $ do - now <- liftIO getCurrentTime - occurrences <- liftHandlerT . runDB $ selectList [ExamOccurrenceExam ==. eid] [] - - let - localNow = utcToLocalTime now - tomorrowEndOfDay = case localTimeToUTC (LocalTime (addDays 2 $ localDay localNow) midnight) of - LTUUnique utc' _ -> utc' - _other -> UTCTime (addDays 2 $ utctDay now) 0 - earliestDate = getOption . fmap getMin $ mconcat - [ Option $ Min <$> examStart - , foldMap (Option . Just . Min . examOccurrenceStart . entityVal) occurrences - ] - modifiedEarliestDate = earliestDate <&> \earliestDate'@(utcToLocalTime -> localEarliestDate') - -> case localTimeToUTC (LocalTime (addDays (-1) $ localDay localEarliestDate') midnight) of - LTUUnique utc' _ -> utc' - _other -> UTCTime (addDays (-1) $ utctDay earliestDate') 0 - defDeadline - | Just registerTo <- examRegisterTo - , registerTo > now - = registerTo - | Just earliestDate' <- modifiedEarliestDate - = max tomorrowEndOfDay earliestDate' - | otherwise - = tomorrowEndOfDay - - deadline <- wreq utcTimeField (fslI MsgExamRegistrationInviteDeadline) (Just defDeadline) - enlist <- wpopt checkBoxField (fslI MsgExamRegistrationEnlistDirectly) (Just False) - registerCourse <- wpopt checkBoxField (fslI MsgExamRegistrationRegisterCourse & setTooltip MsgExamRegistrationRegisterCourseTip) (Just False) - occurrence <- wopt (examOccurrenceField eid) (fslI MsgExamOccurrence) Nothing - users <- wreq (multiUserField (maybe True not $ formResultToMaybe enlist) Nothing) - (fslI MsgExamRegistrationInviteField & setTooltip MsgMultiEmailFieldTip) Nothing - return $ (,,,) <$> deadline <*> registerCourse <*> occurrence <*> users - - formResultModal usersToEnlist (CExamR tid ssh csh examn EUsersR) $ processUsers eEnt - - let heading = prependCourseTitle tid ssh csh MsgExamParticipantsRegisterHeading - - siteLayoutMsg heading $ do - setTitleI heading - wrapForm formWgt def - { formEncoding - , formAction = Just . SomeRoute $ CExamR tid ssh csh examn EAddUserR - } - where - processUsers :: Entity Exam -> (UTCTime, Bool, Maybe ExamOccurrenceId, Set (Either UserEmail UserId)) -> WriterT [Message] Handler () - processUsers (Entity eid Exam{..}) (deadline, registerCourse, occId, users) = do - let (emails,uids) = partitionEithers $ Set.toList users - AddRecipientsResult alreadyRegistered registeredNoField noCourseRegistration registeredOneField <- lift . runDBJobs $ do - -- send Invitation eMails to unkown users - sinkInvitationsF examRegistrationInvitationConfig [(mail,eid,(InvDBDataExamRegistration occId deadline registerCourse, InvTokenDataExamRegistration)) | mail <- emails] - -- register known users - execWriterT $ mapM (registerUser examCourse eid registerCourse occId) uids - - when (not $ null emails) $ - tell . pure <=< messageI Success . MsgExamParticipantsInvited $ length emails - - when (not $ null alreadyRegistered) $ - tell . pure <=< messageI Success . MsgExamRegistrationParticipantsRegistered $ length registeredOneField - - when (not $ null registeredNoField) $ do - let modalTrigger = [whamlet|_{MsgExamRegistrationRegisteredWithoutField (length registeredNoField)}|] - modalContent = $(widgetFile "messages/examRegistrationInvitationRegisteredWithoutField") - tell . pure <=< messageWidget Warning $ msgModal modalTrigger (Right modalContent) - - when (not $ null noCourseRegistration) $ do - let modalTrigger = [whamlet|_{MsgExamRegistrationNotRegisteredWithoutCourse (length noCourseRegistration)}|] - modalContent = $(widgetFile "messages/examRegistrationInvitationNotRegisteredWithoutCourse") - tell . pure <=< messageWidget Error $ msgModal modalTrigger (Right modalContent) - - when (not $ null registeredOneField) $ - tell . pure <=< messageI Success . MsgExamRegistrationAndCourseParticipantsRegistered $ length registeredOneField - - registerUser :: CourseId -> ExamId -> Bool -> Maybe ExamOccurrenceId -> UserId -> WriterT AddRecipientsResult (YesodJobDB UniWorX) () - registerUser cid eid registerCourse occId uid = exceptT tell tell $ do - User{..} <- lift . lift $ getJust uid - now <- liftIO getCurrentTime - - let - examRegister :: YesodJobDB UniWorX () - examRegister = do - insert_ $ ExamRegistration eid uid occId now - audit' $ TransactionExamRegister (unTermKey tid) (unSchoolKey ssh) csh examn userIdent - - whenM (lift . lift . existsBy $ UniqueExamRegistration eid uid) $ - throwError $ mempty { aurAlreadyRegistered = pure userEmail } - - whenM (lift . lift . existsBy $ UniqueParticipant uid cid) $ do - lift $ lift examRegister - throwError $ mempty { aurSuccess = pure userEmail } - - unless registerCourse $ - throwError $ mempty { aurNoCourseRegistration = pure userEmail } - - features <- lift . lift $ selectKeysList [ StudyFeaturesUser ==. uid, StudyFeaturesValid ==. True, StudyFeaturesType ==. FieldPrimary ] [] - - let courseParticipantField - | [f] <- features = Just f - | otherwise = Nothing - - lift . lift . insert_ $ CourseParticipant - { courseParticipantCourse = cid - , courseParticipantUser = uid - , courseParticipantRegistration = now - , .. - } - lift $ lift examRegister - - return $ case courseParticipantField of - Nothing -> mempty { aurNoUniquePrimaryField = pure userEmail } - Just _ -> mempty { aurSuccess = pure userEmail } - - -getEInviteR, postEInviteR :: TermId -> SchoolId -> CourseShorthand -> ExamName -> Handler Html -getEInviteR = postEInviteR -postEInviteR _ _ _ _ = invitationR' examRegistrationInvitationConfig - -postERegisterR :: TermId -> SchoolId -> CourseShorthand -> ExamName -> Handler Html -postERegisterR tid ssh csh examn = do - Entity uid User{..} <- requireAuth - - Entity eId Exam{..} <- runDB $ fetchExam tid ssh csh examn - - ((btnResult, _), _) <- runFormPost buttonForm - - formResult btnResult $ \case - BtnExamRegister -> do - runDB $ do - now <- liftIO getCurrentTime - insert_ $ ExamRegistration eId uid Nothing now - audit' $ TransactionExamRegister (unTermKey tid) (unSchoolKey ssh) csh examn userIdent - addMessageIconI Success IconExamRegisterTrue (MsgExamRegisteredSuccess examn) - redirect $ CExamR tid ssh csh examn EShowR - BtnExamDeregister -> do - runDB $ do - deleteBy $ UniqueExamRegistration eId uid - audit' $ TransactionExamDeregister (unTermKey tid) (unSchoolKey ssh) csh examn userIdent - addMessageIconI Info IconExamRegisterFalse (MsgExamDeregisteredSuccess examn) - -- yes, it's a success message, but it should be visually different from a positive success, since most will just note the positive green color! See discussion on commit 5f4925a4 - redirect $ CExamR tid ssh csh examn EShowR - - invalidArgs ["Register/Deregister button required"] +module Handler.Exam + ( module Handler.Exam + ) where + +import Handler.Exam.List as Handler.Exam +import Handler.Exam.Register as Handler.Exam +import Handler.Exam.CorrectorInvite as Handler.Exam +import Handler.Exam.RegistrationInvite as Handler.Exam +import Handler.Exam.New as Handler.Exam +import Handler.Exam.Edit as Handler.Exam +import Handler.Exam.Show as Handler.Exam +import Handler.Exam.Users as Handler.Exam +import Handler.Exam.AddUser as Handler.Exam diff --git a/src/Handler/Exam/AddUser.hs b/src/Handler/Exam/AddUser.hs new file mode 100644 index 000000000..b34b52288 --- /dev/null +++ b/src/Handler/Exam/AddUser.hs @@ -0,0 +1,154 @@ +module Handler.Exam.AddUser + ( getEAddUserR, postEAddUserR + ) where + +import Import hiding (Option(..)) +import Handler.Exam.RegistrationInvite + +import Handler.Utils +import Handler.Utils.Exam +import Handler.Utils.Invitations + +import Utils.Lens + +import qualified Data.Set as Set + +import Data.Semigroup (Option(..)) + +import Control.Monad.Trans.Writer (WriterT, execWriterT) +import Control.Monad.Error.Class (MonadError(..)) + +import Jobs.Queue + +import Generics.Deriving.Monoid + + +data AddRecipientsResult = AddRecipientsResult + { aurAlreadyRegistered + , aurNoUniquePrimaryField + , aurNoCourseRegistration + , aurSuccess :: [UserEmail] + } deriving (Read, Show, Generic, Typeable) + +instance Monoid AddRecipientsResult where + mempty = memptydefault + mappend = mappenddefault + + +getEAddUserR, postEAddUserR :: TermId -> SchoolId -> CourseShorthand -> ExamName -> Handler Html +getEAddUserR = postEAddUserR +postEAddUserR tid ssh csh examn = do + eEnt@(Entity eid Exam{..}) <- runDB $ fetchExam tid ssh csh examn + ((usersToEnlist,formWgt),formEncoding) <- runFormPost . renderWForm FormStandard $ do + now <- liftIO getCurrentTime + occurrences <- liftHandlerT . runDB $ selectList [ExamOccurrenceExam ==. eid] [] + + let + localNow = utcToLocalTime now + tomorrowEndOfDay = case localTimeToUTC (LocalTime (addDays 2 $ localDay localNow) midnight) of + LTUUnique utc' _ -> utc' + _other -> UTCTime (addDays 2 $ utctDay now) 0 + earliestDate = getOption . fmap getMin $ mconcat + [ Option $ Min <$> examStart + , foldMap (Option . Just . Min . examOccurrenceStart . entityVal) occurrences + ] + modifiedEarliestDate = earliestDate <&> \earliestDate'@(utcToLocalTime -> localEarliestDate') + -> case localTimeToUTC (LocalTime (addDays (-1) $ localDay localEarliestDate') midnight) of + LTUUnique utc' _ -> utc' + _other -> UTCTime (addDays (-1) $ utctDay earliestDate') 0 + defDeadline + | Just registerTo <- examRegisterTo + , registerTo > now + = registerTo + | Just earliestDate' <- modifiedEarliestDate + = max tomorrowEndOfDay earliestDate' + | otherwise + = tomorrowEndOfDay + + deadline <- wreq utcTimeField (fslI MsgExamRegistrationInviteDeadline) (Just defDeadline) + enlist <- wpopt checkBoxField (fslI MsgExamRegistrationEnlistDirectly) (Just False) + registerCourse <- wpopt checkBoxField (fslI MsgExamRegistrationRegisterCourse & setTooltip MsgExamRegistrationRegisterCourseTip) (Just False) + occurrence <- wopt (examOccurrenceField eid) (fslI MsgExamOccurrence) Nothing + users <- wreq (multiUserField (maybe True not $ formResultToMaybe enlist) Nothing) + (fslI MsgExamRegistrationInviteField & setTooltip MsgMultiEmailFieldTip) Nothing + return $ (,,,) <$> deadline <*> registerCourse <*> occurrence <*> users + + formResultModal usersToEnlist (CExamR tid ssh csh examn EUsersR) $ processUsers eEnt + + let heading = prependCourseTitle tid ssh csh MsgExamParticipantsRegisterHeading + + siteLayoutMsg heading $ do + setTitleI heading + wrapForm formWgt def + { formEncoding + , formAction = Just . SomeRoute $ CExamR tid ssh csh examn EAddUserR + } + where + processUsers :: Entity Exam -> (UTCTime, Bool, Maybe ExamOccurrenceId, Set (Either UserEmail UserId)) -> WriterT [Message] Handler () + processUsers (Entity eid Exam{..}) (deadline, registerCourse, occId, users) = do + let (emails,uids) = partitionEithers $ Set.toList users + AddRecipientsResult alreadyRegistered registeredNoField noCourseRegistration registeredOneField <- lift . runDBJobs $ do + -- send Invitation eMails to unkown users + sinkInvitationsF examRegistrationInvitationConfig [(mail,eid,(InvDBDataExamRegistration occId deadline registerCourse, InvTokenDataExamRegistration)) | mail <- emails] + -- register known users + execWriterT $ mapM (registerUser examCourse eid registerCourse occId) uids + + unless (null emails) $ + tell . pure <=< messageI Success . MsgExamParticipantsInvited $ length emails + + unless (null alreadyRegistered) $ + tell . pure <=< messageI Success . MsgExamRegistrationParticipantsRegistered $ length registeredOneField + + unless (null registeredNoField) $ do + let modalTrigger = [whamlet|_{MsgExamRegistrationRegisteredWithoutField (length registeredNoField)}|] + modalContent = $(widgetFile "messages/examRegistrationInvitationRegisteredWithoutField") + tell . pure <=< messageWidget Warning $ msgModal modalTrigger (Right modalContent) + + unless (null noCourseRegistration) $ do + let modalTrigger = [whamlet|_{MsgExamRegistrationNotRegisteredWithoutCourse (length noCourseRegistration)}|] + modalContent = $(widgetFile "messages/examRegistrationInvitationNotRegisteredWithoutCourse") + tell . pure <=< messageWidget Error $ msgModal modalTrigger (Right modalContent) + + unless (null registeredOneField) $ + tell . pure <=< messageI Success . MsgExamRegistrationAndCourseParticipantsRegistered $ length registeredOneField + + registerUser :: CourseId -> ExamId -> Bool -> Maybe ExamOccurrenceId -> UserId -> WriterT AddRecipientsResult (YesodJobDB UniWorX) () + registerUser cid eid registerCourse occId uid = exceptT tell tell $ do + User{..} <- lift . lift $ getJust uid + now <- liftIO getCurrentTime + + let + examRegister :: YesodJobDB UniWorX () + examRegister = do + insert_ $ ExamRegistration eid uid occId now + audit' $ TransactionExamRegister (unTermKey tid) (unSchoolKey ssh) csh examn userIdent + + whenM (lift . lift . existsBy $ UniqueExamRegistration eid uid) $ + throwError $ mempty { aurAlreadyRegistered = pure userEmail } + + whenM (lift . lift . existsBy $ UniqueParticipant uid cid) $ do + lift $ lift examRegister + throwError $ mempty { aurSuccess = pure userEmail } + + unless registerCourse $ + throwError $ mempty { aurNoCourseRegistration = pure userEmail } + + features <- lift . lift $ selectKeysList [ StudyFeaturesUser ==. uid, StudyFeaturesValid ==. True, StudyFeaturesType ==. FieldPrimary ] [] + + let courseParticipantField + | [f] <- features = Just f + | otherwise = Nothing + + lift . lift . insert_ $ CourseParticipant + { courseParticipantCourse = cid + , courseParticipantUser = uid + , courseParticipantRegistration = now + , .. + } + lift $ lift examRegister + + return $ case courseParticipantField of + Nothing -> mempty { aurNoUniquePrimaryField = pure userEmail } + Just _ -> mempty { aurSuccess = pure userEmail } + + diff --git a/src/Handler/Exam/CorrectorInvite.hs b/src/Handler/Exam/CorrectorInvite.hs new file mode 100644 index 000000000..cc2882679 --- /dev/null +++ b/src/Handler/Exam/CorrectorInvite.hs @@ -0,0 +1,80 @@ +{-# OPTIONS_GHC -fno-warn-orphans #-} + +module Handler.Exam.CorrectorInvite + ( InvitableJunction(..) + , InvitationDBData(..) + , InvitationTokenData(..) + , examCorrectorInvitationConfig + , getECInviteR, postECInviteR + ) where + +import Import +import Handler.Utils.Invitations +import Handler.Utils.Exam + +import Utils.Lens + +import Text.Hamlet (ihamlet) + +import Data.Aeson hiding (Result(..)) + + +instance IsInvitableJunction ExamCorrector where + type InvitationFor ExamCorrector = Exam + data InvitableJunction ExamCorrector = JunctionExamCorrector + deriving (Eq, Ord, Read, Show, Generic, Typeable) + data InvitationDBData ExamCorrector = InvDBDataExamCorrector + deriving (Eq, Ord, Read, Show, Generic, Typeable) + data InvitationTokenData ExamCorrector = InvTokenDataExamCorrector + deriving (Eq, Ord, Read, Show, Generic, Typeable) + + _InvitableJunction = iso + (\ExamCorrector{..} -> (examCorrectorUser, examCorrectorExam, JunctionExamCorrector)) + (\(examCorrectorUser, examCorrectorExam, JunctionExamCorrector) -> ExamCorrector{..}) + +instance ToJSON (InvitableJunction ExamCorrector) where + toJSON = genericToJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 1 } + toEncoding = genericToEncoding defaultOptions { fieldLabelModifier = camelToPathPiece' 1 } +instance FromJSON (InvitableJunction ExamCorrector) where + parseJSON = genericParseJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 1 } + +instance ToJSON (InvitationDBData ExamCorrector) where + toJSON = genericToJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 4 } + toEncoding = genericToEncoding defaultOptions { fieldLabelModifier = camelToPathPiece' 4 } +instance FromJSON (InvitationDBData ExamCorrector) where + parseJSON = genericParseJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 4 } + +instance ToJSON (InvitationTokenData ExamCorrector) where + toJSON = genericToJSON defaultOptions { constructorTagModifier = camelToPathPiece' 4 } + toEncoding = genericToEncoding defaultOptions { constructorTagModifier = camelToPathPiece' 4 } +instance FromJSON (InvitationTokenData ExamCorrector) where + parseJSON = genericParseJSON defaultOptions { constructorTagModifier = camelToPathPiece' 4 } + +examCorrectorInvitationConfig :: InvitationConfig ExamCorrector +examCorrectorInvitationConfig = InvitationConfig{..} + where + invitationRoute (Entity _ Exam{..}) _ = do + Course{..} <- get404 examCourse + return $ CExamR courseTerm courseSchool courseShorthand examName ECInviteR + invitationResolveFor _ = do + Just (CExamR tid csh ssh examn ECInviteR) <- getCurrentRoute + fetchExamId tid csh ssh examn + invitationSubject (Entity _ Exam{..}) _ = do + Course{..} <- get404 examCourse + return . SomeMessage $ MsgMailSubjectExamCorrectorInvitation courseTerm courseSchool courseShorthand examName + invitationHeading (Entity _ Exam{..}) _ = return . SomeMessage $ MsgExamCorrectorInviteHeading examName + invitationExplanation _ _ = [ihamlet|_{SomeMessage MsgExamCorrectorInviteExplanation}|] + invitationTokenConfig _ _ = do + itAuthority <- liftHandlerT requireAuthId + return $ InvitationTokenConfig itAuthority Nothing Nothing Nothing + invitationRestriction _ _ = return Authorized + invitationForm _ _ _ = pure (JunctionExamCorrector, ()) + invitationInsertHook _ _ _ _ = id + invitationSuccessMsg (Entity _ Exam{..}) _ = return . SomeMessage $ MsgExamCorrectorInvitationAccepted examName + invitationUltDest (Entity _ Exam{..}) _ = do + Course{..} <- get404 examCourse + return . SomeRoute $ CExamR courseTerm courseSchool courseShorthand examName EShowR + +getECInviteR, postECInviteR :: TermId -> SchoolId -> CourseShorthand -> ExamName -> Handler Html +getECInviteR = postECInviteR +postECInviteR = invitationR examCorrectorInvitationConfig diff --git a/src/Handler/Exam/Edit.hs b/src/Handler/Exam/Edit.hs new file mode 100644 index 000000000..06abd7834 --- /dev/null +++ b/src/Handler/Exam/Edit.hs @@ -0,0 +1,133 @@ +module Handler.Exam.Edit + ( getEEditR, postEEditR + ) where + +import Import +import Handler.Exam.Form +import Handler.Exam.CorrectorInvite + +import Utils.Lens + +import qualified Data.Set as Set + +import Handler.Utils +import Handler.Utils.Exam +import Handler.Utils.Invitations + +import Jobs.Queue + + +getEEditR, postEEditR :: TermId -> SchoolId -> CourseShorthand -> ExamName -> Handler Html +getEEditR = postEEditR +postEEditR tid ssh csh examn = do + (cid, eId, template) <- runDB $ do + (cid, exam@(Entity eId _)) <- fetchCourseIdExam tid ssh csh examn + + template <- examFormTemplate exam + + return (cid, eId, template) + + ((editExamResult, editExamWidget), editExamEnctype) <- runFormPost . validateForm validateExam . examForm $ Just template + + formResult editExamResult $ \ExamForm{..} -> do + insertRes <- runDBJobs $ do + insertRes <- myReplaceUnique eId Exam + { examCourse = cid + , examName = efName + , examGradingRule = efGradingRule + , examBonusRule = efBonusRule + , examOccurrenceRule = efOccurrenceRule + , examVisibleFrom = efVisibleFrom + , examRegisterFrom = efRegisterFrom + , examRegisterTo = efRegisterTo + , examDeregisterUntil = efDeregisterUntil + , examPublishOccurrenceAssignments = efPublishOccurrenceAssignments + , examStart = efStart + , examEnd = efEnd + , examFinished = efFinished + , examClosed = efClosed + , examPublicStatistics = efPublicStatistics + , examShowGrades = efShowGrades + , examDescription = efDescription + } + + when (is _Nothing insertRes) $ do + occIds <- fmap catMaybes . forM (Set.toList efOccurrences) $ traverse decrypt . eofId + deleteWhere [ ExamOccurrenceExam ==. eId, ExamOccurrenceId /<-. occIds ] + forM_ (Set.toList efOccurrences) $ \case + ExamOccurrenceForm{ eofId = Nothing, .. } -> insert_ + ExamOccurrence + { examOccurrenceExam = eId + , examOccurrenceName = eofName + , examOccurrenceRoom = eofRoom + , examOccurrenceCapacity = eofCapacity + , examOccurrenceStart = eofStart + , examOccurrenceEnd = eofEnd + , examOccurrenceDescription = eofDescription + } + ExamOccurrenceForm{ .. } -> void . runMaybeT $ do + cID <- hoistMaybe eofId + eofId' <- decrypt cID + oldOcc <- MaybeT $ get eofId' + guard $ examOccurrenceExam oldOcc == eId + lift $ replace eofId' ExamOccurrence + { examOccurrenceExam = eId + , examOccurrenceName = eofName + , examOccurrenceRoom = eofRoom + , examOccurrenceCapacity = eofCapacity + , examOccurrenceStart = eofStart + , examOccurrenceEnd = eofEnd + , examOccurrenceDescription = eofDescription + } + + + pIds <- fmap catMaybes . forM (Set.toList efExamParts) $ traverse decrypt . epfId + deleteWhere [ ExamPartExam ==. eId, ExamPartId /<-. pIds ] + forM_ (Set.toList efExamParts) $ \case + ExamPartForm{ epfId = Nothing, .. } -> insert_ + ExamPart + { examPartExam = eId + , examPartName = epfName + , examPartMaxPoints = epfMaxPoints + , examPartWeight = epfWeight + } + ExamPartForm{ .. } -> void . runMaybeT $ do + cID <- hoistMaybe epfId + epfId' <- decrypt cID + oldPart <- MaybeT $ get epfId' + guard $ examPartExam oldPart == eId + lift $ replace epfId' ExamPart + { examPartExam = eId + , examPartName = epfName + , examPartMaxPoints = epfMaxPoints + , examPartWeight = epfWeight + } + + + let (invites, adds) = partitionEithers $ Set.toList efCorrectors + + deleteWhere [ ExamCorrectorExam ==. eId ] + insertMany_ $ map (ExamCorrector eId) adds + + deleteWhere [ InvitationFor ==. invRef @ExamCorrector eId, InvitationEmail /<-. invites ] + sinkInvitationsF examCorrectorInvitationConfig $ map (, eId, (InvDBDataExamCorrector, InvTokenDataExamCorrector)) invites + + return insertRes + + case insertRes of + Just _ -> addMessageI Error $ MsgExamNameTaken efName + Nothing -> do + addMessageI Success $ MsgExamEdited efName + redirect $ CExamR tid ssh csh efName EShowR + + let heading = prependCourseTitle tid ssh csh . MsgExamEditHeading $ efName template + + siteLayoutMsg heading $ do + setTitleI heading + let + editExamForm = wrapForm editExamWidget def + { formMethod = POST + , formAction = Just . SomeRoute $ CExamR tid ssh csh examn EEditR + , formEncoding = editExamEnctype + } + $(widgetFile "exam-edit") diff --git a/src/Handler/Exam/Form.hs b/src/Handler/Exam/Form.hs new file mode 100644 index 000000000..905adc4fe --- /dev/null +++ b/src/Handler/Exam/Form.hs @@ -0,0 +1,361 @@ +module Handler.Exam.Form + ( ExamForm(..) + , ExamOccurrenceForm(..) + , ExamPartForm(..) + , examForm + , examFormTemplate, examTemplate + , validateExam + ) where + +import Import +import Utils.Lens hiding (parts) + +import Handler.Exam.CorrectorInvite + +import Handler.Utils +import Handler.Utils.Invitations + +import Data.Map ((!)) +import qualified Data.Set as Set + +import qualified Database.Esqueleto as E + +import qualified Control.Monad.State.Class as State +import Text.Blaze.Html.Renderer.String (renderHtml) + + +data ExamForm = ExamForm + { efName :: ExamName + , efDescription :: Maybe Html + , efStart :: Maybe UTCTime + , efEnd :: Maybe UTCTime + , efVisibleFrom :: Maybe UTCTime + , efRegisterFrom :: Maybe UTCTime + , efRegisterTo :: Maybe UTCTime + , efDeregisterUntil :: Maybe UTCTime + , efPublishOccurrenceAssignments :: Maybe UTCTime + , efFinished :: Maybe UTCTime + , efClosed :: Maybe UTCTime + , efOccurrences :: Set ExamOccurrenceForm + , efShowGrades :: Bool + , efPublicStatistics :: Bool + , efGradingRule :: ExamGradingRule + , efBonusRule :: ExamBonusRule + , efOccurrenceRule :: ExamOccurrenceRule + , efCorrectors :: Set (Either UserEmail UserId) + , efExamParts :: Set ExamPartForm + } + +data ExamOccurrenceForm = ExamOccurrenceForm + { eofId :: Maybe CryptoUUIDExamOccurrence + , eofName :: ExamOccurrenceName + , eofRoom :: Text + , eofCapacity :: Natural + , eofStart :: UTCTime + , eofEnd :: Maybe UTCTime + , eofDescription :: Maybe Html + } deriving (Read, Show, Eq, Ord, Generic, Typeable) + +data ExamPartForm = ExamPartForm + { epfId :: Maybe CryptoUUIDExamPart + , epfName :: ExamPartName + , epfMaxPoints :: Maybe Points + , epfWeight :: Rational + } deriving (Read, Show, Eq, Ord, Generic, Typeable) + +makeLenses_ ''ExamForm + +deriveJSON defaultOptions + { fieldLabelModifier = camelToPathPiece' 1 + } ''ExamPartForm + +deriveJSON defaultOptions + { fieldLabelModifier = camelToPathPiece' 1 + } ''ExamOccurrenceForm + + +examForm :: Maybe ExamForm -> Form ExamForm +examForm template html = do + MsgRenderer mr <- getMsgRenderer + + flip (renderAForm FormStandard) html $ ExamForm + <$> areq ciField (fslpI MsgExamName (mr MsgExamName) & setTooltip MsgExamNameTip) (efName <$> template) + <*> (assertM (not . null . renderHtml) <$> aopt htmlField (fslpI MsgExamDescription "Html") (efDescription <$> template)) + <* aformSection MsgExamFormTimes + <*> aopt utcTimeField (fslpI MsgExamStart (mr MsgDate) & setTooltip MsgExamTimeTip) (efStart <$> template) + <*> aopt utcTimeField (fslpI MsgExamEnd (mr MsgDate) & setTooltip MsgExamTimeTip) (efEnd <$> template) + <*> aopt utcTimeField (fslpI MsgExamVisibleFrom (mr MsgDate) & setTooltip MsgExamVisibleFromTip) (efVisibleFrom <$> template) + <*> aopt utcTimeField (fslpI MsgExamRegisterFrom (mr MsgDate) & setTooltip MsgExamRegisterFromTip) (efRegisterFrom <$> template) + <*> aopt utcTimeField (fslpI MsgExamRegisterTo (mr MsgDate)) (efRegisterTo <$> template) + <*> aopt utcTimeField (fslpI MsgExamDeregisterUntil (mr MsgDate)) (efDeregisterUntil <$> template) + <*> aopt utcTimeField (fslpI MsgExamPublishOccurrenceAssignments (mr MsgDate) & setTooltip MsgExamPublishOccurrenceAssignmentsTip) (efPublishOccurrenceAssignments <$> template) + <*> aopt utcTimeField (fslpI MsgExamFinished (mr MsgDate) & setTooltip MsgExamFinishedTip) (efFinished <$> template) + <*> aopt utcTimeField (fslpI MsgExamClosed (mr MsgDate) & setTooltip MsgExamClosedTip) (efClosed <$> template) + <* aformSection MsgExamFormOccurrences + <*> examOccurrenceForm (efOccurrences <$> template) + <* aformSection MsgExamFormAutomaticFunctions + <*> (fromMaybe False <$> aopt checkBoxField (fslI MsgExamShowGrades & setTooltip MsgExamShowGradesTip) (Just . efShowGrades <$> template)) + <*> (fromMaybe False <$> aopt checkBoxField (fslI MsgExamPublicStatistics & setTooltip MsgExamPublicStatisticsTip) (Just . efPublicStatistics <$> template)) + <*> examGradingRuleForm (efGradingRule <$> template) + <*> examBonusRuleForm (efBonusRule <$> template) + <*> examOccurrenceRuleForm (efOccurrenceRule <$> template) + <* aformSection MsgExamFormCorrection + <*> examCorrectorsForm (efCorrectors <$> template) + <* aformSection MsgExamFormParts + <*> examPartsForm (efExamParts <$> template) + +examCorrectorsForm :: Maybe (Set (Either UserEmail UserId)) -> AForm Handler (Set (Either UserEmail UserId)) +examCorrectorsForm mPrev = wFormToAForm $ do + MsgRenderer mr <- getMsgRenderer + Just currentRoute <- getCurrentRoute + uid <- liftHandlerT requireAuthId + + let + miButtonAction' :: forall p. PathPiece p => p -> Maybe (SomeRoute UniWorX) + miButtonAction' frag = Just . SomeRoute $ currentRoute :#: frag + + miAdd' :: (Text -> Text) -> FieldView UniWorX -> Form ([Either UserEmail UserId] -> FormResult [Either UserEmail UserId]) + miAdd' nudge submitView csrf = do + (addRes, addView) <- mpreq (multiUserField False $ Just corrUserSuggestions) ("" & addName (nudge "email")) Nothing + let + addRes' + | otherwise + = addRes <&> \newDat oldDat -> if + | existing <- newDat `Set.intersection` Set.fromList oldDat + , not $ Set.null existing + -> FormFailure [mr MsgExamCorrectorAlreadyAdded] + | otherwise + -> FormSuccess $ Set.toList newDat + return (addRes', $(widgetFile "widgets/massinput/examCorrectors/add")) + + corrUserSuggestions :: E.SqlQuery (E.SqlExpr (Entity User)) + corrUserSuggestions = E.from $ \(lecturer `E.InnerJoin` course `E.InnerJoin` exam `E.InnerJoin` corrector `E.InnerJoin` corrUser) -> do + E.on $ corrUser E.^. UserId E.==. corrector E.^. ExamCorrectorUser + E.on $ corrector E.^. ExamCorrectorExam E.==. exam E.^. ExamId + E.on $ exam E.^. ExamCourse E.==. course E.^. CourseId + E.on $ course E.^. CourseId E.==. lecturer E.^. LecturerCourse + E.where_ $ lecturer E.^. LecturerUser E.==. E.val uid + return corrUser + + + miCell' :: Either UserEmail UserId -> Widget + miCell' (Left email) = + $(widgetFile "widgets/massinput/examCorrectors/cellInvitation") + miCell' (Right userId) = do + User{..} <- liftHandlerT . runDB $ get404 userId + $(widgetFile "widgets/massinput/examCorrectors/cellKnown") + + miLayout' :: MassInputLayout ListLength (Either UserEmail UserId) () + miLayout' lLength _ cellWdgts delButtons addWdgts = $(widgetFile "widgets/massinput/examCorrectors/layout") + + fmap Set.fromList <$> massInputAccumW miAdd' miCell' miButtonAction' miLayout' ("correctors" :: Text) (fslI MsgExamCorrectors & setTooltip MsgMassInputTip) False (Set.toList <$> mPrev) + +examOccurrenceForm :: Maybe (Set ExamOccurrenceForm) -> AForm Handler (Set ExamOccurrenceForm) +examOccurrenceForm prev = wFormToAForm $ do + Just currentRoute <- getCurrentRoute + let + miButtonAction' :: forall p. PathPiece p => p -> Maybe (SomeRoute UniWorX) + miButtonAction' frag = Just . SomeRoute $ currentRoute :#: frag + + fmap (fmap Set.fromList) . massInputAccumEditW miAdd' miCell' miButtonAction' miLayout' miIdent' (fslI MsgExamOccurrences & setTooltip MsgMassInputTip) False $ Set.toList <$> prev + where + examOccurrenceForm' nudge mPrev csrf = do + (eofIdRes, eofIdView) <- mopt hiddenField ("" & addName (nudge "id")) (Just $ eofId =<< mPrev) + (eofNameRes, eofNameView) <- mpreq ciField ("" & addName (nudge "name")) (eofName <$> mPrev) + (eofRoomRes, eofRoomView) <- mpreq textField ("" & addName (nudge "room")) (eofRoom <$> mPrev) + (eofCapacityRes, eofCapacityView) <- mpreq (natFieldI MsgExamRoomCapacityNegative) ("" & addName (nudge "capacity")) (eofCapacity <$> mPrev) + (eofStartRes, eofStartView) <- mpreq utcTimeField ("" & addName (nudge "start")) (eofStart <$> mPrev) + (eofEndRes, eofEndView) <- mopt utcTimeField ("" & addName (nudge "end")) (eofEnd <$> mPrev) + (eofDescRes, eofDescView) <- mopt htmlFieldSmall ("" & addName (nudge "description")) (eofDescription <$> mPrev) + + return ( ExamOccurrenceForm + <$> eofIdRes + <*> eofNameRes + <*> eofRoomRes + <*> eofCapacityRes + <*> eofStartRes + <*> eofEndRes + <*> (assertM (not . null . renderHtml) <$> eofDescRes) + , $(widgetFile "widgets/massinput/examRooms/form") + ) + + miAdd' nudge submitView csrf = do + MsgRenderer mr <- getMsgRenderer + (res, formWidget) <- examOccurrenceForm' nudge Nothing csrf + let + addRes = res <&> \newDat (Set.fromList -> oldDat) -> if + | newDat `Set.member` oldDat -> FormFailure [mr MsgExamRoomAlreadyExists] + | otherwise -> FormSuccess $ pure newDat + return (addRes, $(widgetFile "widgets/massinput/examRooms/add")) + miCell' nudge dat = examOccurrenceForm' nudge (Just dat) + miLayout' lLength _ cellWdgts delButtons addWdgts = $(widgetFile "widgets/massinput/examRooms/layout") + miIdent' :: Text + miIdent' = "exam-occurrences" + +examPartsForm :: Maybe (Set ExamPartForm) -> AForm Handler (Set ExamPartForm) +examPartsForm prev = wFormToAForm $ do + Just currentRoute <- getCurrentRoute + let + miButtonAction' :: forall p. PathPiece p => p -> Maybe (SomeRoute UniWorX) + miButtonAction' frag = Just . SomeRoute $ currentRoute :#: frag + + fmap (fmap Set.fromList) . massInputAccumEditW miAdd' miCell' miButtonAction' miLayout' miIdent' (fslI MsgExamParts & setTooltip MsgMassInputTip) False $ Set.toList <$> prev + where + examPartForm' nudge mPrev csrf = do + (epfIdRes, epfIdView) <- mopt hiddenField ("" & addName (nudge "id")) (Just $ epfId =<< mPrev) + (epfNameRes, epfNameView) <- mpreq ciField ("" & addName (nudge "name")) (epfName <$> mPrev) + (epfMaxPointsRes, epfMaxPointsView) <- mopt pointsField ("" & addName (nudge "max-points")) (epfMaxPoints <$> mPrev) + (epfWeightRes, epfWeightView) <- mpreq (checkBool (>= 0) MsgExamPartWeightNegative rationalField) ("" & addName (nudge "weight")) (epfWeight <$> mPrev <|> Just 1) + + return ( ExamPartForm + <$> epfIdRes + <*> epfNameRes + <*> epfMaxPointsRes + <*> epfWeightRes + , $(widgetFile "widgets/massinput/examParts/form") + ) + + miAdd' nudge submitView csrf = do + MsgRenderer mr <- getMsgRenderer + (res, formWidget) <- examPartForm' nudge Nothing csrf + let + addRes = res <&> \newDat (Set.fromList -> oldDat) -> if + | any (((==) `on` epfName) newDat) oldDat -> FormFailure [mr MsgExamPartAlreadyExists] + | otherwise -> FormSuccess $ pure newDat + return (addRes, $(widgetFile "widgets/massinput/examParts/add")) + miCell' nudge dat = examPartForm' nudge (Just dat) + miLayout' lLength _ cellWdgts delButtons addWdgts = $(widgetFile "widgets/massinput/examParts/layout") + miIdent' :: Text + miIdent' = "exam-parts" + +examFormTemplate :: Entity Exam -> DB ExamForm +examFormTemplate (Entity eId Exam{..}) = do + parts <- selectList [ ExamPartExam ==. eId ] [] + occurrences <- selectList [ ExamOccurrenceExam ==. eId ] [] + correctors <- selectList [ ExamCorrectorExam ==. eId ] [] + invitations <- map (\(email, InvDBDataExamCorrector) -> email) <$> sourceInvitationsList eId + + parts' <- forM parts $ \(Entity pid part) -> (,) <$> encrypt pid <*> pure part + occurrences' <- forM occurrences $ \(Entity oid occ) -> (,) <$> encrypt oid <*> pure occ + + return ExamForm + { efName = examName + , efGradingRule = examGradingRule + , efBonusRule = examBonusRule + , efOccurrenceRule = examOccurrenceRule + , efVisibleFrom = examVisibleFrom + , efRegisterFrom = examRegisterFrom + , efRegisterTo = examRegisterTo + , efDeregisterUntil = examDeregisterUntil + , efPublishOccurrenceAssignments = examPublishOccurrenceAssignments + , efStart = examStart + , efEnd = examEnd + , efFinished = examFinished + , efClosed = examClosed + , efShowGrades = examShowGrades + , efPublicStatistics = examPublicStatistics + , efDescription = examDescription + , efOccurrences = Set.fromList $ do + (Just -> eofId, ExamOccurrence{..}) <- occurrences' + return ExamOccurrenceForm + { eofId + , eofName = examOccurrenceName + , eofRoom = examOccurrenceRoom + , eofCapacity = examOccurrenceCapacity + , eofStart = examOccurrenceStart + , eofEnd = examOccurrenceEnd + , eofDescription = examOccurrenceDescription + } + , efExamParts = Set.fromList $ do + (Just -> epfId, ExamPart{..}) <- parts' + return ExamPartForm + { epfId + , epfName = examPartName + , epfMaxPoints = examPartMaxPoints + , epfWeight = examPartWeight + } + , efCorrectors = Set.unions + [ Set.fromList $ map Left invitations + , Set.fromList . map Right $ do + Entity _ ExamCorrector{..} <- correctors + return examCorrectorUser + ] + } + +examTemplate :: CourseId -> DB (Maybe ExamForm) +examTemplate cid = runMaybeT $ do + newCourse <- MaybeT $ get cid + + [(Entity _ oldCourse, Entity _ oldExam)] <- lift . E.select . E.from $ \(course `E.InnerJoin` exam) -> do + E.on $ course E.^. CourseId E.==. exam E.^. ExamCourse + E.where_ $ ( course E.^. CourseShorthand E.==. E.val (courseShorthand newCourse) + E.||. course E.^. CourseName E.==. E.val (courseName newCourse) + ) + E.&&. course E.^. CourseSchool E.==. E.val (courseSchool newCourse) + E.where_ . E.not_ . E.exists . E.from $ \exam' -> do + E.where_ $ exam' E.^. ExamCourse E.==. E.val cid + E.where_ $ exam E.^. ExamName E.==. exam' E.^. ExamName + E.where_ . E.not_ . E.isNothing $ exam E.^. ExamVisibleFrom + E.limit 1 + E.orderBy [ E.desc $ course E.^. CourseTerm, E.asc $ exam E.^. ExamVisibleFrom ] + return (course, exam) + + oldTerm <- MaybeT . get $ courseTerm oldCourse + newTerm <- MaybeT . get $ courseTerm newCourse + + let + dateOffset = over _utctDay . addDays $ (diffDays `on` termLectureEnd) newTerm oldTerm + + return ExamForm + { efName = examName oldExam + , efGradingRule = examGradingRule oldExam + , efBonusRule = examBonusRule oldExam + , efOccurrenceRule = examOccurrenceRule oldExam + , efVisibleFrom = dateOffset <$> examVisibleFrom oldExam + , efRegisterFrom = dateOffset <$> examRegisterFrom oldExam + , efRegisterTo = dateOffset <$> examRegisterTo oldExam + , efDeregisterUntil = dateOffset <$> examDeregisterUntil oldExam + , efPublishOccurrenceAssignments = dateOffset <$> examPublishOccurrenceAssignments oldExam + , efStart = dateOffset <$> examStart oldExam + , efEnd = dateOffset <$> examEnd oldExam + , efFinished = dateOffset <$> examFinished oldExam + , efClosed = dateOffset <$> examClosed oldExam + , efShowGrades = examShowGrades oldExam + , efPublicStatistics = examPublicStatistics oldExam + , efDescription = examDescription oldExam + , efOccurrences = Set.empty + , efExamParts = Set.empty + , efCorrectors = Set.empty + } + + +validateExam :: (MonadHandler m, HandlerSite m ~ UniWorX) => FormValidator ExamForm m () +validateExam = do + ExamForm{..} <- State.get + + guardValidation MsgExamRegisterToMustBeAfterRegisterFrom $ NTop efRegisterTo >= NTop efRegisterFrom + guardValidation MsgExamDeregisterUntilMustBeAfterRegisterFrom $ NTop efDeregisterUntil >= NTop efRegisterFrom + guardValidation MsgExamStartMustBeAfterPublishOccurrenceAssignments . fromMaybe True $ (>=) <$> efStart <*> efPublishOccurrenceAssignments + guardValidation MsgExamEndMustBeAfterStart $ NTop efEnd >= NTop efStart + guardValidation MsgExamFinishedMustBeAfterEnd . fromMaybe True $ (>=) <$> efFinished <*> efEnd + guardValidation MsgExamFinishedMustBeAfterStart $ NTop efFinished >= NTop efStart + guardValidation MsgExamClosedMustBeAfterFinished . fromMaybe True $ (>=) <$> efClosed <*> efFinished + guardValidation MsgExamClosedMustBeAfterStart $ NTop efClosed >= NTop efStart + guardValidation MsgExamClosedMustBeAfterEnd . fromMaybe True $ (>=) <$> efClosed <*> efEnd + + forM_ efOccurrences $ \ExamOccurrenceForm{..} -> do + guardValidation (MsgExamOccurrenceEndMustBeAfterStart eofName) $ NTop eofEnd >= NTop (Just eofStart) + guardValidation (MsgExamOccurrenceStartMustBeAfterExamStart eofName) $ NTop (Just eofStart) >= NTop efStart + guardValidation (MsgExamOccurrenceEndMustBeBeforeExamEnd eofName) $ NTop eofEnd <= NTop efEnd + + forM_ [ (a, b) | a <- Set.toAscList efOccurrences, b <- Set.toAscList efOccurrences, b > a ] $ \(a, b) -> do + eofRange' <- formatTimeRange SelFormatDateTime (eofStart a) (eofEnd a) + + guardValidation (MsgExamOccurrenceDuplicate (eofRoom a) eofRange') $ any (\f -> f a b) + [ (/=) `on` eofRoom + , (/=) `on` eofStart + , (/=) `on` eofEnd + , (/=) `on` fmap renderHtml . eofDescription + ] + + guardValidation (MsgExamOccurrenceDuplicateName $ eofName a) $ ((/=) `on` eofName) a b diff --git a/src/Handler/Exam/List.hs b/src/Handler/Exam/List.hs new file mode 100644 index 000000000..752d8e3c1 --- /dev/null +++ b/src/Handler/Exam/List.hs @@ -0,0 +1,60 @@ +module Handler.Exam.List + ( getCExamListR + ) where + +import Import + +import Handler.Utils +import Handler.Utils.Table.Cells + +import qualified Data.Map as Map + +import qualified Database.Esqueleto as E + + +getCExamListR :: TermId -> SchoolId -> CourseShorthand -> Handler Html +getCExamListR tid ssh csh = do + Entity cid Course{..} <- runDB . getBy404 $ TermSchoolCourseShort tid ssh csh + now <- liftIO getCurrentTime + mayCreate <- hasWriteAccessTo $ CourseR tid ssh csh CExamNewR + + let + examDBTable = DBTable{..} + where + dbtSQLQuery exam = do + E.where_ $ exam E.^. ExamCourse E.==. E.val cid + return exam + dbtRowKey = (E.^. ExamId) + dbtProj x@DBRow{ dbrOutput = Entity _ Exam{..} } = do + guardM . hasReadAccessTo $ CExamR tid ssh csh examName EShowR + return x + dbtColonnade = dbColonnade . mconcat $ catMaybes + [ Just . sortable (Just "name") (i18nCell MsgExamName) $ \DBRow{ dbrOutput = Entity _ Exam{..} } -> anchorCell (CExamR tid ssh csh examName EShowR) examName + , (<$ guard mayCreate) . sortable (Just "visible") (i18nCell MsgExamVisibleFrom) $ \DBRow { dbrOutput = Entity _ Exam{..} } -> maybe mempty (dateTimeCellVisible now) examVisibleFrom + , Just . sortable (Just "register-from") (i18nCell MsgExamRegisterFrom) $ \DBRow { dbrOutput = Entity _ Exam{..} } -> maybe mempty dateTimeCell examRegisterFrom + , Just . sortable (Just "register-to") (i18nCell MsgExamRegisterTo) $ \DBRow { dbrOutput = Entity _ Exam{..} } -> maybe mempty dateTimeCell examRegisterTo + , Just . sortable (Just "time") (i18nCell MsgExamTime) $ \DBRow{ dbrOutput = Entity _ Exam{..} } -> maybe mempty (cell . flip (formatTimeRangeW SelFormatDateTime) examEnd) examStart + ] + dbtSorting = Map.fromList + [ ("name", SortColumn $ \exam -> exam E.^. ExamName ) + , ("time", SortColumn $ \exam -> exam E.^. ExamStart ) + , ("register-from", SortColumn $ \exam -> exam E.^. ExamRegisterFrom ) + , ("register-to", SortColumn $ \exam -> exam E.^. ExamRegisterTo ) + , ("visible", SortColumn $ \exam -> exam E.^. ExamVisibleFrom ) + ] + dbtFilter = Map.empty + dbtFilterUI = const mempty + dbtStyle = def + dbtParams = def + dbtIdent :: Text + dbtIdent = "exams" + dbtCsvEncode = noCsvEncode + dbtCsvDecode = Nothing + + examDBTableValidator = def + & defaultSorting [SortAscBy "time"] + ((), examTable) <- runDB $ dbTable examDBTableValidator examDBTable + + siteLayoutMsg (prependCourseTitle tid ssh csh MsgExamsHeading) $ do + setTitleI $ prependCourseTitle tid ssh csh MsgExamsHeading + $(widgetFile "exam-list") diff --git a/src/Handler/Exam/New.hs b/src/Handler/Exam/New.hs new file mode 100644 index 000000000..d6bcfc828 --- /dev/null +++ b/src/Handler/Exam/New.hs @@ -0,0 +1,93 @@ +module Handler.Exam.New + ( getCExamNewR, postCExamNewR + ) where + +import Import +import Handler.Exam.Form +import Handler.Exam.CorrectorInvite + +import qualified Data.Set as Set + +import Handler.Utils +import Handler.Utils.Invitations + +import Jobs.Queue + + +getCExamNewR, postCExamNewR :: TermId -> SchoolId -> CourseShorthand -> Handler Html +getCExamNewR = postCExamNewR +postCExamNewR tid ssh csh = do + (cid, template) <- runDB $ do + cid <- getKeyBy404 $ TermSchoolCourseShort tid ssh csh + template <- examTemplate cid + return (cid, template) + + ((newExamResult, newExamWidget), newExamEnctype) <- runFormPost . validateForm validateExam $ examForm template + + formResult newExamResult $ \ExamForm{..} -> do + insertRes <- runDBJobs $ do + insertRes <- insertUnique Exam + { examName = efName + , examCourse = cid + , examGradingRule = efGradingRule + , examBonusRule = efBonusRule + , examOccurrenceRule = efOccurrenceRule + , examVisibleFrom = efVisibleFrom + , examRegisterFrom = efRegisterFrom + , examRegisterTo = efRegisterTo + , examDeregisterUntil = efDeregisterUntil + , examPublishOccurrenceAssignments = efPublishOccurrenceAssignments + , examStart = efStart + , examEnd = efEnd + , examFinished = efFinished + , examClosed = efClosed + , examShowGrades = efShowGrades + , examPublicStatistics = efPublicStatistics + , examDescription = efDescription + } + whenIsJust insertRes $ \examid -> do + insertMany_ + [ ExamPart{..} + | ExamPartForm{..} <- Set.toList efExamParts + , let examPartExam = examid + examPartName = epfName + examPartMaxPoints = epfMaxPoints + examPartWeight = epfWeight + ] + + insertMany_ + [ ExamOccurrence{..} + | ExamOccurrenceForm{..} <- Set.toList efOccurrences + , let examOccurrenceExam = examid + examOccurrenceName = eofName + examOccurrenceRoom = eofRoom + examOccurrenceCapacity = eofCapacity + examOccurrenceStart = eofStart + examOccurrenceEnd = eofEnd + examOccurrenceDescription = eofDescription + ] + + let (invites, adds) = partitionEithers $ Set.toList efCorrectors + insertMany_ [ ExamCorrector{..} + | examCorrectorUser <- adds + , let examCorrectorExam = examid + ] + sinkInvitationsF examCorrectorInvitationConfig $ map (, examid, (InvDBDataExamCorrector, InvTokenDataExamCorrector)) invites + return insertRes + case insertRes of + Nothing -> addMessageI Error $ MsgExamNameTaken efName + Just _ -> do + addMessageI Success $ MsgExamCreated efName + redirect $ CourseR tid ssh csh CExamListR + + let heading = prependCourseTitle tid ssh csh MsgExamNew + + siteLayoutMsg heading $ do + setTitleI heading + let + newExamForm = wrapForm newExamWidget def + { formMethod = POST + , formAction = Just . SomeRoute $ CourseR tid ssh csh CExamNewR + , formEncoding = newExamEnctype + } + $(widgetFile "exam-new") diff --git a/src/Handler/Exam/Register.hs b/src/Handler/Exam/Register.hs new file mode 100644 index 000000000..8051de2a7 --- /dev/null +++ b/src/Handler/Exam/Register.hs @@ -0,0 +1,52 @@ +module Handler.Exam.Register + ( ButtonExamRegister(..) + , postERegisterR + ) where + +import Import + +import Handler.Utils +import Handler.Utils.Exam + + +-- Dedicated ExamRegistrationButton +data ButtonExamRegister = BtnExamRegister | BtnExamDeregister + deriving (Enum, Eq, Ord, Bounded, Read, Show, Generic, Typeable) +instance Universe ButtonExamRegister +instance Finite ButtonExamRegister +nullaryPathPiece ''ButtonExamRegister $ camelToPathPiece' 1 +embedRenderMessage ''UniWorX ''ButtonExamRegister id +instance Button UniWorX ButtonExamRegister where + btnClasses BtnExamRegister = [BCIsButton, BCPrimary] + btnClasses BtnExamDeregister = [BCIsButton, BCDanger] + + btnLabel BtnExamRegister = [whamlet|#{iconExamRegister True} _{MsgBtnExamRegister}|] + btnLabel BtnExamDeregister = [whamlet|#{iconExamRegister False} _{MsgBtnExamDeregister}|] + + +postERegisterR :: TermId -> SchoolId -> CourseShorthand -> ExamName -> Handler Html + +postERegisterR tid ssh csh examn = do + Entity uid User{..} <- requireAuth + + Entity eId Exam{..} <- runDB $ fetchExam tid ssh csh examn + + ((btnResult, _), _) <- runFormPost buttonForm + + formResult btnResult $ \case + BtnExamRegister -> do + runDB $ do + now <- liftIO getCurrentTime + insert_ $ ExamRegistration eId uid Nothing now + audit' $ TransactionExamRegister (unTermKey tid) (unSchoolKey ssh) csh examn userIdent + addMessageIconI Success IconExamRegisterTrue $ MsgExamRegisteredSuccess examn + redirect $ CExamR tid ssh csh examn EShowR + BtnExamDeregister -> do + runDB $ do + deleteBy $ UniqueExamRegistration eId uid + audit' $ TransactionExamDeregister (unTermKey tid) (unSchoolKey ssh) csh examn userIdent + addMessageIconI Info IconExamRegisterFalse $ MsgExamDeregisteredSuccess examn + -- yes, it's a success message, but it should be visually different from a positive success, since most will just note the positive green color! See discussion on commit 5f4925a4 + redirect $ CExamR tid ssh csh examn EShowR + + invalidArgs ["Register/Deregister button required"] diff --git a/src/Handler/Exam/RegistrationInvite.hs b/src/Handler/Exam/RegistrationInvite.hs new file mode 100644 index 000000000..b17fae8a5 --- /dev/null +++ b/src/Handler/Exam/RegistrationInvite.hs @@ -0,0 +1,112 @@ +{-# OPTIONS_GHC -fno-warn-orphans #-} + +module Handler.Exam.RegistrationInvite + ( InvitableJunction(..) + , InvitationDBData(..) + , InvitationTokenData(..) + , examRegistrationInvitationConfig + , getEInviteR, postEInviteR + ) where + +import Import +import Handler.Utils +import Handler.Utils.Exam +import Handler.Utils.Invitations + +import qualified Data.Set as Set + +import Text.Hamlet (ihamlet) + +import Utils.Lens + +import Data.Aeson hiding (Result(..)) + + +instance IsInvitableJunction ExamRegistration where + type InvitationFor ExamRegistration = Exam + data InvitableJunction ExamRegistration = JunctionExamRegistration + { jExamRegistrationOccurrence :: Maybe ExamOccurrenceId + , jExamRegistrationTime :: UTCTime + } deriving (Eq, Ord, Read, Show, Generic, Typeable) + data InvitationDBData ExamRegistration = InvDBDataExamRegistration + { invDBExamRegistrationOccurrence :: Maybe ExamOccurrenceId + , invDBExamRegistrationDeadline :: UTCTime + , invDBExamRegistrationCourseRegister :: Bool + } deriving (Eq, Ord, Read, Show, Generic, Typeable) + data InvitationTokenData ExamRegistration = InvTokenDataExamRegistration + deriving (Eq, Ord, Read, Show, Generic, Typeable) + + _InvitableJunction = iso + (\ExamRegistration{..} -> (examRegistrationUser, examRegistrationExam, JunctionExamRegistration examRegistrationOccurrence examRegistrationTime)) + (\(examRegistrationUser, examRegistrationExam, JunctionExamRegistration examRegistrationOccurrence examRegistrationTime) -> ExamRegistration{..}) + +instance ToJSON (InvitableJunction ExamRegistration) where + toJSON = genericToJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 1 } + toEncoding = genericToEncoding defaultOptions { fieldLabelModifier = camelToPathPiece' 1 } +instance FromJSON (InvitableJunction ExamRegistration) where + parseJSON = genericParseJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 1 } + +instance ToJSON (InvitationDBData ExamRegistration) where + toJSON = genericToJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 4 } + toEncoding = genericToEncoding defaultOptions { fieldLabelModifier = camelToPathPiece' 4 } +instance FromJSON (InvitationDBData ExamRegistration) where + parseJSON = genericParseJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 4 } + +instance ToJSON (InvitationTokenData ExamRegistration) where + toJSON = genericToJSON defaultOptions { constructorTagModifier = camelToPathPiece' 4 } + toEncoding = genericToEncoding defaultOptions { constructorTagModifier = camelToPathPiece' 4 } +instance FromJSON (InvitationTokenData ExamRegistration) where + parseJSON = genericParseJSON defaultOptions { constructorTagModifier = camelToPathPiece' 4 } + +examRegistrationInvitationConfig :: InvitationConfig ExamRegistration +examRegistrationInvitationConfig = InvitationConfig{..} + where + invitationRoute (Entity _ Exam{..}) _ = do + Course{..} <- get404 examCourse + return $ CExamR courseTerm courseSchool courseShorthand examName EInviteR + invitationResolveFor _ = do + Just (CExamR tid csh ssh examn EInviteR) <- getCurrentRoute + fetchExamId tid csh ssh examn + invitationSubject (Entity _ Exam{..}) _ = do + Course{..} <- get404 examCourse + return . SomeMessage $ MsgMailSubjectExamRegistrationInvitation courseTerm courseSchool courseShorthand examName + invitationHeading (Entity _ Exam{..}) _ = return . SomeMessage $ MsgExamRegistrationInviteHeading examName + invitationExplanation _ _ = [ihamlet|_{SomeMessage MsgExamRegistrationInviteExplanation}|] + invitationTokenConfig _ (InvDBDataExamRegistration{..}, _) = do + itAuthority <- liftHandlerT requireAuthId + let itExpiresAt = Just $ Just invDBExamRegistrationDeadline + itAddAuth + | not invDBExamRegistrationCourseRegister + = Just . PredDNF . Set.singleton . impureNonNull . Set.singleton $ PLVariable AuthCourseRegistered + | otherwise + = Nothing + itStartsAt = Nothing + return InvitationTokenConfig{..} + invitationRestriction _ _ = return Authorized + invitationForm (Entity _ Exam{..}) (InvDBDataExamRegistration{..}, _) uid = hoistAForm liftHandlerT . wFormToAForm $ do + isRegistered <- fmap (is _Just) . liftHandlerT . runDB . getBy $ UniqueParticipant uid examCourse + now <- liftIO getCurrentTime + + case (isRegistered, invDBExamRegistrationCourseRegister) of + (False, False) -> permissionDeniedI MsgUnauthorizedParticipant + (False, True ) -> do + fieldRes <- wreq (studyFeaturesPrimaryFieldFor False [] $ Just uid) (fslI MsgCourseStudyFeature) Nothing + return $ (JunctionExamRegistration invDBExamRegistrationOccurrence now, ) . Just <$> fieldRes + (True , _ ) -> return $ pure (JunctionExamRegistration invDBExamRegistrationOccurrence now, Nothing) + invitationInsertHook (Entity _ Exam{..}) _ ExamRegistration{..} mField act = do + whenIsJust mField $ + insert_ . CourseParticipant examCourse examRegistrationUser examRegistrationTime + + Course{..} <- get404 examCourse + User{..} <- get404 examRegistrationUser + let doAudit = audit' $ TransactionExamRegister (unTermKey courseTerm) (unSchoolKey courseSchool) courseShorthand examName userIdent + act <* doAudit + invitationSuccessMsg (Entity _ Exam{..}) _ = return . SomeMessage $ MsgExamRegistrationInvitationAccepted examName + invitationUltDest (Entity _ Exam{..}) _ = do + Course{..} <- get404 examCourse + return . SomeRoute $ CExamR courseTerm courseSchool courseShorthand examName EShowR + + +getEInviteR, postEInviteR :: TermId -> SchoolId -> CourseShorthand -> ExamName -> Handler Html +getEInviteR = postEInviteR +postEInviteR _ _ _ _ = invitationR' examRegistrationInvitationConfig diff --git a/src/Handler/Exam/Show.hs b/src/Handler/Exam/Show.hs new file mode 100644 index 000000000..ad371d147 --- /dev/null +++ b/src/Handler/Exam/Show.hs @@ -0,0 +1,106 @@ +module Handler.Exam.Show + ( getEShowR + ) where + +import Import +import Handler.Exam.Register + +import Utils.Lens hiding (parts) + +import Data.Map ((!?)) +import qualified Data.Map as Map + +import qualified Database.Esqueleto as E +import qualified Database.Esqueleto.Utils as E + +import qualified Data.CaseInsensitive as CI + +import Handler.Utils +import Handler.Utils.Exam + + +getEShowR :: TermId -> SchoolId -> CourseShorthand -> ExamName -> Handler Html +getEShowR tid ssh csh examn = do + cTime <- liftIO getCurrentTime + mUid <- maybeAuthId + + (Entity _ Exam{..}, parts, examVisible, (gradingVisible, gradingShown), (occurrenceAssignmentsVisible, occurrenceAssignmentsShown), results, result, occurrences, (registered, mayRegister), occurrenceNamesShown) <- runDB $ do + exam@(Entity eId Exam{..}) <- fetchExam tid ssh csh examn + + let examVisible = NTop (Just cTime) >= NTop examVisibleFrom + + let gradingVisible = NTop (Just cTime) >= NTop examFinished + gradingShown <- or2M (return gradingVisible) . hasReadAccessTo $ CExamR tid ssh csh examn EEditR + + let occurrenceAssignmentsVisible = NTop (Just cTime) >= NTop examPublishOccurrenceAssignments + occurrenceAssignmentsShown <- or2M (return occurrenceAssignmentsVisible) . hasReadAccessTo $ CExamR tid ssh csh examn EEditR + + parts <- selectList [ ExamPartExam ==. eId ] [ Asc ExamPartName ] + + resultsRaw <- for mUid $ \uid -> + E.select . E.from $ \examPartResult -> do + E.where_ $ examPartResult E.^. ExamPartResultUser E.==. E.val uid + E.&&. examPartResult E.^. ExamPartResultExamPart `E.in_` E.valList (map entityKey parts) + return examPartResult + let results = maybe Map.empty (\rs -> Map.fromList [ (examPartResultExamPart, res) | res@(Entity _ ExamPartResult{..}) <- rs ]) resultsRaw + + result <- fmap join . for mUid $ getBy . UniqueExamResult eId + + occurrencesRaw <- E.select . E.from $ \examOccurrence -> do + E.where_ $ examOccurrence E.^. ExamOccurrenceExam E.==. E.val eId + let + registered + | Just uid <- mUid + = E.exists . E.from $ \examRegistration -> + E.where_ $ examRegistration E.^. ExamRegistrationExam E.==. E.val eId + E.&&. examRegistration E.^. ExamRegistrationUser E.==. E.val uid + E.&&. examRegistration E.^. ExamRegistrationOccurrence E.==. E.just (examOccurrence E.^. ExamOccurrenceId) + | otherwise = E.false + E.orderBy [E.desc registered, E.asc $ examOccurrence E.^. ExamOccurrenceStart, E.asc $ examOccurrence E.^. ExamOccurrenceRoom] + return (examOccurrence, registered) + + let occurrences = map (over _2 E.unValue) occurrencesRaw + + registered <- for mUid $ existsBy . UniqueExamRegistration eId + mayRegister <- (== Authorized) <$> evalAccessDB (CExamR tid ssh csh examName ERegisterR) True + + occurrenceNamesShown <- hasReadAccessTo $ CExamR tid ssh csh examn EEditR + + return (exam, parts, examVisible, (gradingVisible, gradingShown), (occurrenceAssignmentsVisible, occurrenceAssignmentsShown), results, result, occurrences, (registered, mayRegister), occurrenceNamesShown) + + let examTimes = all (\(Entity _ ExamOccurrence{..}, _) -> Just examOccurrenceStart == examStart && examOccurrenceEnd == examEnd) occurrences + registerWidget + | Just isRegistered <- registered + , mayRegister = Just $ do + (examRegisterForm, examRegisterEnctype) <- liftHandlerT . generateFormPost . buttonForm' $ bool [BtnExamRegister] [BtnExamDeregister] isRegistered + [whamlet| +
+ $if isRegistered + _{MsgExamRegistered} + $else + _{MsgExamNotRegistered} + |] + wrapForm examRegisterForm def + { formAction = Just . SomeRoute $ CExamR tid ssh csh examName ERegisterR + , formEncoding = examRegisterEnctype + , formSubmit = FormNoSubmit + } + | fromMaybe False registered = Just [whamlet|_{MsgExamRegistered}|] + | otherwise = Nothing + + let heading = prependCourseTitle tid ssh csh $ CI.original examName + + siteLayoutMsg heading $ do + setTitleI heading + let + gradingKeyW :: [Points] -> Widget + gradingKeyW bounds + = let boundWidgets :: [Widget] + boundWidgets = toWidget . (pack :: String -> Text) . showFixed True <$> 0 : bounds + grades :: [ExamGrade] + grades = universeF + in $(widgetFile "widgets/gradingKey") + + examBonusW :: ExamBonusRule -> Widget + examBonusW bonusRule = $(widgetFile "widgets/bonusRule") + $(widgetFile "exam-show") diff --git a/src/Handler/Exam/Users.hs b/src/Handler/Exam/Users.hs new file mode 100644 index 000000000..ca8599861 --- /dev/null +++ b/src/Handler/Exam/Users.hs @@ -0,0 +1,619 @@ +{-# OPTIONS_GHC -fno-warn-orphans #-} + +module Handler.Exam.Users + ( getEUsersR, postEUsersR + ) where + +import Import + +import Utils.Lens +import Handler.Utils +import Handler.Utils.Exam +import Handler.Utils.Table.Columns +import Handler.Utils.Table.Cells +import Handler.Utils.Csv + +import qualified Database.Esqueleto as E +import qualified Database.Esqueleto.Utils as E +import Database.Esqueleto.Utils.TH + +import qualified Data.Csv as Csv + +import Data.Map ((!)) +import qualified Data.Map as Map +import qualified Data.Set as Set + +import qualified Data.Text as Text +import qualified Data.Text.Lens as Text + +import qualified Data.Conduit.List as C + +import qualified Data.CaseInsensitive as CI + +import Numeric.Lens (integral) +import Control.Arrow (Kleisli(..)) + +import Database.Persist.Sql (deleteWhereCount, updateWhereCount) + + +type ExamUserTableExpr = (E.SqlExpr (Entity ExamRegistration) `E.InnerJoin` E.SqlExpr (Entity User)) `E.LeftOuterJoin` E.SqlExpr (Maybe (Entity ExamOccurrence)) `E.LeftOuterJoin` (E.SqlExpr (Maybe (Entity CourseParticipant)) `E.LeftOuterJoin` (E.SqlExpr (Maybe (Entity StudyFeatures)) `E.InnerJoin` E.SqlExpr (Maybe (Entity StudyDegree)) `E.InnerJoin` E.SqlExpr (Maybe (Entity StudyTerms)))) `E.LeftOuterJoin` E.SqlExpr (Maybe (Entity ExamResult)) +type ExamUserTableData = DBRow (Entity ExamRegistration, Entity User, Maybe (Entity ExamOccurrence), Maybe (Entity StudyFeatures), Maybe (Entity StudyDegree), Maybe (Entity StudyTerms), Maybe (Entity ExamResult)) + +instance HasEntity ExamUserTableData User where + hasEntity = _dbrOutput . _2 + +instance HasUser ExamUserTableData where + hasUser = _dbrOutput . _2 . _entityVal + +_userTableOccurrence :: Lens' ExamUserTableData (Maybe (Entity ExamOccurrence)) +_userTableOccurrence = _dbrOutput . _3 + +queryUser :: ExamUserTableExpr -> E.SqlExpr (Entity User) +queryUser = $(sqlIJproj 2 2) . $(sqlLOJproj 4 1) + +queryStudyFeatures :: ExamUserTableExpr -> E.SqlExpr (Maybe (Entity StudyFeatures)) +queryStudyFeatures = $(sqlIJproj 3 1) . $(sqlLOJproj 2 2) . $(sqlLOJproj 4 3) + +queryExamRegistration :: ExamUserTableExpr -> E.SqlExpr (Entity ExamRegistration) +queryExamRegistration = $(sqlIJproj 2 1) . $(sqlLOJproj 4 1) + +queryExamOccurrence :: ExamUserTableExpr -> E.SqlExpr (Maybe (Entity ExamOccurrence)) +queryExamOccurrence = $(sqlLOJproj 4 2) + +queryStudyDegree :: ExamUserTableExpr -> E.SqlExpr (Maybe (Entity StudyDegree)) +queryStudyDegree = $(sqlIJproj 3 2) . $(sqlLOJproj 2 2) . $(sqlLOJproj 4 3) + +queryStudyField :: ExamUserTableExpr -> E.SqlExpr (Maybe (Entity StudyTerms)) +queryStudyField = $(sqlIJproj 3 3) . $(sqlLOJproj 2 2) . $(sqlLOJproj 4 3) + +queryExamResult :: ExamUserTableExpr -> E.SqlExpr (Maybe (Entity ExamResult)) +queryExamResult = $(sqlLOJproj 4 4) + +resultExamRegistration :: Lens' ExamUserTableData (Entity ExamRegistration) +resultExamRegistration = _dbrOutput . _1 + +resultUser :: Lens' ExamUserTableData (Entity User) +resultUser = _dbrOutput . _2 + +resultStudyFeatures :: Traversal' ExamUserTableData (Entity StudyFeatures) +resultStudyFeatures = _dbrOutput . _4 . _Just + +resultStudyDegree :: Traversal' ExamUserTableData (Entity StudyDegree) +resultStudyDegree = _dbrOutput . _5 . _Just + +resultStudyField :: Traversal' ExamUserTableData (Entity StudyTerms) +resultStudyField = _dbrOutput . _6 . _Just + +resultExamOccurrence :: Traversal' ExamUserTableData (Entity ExamOccurrence) +resultExamOccurrence = _dbrOutput . _3 . _Just + +resultExamResult :: Traversal' ExamUserTableData (Entity ExamResult) +resultExamResult = _dbrOutput . _7 . _Just + +data ExamUserTableCsv = ExamUserTableCsv + { csvEUserSurname :: Maybe Text + , csvEUserFirstName :: Maybe Text + , csvEUserName :: Maybe Text + , csvEUserMatriculation :: Maybe Text + , csvEUserField :: Maybe Text + , csvEUserDegree :: Maybe Text + , csvEUserSemester :: Maybe Int + , csvEUserOccurrence :: Maybe (CI Text) + , csvEUserExercisePoints :: Maybe Points + , csvEUserExerciseNumPasses :: Maybe Int + , csvEUserExercisePointsMax :: Maybe Points + , csvEUserExerciseNumPassesMax :: Maybe Int + , csvEUserExamResult :: Maybe (Either ExamResultPassed ExamResultGrade) + } + deriving (Generic) +makeLenses_ ''ExamUserTableCsv + +examUserTableCsvOptions :: Csv.Options +examUserTableCsvOptions = Csv.defaultOptions { Csv.fieldLabelModifier = camelToPathPiece' 3 } + +instance ToNamedRecord ExamUserTableCsv where + toNamedRecord = Csv.genericToNamedRecord examUserTableCsvOptions + +instance FromNamedRecord ExamUserTableCsv where + parseNamedRecord csv -- Manually defined awaiting issue #427 + = ExamUserTableCsv + <$> csv .:? "surname" + <*> csv .:? "first-name" + <*> csv .:? "name" + <*> csv .:? "matriculation" + <*> csv .:? "field" + <*> csv .:? "degree" + <*> csv .:? "semester" + <*> csv .:? "occurrence" + <*> csv .:? "exercise-points" + <*> csv .:? "exercise-num-passes" + <*> csv .:? "exercise-points-max" + <*> csv .:? "exercise-num-passes-max" + <*> csv .:? "exam-result" + where + (.:?) :: FromField (Maybe a) => Csv.NamedRecord -> ByteString -> Csv.Parser (Maybe a) + m .:? name = Csv.lookup m name <|> return Nothing + +instance DefaultOrdered ExamUserTableCsv where + headerOrder = Csv.genericHeaderOrder examUserTableCsvOptions + +instance CsvColumnsExplained ExamUserTableCsv where + csvColumnsExplanations = genericCsvColumnsExplanations examUserTableCsvOptions $ Map.fromList + [ ('csvEUserSurname , MsgCsvColumnExamUserSurname ) + , ('csvEUserFirstName , MsgCsvColumnExamUserFirstName ) + , ('csvEUserName , MsgCsvColumnExamUserName ) + , ('csvEUserMatriculation , MsgCsvColumnExamUserMatriculation ) + , ('csvEUserField , MsgCsvColumnExamUserField ) + , ('csvEUserDegree , MsgCsvColumnExamUserDegree ) + , ('csvEUserSemester , MsgCsvColumnExamUserSemester ) + , ('csvEUserOccurrence , MsgCsvColumnExamUserOccurrence ) + , ('csvEUserExercisePoints , MsgCsvColumnExamUserExercisePoints ) + , ('csvEUserExerciseNumPasses , MsgCsvColumnExamUserExercisePasses ) + , ('csvEUserExercisePointsMax , MsgCsvColumnExamUserExercisePointsMax ) + , ('csvEUserExerciseNumPassesMax, MsgCsvColumnExamUserExercisePassesMax ) + , ('csvEUserExamResult , MsgCsvColumnExamUserResult ) + ] + +data ExamUserAction = ExamUserDeregister + | ExamUserAssignOccurrence + deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic, Typeable) + +instance Universe ExamUserAction +instance Finite ExamUserAction +nullaryPathPiece ''ExamUserAction $ camelToPathPiece' 2 +embedRenderMessage ''UniWorX ''ExamUserAction id + +data ExamUserActionData = ExamUserDeregisterData + | ExamUserAssignOccurrenceData (Maybe ExamOccurrenceId) + +data ExamUserCsvActionClass + = ExamUserCsvCourseRegister + | ExamUserCsvRegister + | ExamUserCsvAssignOccurrence + | ExamUserCsvSetCourseField + | ExamUserCsvDeregister + | ExamUserCsvSetResult + deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic, Typeable) +embedRenderMessage ''UniWorX ''ExamUserCsvActionClass id + +data ExamUserCsvAction + = ExamUserCsvCourseRegisterData + { examUserCsvActUser :: UserId + , examUserCsvActCourseField :: Maybe StudyFeaturesId + , examUserCsvActOccurrence :: Maybe ExamOccurrenceId + } + | ExamUserCsvRegisterData + { examUserCsvActUser :: UserId + , examUserCsvActOccurrence :: Maybe ExamOccurrenceId + } + | ExamUserCsvAssignOccurrenceData + { examUserCsvActRegistration :: ExamRegistrationId + , examUserCsvActOccurrence :: Maybe ExamOccurrenceId + } + | ExamUserCsvSetCourseFieldData + { examUserCsvActCourseParticipant :: CourseParticipantId + , examUserCsvActCourseField :: Maybe StudyFeaturesId + } + | ExamUserCsvDeregisterData + { examUserCsvActRegistration :: ExamRegistrationId + } + | ExamUserCsvSetResultData + { examUserCsvActUser :: UserId + , examUserCsvActExamResult :: Maybe (Either ExamResultPassed ExamResultGrade) + } + deriving (Eq, Ord, Read, Show, Generic, Typeable) +deriveJSON defaultOptions + { constructorTagModifier = over Text.packed $ Text.intercalate "-" . map Text.toLower . drop 3 . dropEnd 1 . splitCamel + , fieldLabelModifier = camelToPathPiece' 3 + , sumEncoding = TaggedObject "action" "data" + } ''ExamUserCsvAction + +data ExamUserCsvException + = ExamUserCsvExceptionNoMatchingUser + | ExamUserCsvExceptionNoMatchingStudyFeatures + | ExamUserCsvExceptionNoMatchingOccurrence + deriving (Show, Generic, Typeable) + +instance Exception ExamUserCsvException + +embedRenderMessage ''UniWorX ''ExamUserCsvException id + +getEUsersR, postEUsersR :: TermId -> SchoolId -> CourseShorthand -> ExamName -> Handler Html +getEUsersR = postEUsersR +postEUsersR tid ssh csh examn = do + (registrationResult, examUsersTable) <- runDB $ do + exam@(Entity eid Exam{..}) <- fetchExam tid ssh csh examn + bonus <- examBonus exam + + let + allBoni = (mappend <$> normalSummary <*> bonusSummary) $ fold bonus + showPasses = numSheetsPasses allBoni /= 0 + showPoints = getSum (numSheetsPoints allBoni) /= 0 + + resultView :: ExamResultGrade -> Either ExamResultPassed ExamResultGrade + resultView = bool (Left . over _examResult (view passingGrade)) Right examShowGrades + + let + examUsersDBTable = DBTable{..} + where + dbtSQLQuery ((examRegistration `E.InnerJoin` user) `E.LeftOuterJoin` occurrence `E.LeftOuterJoin` (courseParticipant `E.LeftOuterJoin` (studyFeatures `E.InnerJoin` studyDegree `E.InnerJoin` studyField)) `E.LeftOuterJoin` examResult) = do + E.on $ examResult E.?. ExamResultUser E.==. E.just (user E.^. UserId) + E.&&. examResult E.?. ExamResultExam E.==. E.just (E.val eid) + E.on $ studyField E.?. StudyTermsId E.==. studyFeatures E.?. StudyFeaturesField + E.on $ studyDegree E.?. StudyDegreeId E.==. studyFeatures E.?. StudyFeaturesDegree + E.on $ studyFeatures E.?. StudyFeaturesId E.==. E.joinV (courseParticipant E.?. CourseParticipantField) + E.on $ courseParticipant E.?. CourseParticipantCourse E.==. E.just (E.val examCourse) + E.&&. courseParticipant E.?. CourseParticipantUser E.==. E.just (user E.^. UserId) + E.on $ occurrence E.?. ExamOccurrenceExam E.==. E.just (E.val eid) + E.&&. occurrence E.?. ExamOccurrenceId E.==. examRegistration E.^. ExamRegistrationOccurrence + E.on $ examRegistration E.^. ExamRegistrationUser E.==. user E.^. UserId + E.where_ $ examRegistration E.^. ExamRegistrationExam E.==. E.val eid + return (examRegistration, user, occurrence, studyFeatures, studyDegree, studyField, examResult) + dbtRowKey = queryExamRegistration >>> (E.^. ExamRegistrationId) + dbtProj = return + dbtColonnade = mconcat $ catMaybes + [ pure $ dbSelect (applying _2) id $ return . view (resultExamRegistration . _entityKey) + , pure $ colUserNameLink (CourseR tid ssh csh . CUserR) + , pure colUserMatriclenr + , pure $ colField resultStudyField + , pure $ colDegreeShort resultStudyDegree + , pure $ colFeaturesSemester resultStudyFeatures + , pure $ sortable (Just "occurrence") (i18nCell MsgExamOccurrence) $ maybe mempty (anchorCell' (\n -> CExamR tid ssh csh examn EShowR :#: [st|exam-occurrence__#{n}|]) id . examOccurrenceName . entityVal) . view _userTableOccurrence + , guardOn showPasses $ sortable Nothing (i18nCell MsgAchievedPasses) $ \(view $ resultUser . _entityKey -> uid) -> fromMaybe mempty $ do + SheetGradeSummary{achievedPasses} <- examBonusAchieved uid bonus + SheetGradeSummary{numSheetsPasses} <- examBonusPossible uid bonus + return $ propCell (getSum achievedPasses) (getSum numSheetsPasses) + , guardOn showPoints $ sortable Nothing (i18nCell MsgAchievedPoints) $ \(view $ resultUser . _entityKey -> uid) -> fromMaybe mempty $ do + SheetGradeSummary{achievedPoints} <- examBonusAchieved uid bonus + SheetGradeSummary{sumSheetsPoints} <- examBonusPossible uid bonus + return $ propCell (getSum achievedPoints) (getSum sumSheetsPoints) + , guardOn examShowGrades $ sortable (Just "result") (i18nCell MsgExamResult) $ maybe mempty i18nCell . preview (resultExamResult . _entityVal . _examResultResult) + , guardOn (not examShowGrades) $ sortable (Just "result-bool") (i18nCell MsgExamResult) $ maybe mempty i18nCell . preview (resultExamResult . _entityVal . _examResultResult . to (over _examResult $ view passingGrade)) + ] + dbtSorting = Map.fromList + [ sortUserNameLink queryUser + , sortUserMatriclenr queryUser + , sortField queryStudyField + , sortDegreeShort queryStudyDegree + , sortFeaturesSemester queryStudyFeatures + , ("occurrence", SortColumn $ queryExamOccurrence >>> (E.?. ExamOccurrenceName)) + , ("result", SortColumn $ queryExamResult >>> (E.?. ExamResultResult)) + , ("result-bool", SortColumn $ queryExamResult >>> (E.?. ExamResultResult) >>> E.orderByList [Just ExamVoided, Just ExamNoShow, Just $ ExamAttended Grade50]) + ] + dbtFilter = Map.fromList + [ fltrUserNameEmail queryUser + , fltrUserMatriclenr queryUser + , fltrField queryStudyField + , fltrDegree queryStudyDegree + , fltrFeaturesSemester queryStudyFeatures + , ("occurrence", FilterColumn . E.mkContainsFilterWith Just $ queryExamOccurrence >>> (E.?. ExamOccurrenceName)) + , ("result", FilterColumn . E.mkExactFilterWith Just $ queryExamResult >>> (E.?. ExamResultResult)) + , ( "result-bool" + , FilterColumn $ \row criteria -> if + | Set.null criteria -> E.true + | otherwise -> let passed :: [ExamResultGrade] + passed = filter (\res -> preview (_examResult . passingGrade) res == Just (ExamPassed True)) universeF + criteria' = Set.map (fmap $ review passingGrade) criteria + criteria'' + | ExamAttended (ExamPassed True) `Set.member` criteria + = criteria' `Set.union` Set.fromList passed + | otherwise + = criteria' + in queryExamResult row E.?. ExamResultResult `E.in_` E.valList (Just <$> Set.toList criteria'') + ) + ] + dbtFilterUI mPrev = mconcat $ catMaybes + [ Just $ fltrUserNameEmailUI mPrev + , Just $ fltrUserMatriclenrUI mPrev + , Just $ fltrFieldUI mPrev + , Just $ fltrDegreeUI mPrev + , Just $ fltrFeaturesSemesterUI mPrev + , Just $ prismAForm (singletonFilter "occurrence") mPrev $ aopt textField (fslI MsgExamOccurrence) + , guardOn examShowGrades $ prismAForm (singletonFilter "result" . maybePrism _PathPiece) mPrev $ aopt (examResultField examGradeField) (fslI MsgExamResult) + , guardOn (not examShowGrades) $ prismAForm (singletonFilter "result" . maybePrism _PathPiece) mPrev $ aopt (examResultField examPassedField) (fslI MsgExamResult) + ] + dbtStyle = def { dbsFilterLayout = defaultDBSFilterLayout } + dbtParams = DBParamsForm + { dbParamsFormMethod = POST + , dbParamsFormAction = Just . SomeRoute $ CExamR tid ssh csh examn EUsersR + , dbParamsFormAttrs = [] + , dbParamsFormSubmit = FormSubmit + , dbParamsFormAdditional = \csrf -> do + let + actionMap :: Map ExamUserAction (AForm Handler ExamUserActionData) + actionMap = Map.fromList + [ ( ExamUserDeregister + , pure ExamUserDeregisterData + ) + , ( ExamUserAssignOccurrence + , ExamUserAssignOccurrenceData + <$> aopt (examOccurrenceField eid) (fslI MsgExamOccurrence) (Just Nothing) + ) + ] + (res, formWgt) <- multiActionM actionMap (fslI MsgAction) Nothing csrf + let formRes = (, mempty) . First . Just <$> res + return (formRes, formWgt) + , dbParamsFormEvaluate = liftHandlerT . runFormPost + , dbParamsFormResult = id + , dbParamsFormIdent = def + } + dbtIdent :: Text + dbtIdent = "exam-users" + dbtCsvEncode :: DBTCsvEncode ExamUserTableData ExamUserTableCsv + dbtCsvEncode = DictJust . C.map $ ExamUserTableCsv + <$> view (resultUser . _entityVal . _userSurname . to Just) + <*> view (resultUser . _entityVal . _userFirstName . to Just) + <*> view (resultUser . _entityVal . _userDisplayName . to Just) + <*> view (resultUser . _entityVal . _userMatrikelnummer) + <*> preview (resultStudyField . _entityVal . to (\StudyTerms{..} -> studyTermsName <|> studyTermsShorthand <|> Just (tshow studyTermsKey)) . _Just) + <*> preview (resultStudyDegree . _entityVal . to (\StudyDegree{..} -> studyDegreeName <|> studyDegreeShorthand <|> Just (tshow studyDegreeKey)) . _Just) + <*> preview (resultStudyFeatures . _entityVal . _studyFeaturesSemester) + <*> preview (resultExamOccurrence . _entityVal . _examOccurrenceName) + <*> preview (resultUser . _entityKey . to (examBonusAchieved ?? bonus) . _Just . _achievedPoints . _Wrapped) + <*> preview (resultUser . _entityKey . to (examBonusAchieved ?? bonus) . _Just . _achievedPasses . _Wrapped . integral) + <*> preview (resultUser . _entityKey . to (examBonusPossible ?? bonus) . _Just . _sumSheetsPoints . _Wrapped) + <*> preview (resultUser . _entityKey . to (examBonusPossible ?? bonus) . _Just . _numSheetsPasses . _Wrapped . integral) + <*> preview (resultExamResult . _entityVal . _examResultResult . to resultView) + dbtCsvDecode = Just DBTCsvDecode + { dbtCsvRowKey = \csv -> do + uid <- lift $ view _2 <$> guessUser csv + fmap E.Value . MaybeT . getKeyBy $ UniqueExamRegistration eid uid + , dbtCsvComputeActions = \case + DBCsvDiffMissing{dbCsvOldKey} + -> yield . ExamUserCsvDeregisterData $ E.unValue dbCsvOldKey + DBCsvDiffNew{dbCsvNewKey = Just _} + -> fail "An UniqueExamRegistration could be found, but the ExamRegistrationKey is not among the existing keys" + DBCsvDiffNew{dbCsvNewKey = Nothing, dbCsvNew} -> do + (isPart, uid) <- lift $ guessUser dbCsvNew + if + | isPart -> do + yieldM $ ExamUserCsvRegisterData <$> pure uid <*> lookupOccurrence dbCsvNew + newFeatures <- lift $ lookupStudyFeatures dbCsvNew + Just (Entity cpId CourseParticipant{ courseParticipantField = oldFeatures }) <- lift . getBy $ UniqueParticipant uid examCourse + when (newFeatures /= oldFeatures) $ + yield $ ExamUserCsvSetCourseFieldData cpId newFeatures + | otherwise -> + yieldM $ ExamUserCsvCourseRegisterData uid <$> lookupStudyFeatures dbCsvNew <*> lookupOccurrence dbCsvNew + when (is _Just $ csvEUserExamResult dbCsvNew) $ + yield . ExamUserCsvSetResultData uid $ csvEUserExamResult dbCsvNew + DBCsvDiffExisting{..} -> do + newOccurrence <- lift $ lookupOccurrence dbCsvNew + when (newOccurrence /= dbCsvOld ^? resultExamOccurrence . _entityKey) $ + yield $ ExamUserCsvAssignOccurrenceData (E.unValue dbCsvOldKey) newOccurrence + + newFeatures <- lift $ lookupStudyFeatures dbCsvNew + when (newFeatures /= dbCsvOld ^? resultStudyFeatures . _entityKey) $ do + Just (Entity cpId _) <- lift . getBy . flip UniqueParticipant examCourse $ dbCsvOld ^. resultUser . _entityKey + yield $ ExamUserCsvSetCourseFieldData cpId newFeatures + + when (csvEUserExamResult dbCsvNew /= dbCsvOld ^? resultExamResult . _entityVal . _examResultResult . to resultView) $ + yield . ExamUserCsvSetResultData (dbCsvOld ^. resultUser . _entityKey) $ csvEUserExamResult dbCsvNew + , dbtCsvClassifyAction = \case + ExamUserCsvCourseRegisterData{} -> ExamUserCsvCourseRegister + ExamUserCsvRegisterData{} -> ExamUserCsvRegister + ExamUserCsvDeregisterData{} -> ExamUserCsvDeregister + ExamUserCsvAssignOccurrenceData{} -> ExamUserCsvAssignOccurrence + ExamUserCsvSetCourseFieldData{} -> ExamUserCsvSetCourseField + ExamUserCsvSetResultData{} -> ExamUserCsvSetResult + , dbtCsvCoarsenActionClass = \case + ExamUserCsvCourseRegister -> DBCsvActionNew + ExamUserCsvRegister -> DBCsvActionNew + ExamUserCsvDeregister -> DBCsvActionMissing + _other -> DBCsvActionExisting + , dbtCsvExecuteActions = do + C.mapM_ $ \case + ExamUserCsvCourseRegisterData{..} -> do + now <- liftIO getCurrentTime + insert_ CourseParticipant + { courseParticipantCourse = examCourse + , courseParticipantUser = examUserCsvActUser + , courseParticipantRegistration = now + , courseParticipantField = examUserCsvActCourseField + } + User{userIdent} <- getJust examUserCsvActUser + audit' $ TransactionExamRegister (unTermKey tid) (unSchoolKey ssh) csh examn userIdent + insert_ ExamRegistration + { examRegistrationExam = eid + , examRegistrationUser = examUserCsvActUser + , examRegistrationOccurrence = examUserCsvActOccurrence + , examRegistrationTime = now + } + ExamUserCsvRegisterData{..} -> do + examRegistrationTime <- liftIO getCurrentTime + insert_ ExamRegistration + { examRegistrationExam = eid + , examRegistrationUser = examUserCsvActUser + , examRegistrationOccurrence = examUserCsvActOccurrence + , .. + } + ExamUserCsvAssignOccurrenceData{..} -> + update examUserCsvActRegistration [ ExamRegistrationOccurrence =. examUserCsvActOccurrence ] + ExamUserCsvSetCourseFieldData{..} -> + update examUserCsvActCourseParticipant [ CourseParticipantField =. examUserCsvActCourseField ] + ExamUserCsvSetResultData{..} -> case examUserCsvActExamResult of + Nothing -> deleteBy $ UniqueExamResult eid examUserCsvActUser + Just res -> let res' = either (over _examResult $ review passingGrade) id res + in void $ upsert + (ExamResult eid examUserCsvActUser res') + [ ExamResultResult =. res' + ] + ExamUserCsvDeregisterData{..} -> do + ExamRegistration{examRegistrationUser} <- getJust examUserCsvActRegistration + User{userIdent} <- getJust examRegistrationUser + audit' $ TransactionExamDeregister (unTermKey tid) (unSchoolKey ssh) csh examn userIdent + delete examUserCsvActRegistration + return $ CExamR tid ssh csh examn EUsersR + , dbtCsvRenderKey = \(registeredUserName -> registeredUserName') -> \case + ExamUserCsvCourseRegisterData{..} -> do + (User{..}, occ) <- liftHandlerT . runDB $ (,) <$> getJust examUserCsvActUser <*> for examUserCsvActOccurrence getJust + [whamlet| + $newline never + ^{nameWidget userDisplayName userSurname} + $maybe features <- examUserCsvActCourseField + , ^{studyFeaturesWidget features} + $nothing + , _{MsgCourseStudyFeatureNone} + $maybe ExamOccurrence{examOccurrenceName} <- occ + \ (#{examOccurrenceName}) + $nothing + \ (_{MsgExamNoOccurrence}) + |] + ExamUserCsvRegisterData{..} -> do + (User{..}, occ) <- liftHandlerT . runDB $ (,) <$> getJust examUserCsvActUser <*> for examUserCsvActOccurrence getJust + [whamlet| + $newline never + ^{nameWidget userDisplayName userSurname} + $maybe ExamOccurrence{examOccurrenceName} <- occ + \ (#{examOccurrenceName}) + $nothing + \ (_{MsgExamNoOccurrence}) + |] + ExamUserCsvAssignOccurrenceData{..} -> do + occ <- for examUserCsvActOccurrence $ liftHandlerT . runDB . getJust + [whamlet| + $newline never + ^{registeredUserName' examUserCsvActRegistration} + $maybe ExamOccurrence{examOccurrenceName} <- occ + \ (#{examOccurrenceName}) + $nothing + \ (_{MsgExamNoOccurrence}) + |] + ExamUserCsvSetCourseFieldData{..} -> do + User{..} <- liftHandlerT . runDB $ getJust . courseParticipantUser =<< getJust examUserCsvActCourseParticipant + [whamlet| + $newline never + ^{nameWidget userDisplayName userSurname} + $maybe features <- examUserCsvActCourseField + , ^{studyFeaturesWidget features} + $nothing + , _{MsgCourseStudyFeatureNone} + |] + ExamUserCsvSetResultData{..} -> do + User{..} <- liftHandlerT . runDB $ getJust examUserCsvActUser + [whamlet| + $newline never + ^{nameWidget userDisplayName userSurname} + $maybe newResult <- examUserCsvActExamResult + $case newResult + $of Left pResult + , _{pResult} + $of Right gResult + , _{gResult} + $nothing + , _{MsgExamResultNone} + |] + + ExamUserCsvDeregisterData{..} + -> registeredUserName' examUserCsvActRegistration + , dbtCsvRenderActionClass = toWidget <=< ap getMessageRender . pure + , dbtCsvRenderException = ap getMessageRender . pure :: ExamUserCsvException -> DB Text + } + where + studyFeaturesWidget :: StudyFeaturesId -> Widget + studyFeaturesWidget featId = do + (StudyFeatures{studyFeaturesSemester}, (degree, terms)) <- liftHandlerT . runDB . ($ featId) . runKleisli $ Kleisli getJust >>> Kleisli return &&& Kleisli (getJust . studyFeaturesDegree) &&& Kleisli (getJust . studyFeaturesField) + [whamlet| + $newline never + _{StudyDegreeTerm degree terms}, _{MsgStudyFeatureAge} #{studyFeaturesSemester} + |] + + registeredUserName :: Map (E.Value ExamRegistrationId) ExamUserTableData -> ExamRegistrationId -> Widget + registeredUserName existing (E.Value -> registration) = nameWidget userDisplayName userSurname + where + Entity _ User{..} = view resultUser $ existing ! registration + + guessUser :: ExamUserTableCsv -> DB (Bool, UserId) + guessUser ExamUserTableCsv{..} = $cachedHereBinary (csvEUserMatriculation, csvEUserName, csvEUserSurname) $ do + users <- E.select . E.from $ \user -> do + E.where_ . E.and $ catMaybes + [ (user E.^. UserMatrikelnummer E.==.) . E.val . Just <$> csvEUserMatriculation + , (user E.^. UserDisplayName E.==.) . E.val <$> csvEUserName + , (user E.^. UserSurname E.==.) . E.val <$> csvEUserSurname + , (user E.^. UserFirstName E.==.) . E.val <$> csvEUserFirstName + ] + let isCourseParticipant = E.exists . E.from $ \courseParticipant -> + E.where_ $ courseParticipant E.^. CourseParticipantCourse E.==. E.val examCourse + E.&&. courseParticipant E.^. CourseParticipantUser E.==. user E.^. UserId + E.limit 2 + return (isCourseParticipant, user E.^. UserId) + case users of + (filter . view $ _1 . _Value -> [(E.Value isPart, E.Value uid)]) + -> return (isPart, uid) + [(E.Value isPart, E.Value uid)] + -> return (isPart, uid) + _other + -> throwM ExamUserCsvExceptionNoMatchingUser + + lookupOccurrence :: ExamUserTableCsv -> DB (Maybe ExamOccurrenceId) + lookupOccurrence ExamUserTableCsv{..} = $cachedHereBinary (CI.foldedCase <$> csvEUserOccurrence) . for csvEUserOccurrence $ \occName -> do + occIds <- selectKeysList [ ExamOccurrenceName ==. occName, ExamOccurrenceExam ==. eid ] [] + case occIds of + [occId] -> return occId + _other -> throwM ExamUserCsvExceptionNoMatchingOccurrence + + lookupStudyFeatures :: ExamUserTableCsv -> DB (Maybe StudyFeaturesId) + lookupStudyFeatures csv@ExamUserTableCsv{..} = do + uid <- view _2 <$> guessUser csv + studyFeatures <- E.select . E.from $ \(studyFeatures `E.InnerJoin` studyDegree `E.InnerJoin` studyTerms) -> do + E.on $ studyTerms E.^. StudyTermsId E.==. studyFeatures E.^. StudyFeaturesField + E.on $ studyDegree E.^. StudyDegreeId E.==. studyFeatures E.^. StudyFeaturesDegree + E.where_ . E.and $ catMaybes + [ do + field <- csvEUserField + return . E.or $ catMaybes + [ Just $ studyTerms E.^. StudyTermsName `E.ciEq` E.just (E.val field) + , Just $ studyTerms E.^. StudyTermsShorthand `E.ciEq` E.just (E.val field) + , (studyTerms E.^. StudyTermsKey E.==.) . E.val <$> readMay field + ] + , do + degree <- csvEUserDegree + return . E.or $ catMaybes + [ Just $ studyDegree E.^. StudyDegreeName `E.ciEq` E.just (E.val degree) + , Just $ studyDegree E.^. StudyDegreeShorthand `E.ciEq` E.just (E.val degree) + , (studyDegree E.^. StudyDegreeKey E.==.) . E.val <$> readMay degree + ] + , (studyFeatures E.^. StudyFeaturesSemester E.==.) . E.val <$> csvEUserSemester + ] + E.where_ $ studyFeatures E.^. StudyFeaturesUser E.==. E.val uid + E.&&. studyFeatures E.^. StudyFeaturesType E.==. E.val FieldPrimary + E.&&. studyFeatures E.^. StudyFeaturesValid E.==. E.val True + E.limit 2 + return $ studyFeatures E.^. StudyFeaturesId + case studyFeatures of + [E.Value fid] -> return $ Just fid + _other + | is _Nothing csvEUserField + , is _Nothing csvEUserDegree + , is _Nothing csvEUserSemester + -> return Nothing + _other -> throwM ExamUserCsvExceptionNoMatchingStudyFeatures + + examUsersDBTableValidator = def & defaultSorting [SortAscBy "user-name"] + & defaultPagesize PagesizeAll + + postprocess :: FormResult (First ExamUserActionData, DBFormResult ExamRegistrationId Bool ExamUserTableData) -> FormResult (ExamUserActionData, Set ExamRegistrationId) + postprocess inp = do + (First (Just act), regMap) <- inp + let regSet = Map.keysSet . Map.filter id $ getDBFormResult (const False) regMap + return (act, regSet) + over _1 postprocess <$> dbTable examUsersDBTableValidator examUsersDBTable + + formResult registrationResult $ \case + (ExamUserDeregisterData, selectedRegistrations) -> do + nrDel <- runDB $ deleteWhereCount + [ ExamRegistrationId <-. Set.toList selectedRegistrations + ] + addMessageI Success $ MsgExamUsersDeregistered nrDel + redirect $ CExamR tid ssh csh examn EUsersR + (ExamUserAssignOccurrenceData occId, selectedRegistrations) -> do + nrUpdated <- runDB $ updateWhereCount + [ ExamRegistrationId <-. Set.toList selectedRegistrations + ] + [ ExamRegistrationOccurrence =. occId + ] + addMessageI Success $ MsgExamUsersOccurrenceUpdated nrUpdated + redirect $ CExamR tid ssh csh examn EUsersR + + siteLayoutMsg (prependCourseTitle tid ssh csh MsgExamUsersHeading) $ do + setTitleI $ prependCourseTitle tid ssh csh MsgExamUsersHeading + $(widgetFile "exam-users") diff --git a/src/Handler/Profile.hs b/src/Handler/Profile.hs index 8afac65ce..7b2b5344d 100644 --- a/src/Handler/Profile.hs +++ b/src/Handler/Profile.hs @@ -9,9 +9,11 @@ import Utils.Lens -- import Colonnade hiding (fromMaybe, singleton) -- import Yesod.Colonnade import Data.Monoid (Any(..)) +import Data.Map ((!)) import qualified Data.Map as Map import qualified Data.Set as Set import qualified Database.Esqueleto as E +import qualified Database.Esqueleto.Utils as E -- import Database.Esqueleto ((^.)) @@ -26,6 +28,14 @@ data SettingsForm = SettingsForm , stgNotificationSettings :: NotificationSettings } +data NotificationTriggerKind = NTKAll | NTKCourseParticipant | NTKCorrector | NTKLecturer | NTKAdmin + deriving (Eq, Ord, Enum, Bounded, Generic, Typeable) +instance Universe NotificationTriggerKind +instance Finite NotificationTriggerKind + +embedRenderMessage ''UniWorX ''NotificationTriggerKind $ ("NotificationTriggerKind" <>) . mconcat . drop 1 . splitCamel + + makeSettingForm :: Maybe SettingsForm -> Form SettingsForm makeSettingForm template html = do (result, widget) <- flip (renderAForm FormStandard) html $ SettingsForm @@ -38,7 +48,7 @@ makeSettingForm template html = do <*> areq (selectField $ dateTimeFormatOptions SelFormatDate) (fslI MsgDateFormat) (stgDate <$> template) <*> areq (selectField $ dateTimeFormatOptions SelFormatTime) (fslI MsgTimeFormat) (stgTime <$> template) <* aformSection MsgFormBehaviour - <*> areq checkBoxField (fslI MsgDownloadFiles + <*> apopt checkBoxField (fslI MsgDownloadFiles & setTooltip MsgDownloadFilesTip ) (stgDownloadFiles <$> template) <* aformSection MsgFormNotifications @@ -76,9 +86,64 @@ makeSettingForm template html = do -- nsForm nt = fromMaybe False <$> aopt checkBoxField (fslI nt) (Just $ flip notificationAllowed nt . stgNotificationSettings <$> template) notificationForm :: Maybe NotificationSettings -> AForm Handler NotificationSettings -notificationForm template = NotificationSettings <$> funcForm nsForm (fslI MsgNotificationSettings) True - where - nsForm nt = fromMaybe False <$> aopt checkBoxField (fslI nt) (Just $ flip notificationAllowed nt <$> template) +notificationForm template = wFormToAForm $ do + mbUid <- liftHandlerT maybeAuthId + isAdmin <- hasReadAccessTo AdminR + + let + sectionIsHidden :: NotificationTriggerKind -> DB Bool + sectionIsHidden nt + | isAdmin + = return False + | Just uid <- mbUid + , NTKAdmin <- nt + = E.selectExists . E.from $ \userAdmin -> + E.where_ $ userAdmin E.^. UserAdminUser E.==. E.val uid + | Just uid <- mbUid + , NTKLecturer <- nt + = E.selectExists . E.from $ \userLecturer -> + E.where_ $ userLecturer E.^. UserLecturerUser E.==. E.val uid + | Just uid <- mbUid + , NTKCorrector <- nt + = E.selectExists . E.from $ \sheetCorrector -> + E.where_ $ sheetCorrector E.^. SheetCorrectorUser E.==. E.val uid + | Just uid <- mbUid + , NTKCourseParticipant <- nt + = E.selectExists . E.from $ \courseParticipant -> + E.where_ $ courseParticipant E.^. CourseParticipantUser E.==. E.val uid + | otherwise + = return False + + ntHidden <- liftHandlerT . runDB + $ Set.fromList universeF + & Map.fromSet sectionIsHidden + & sequenceA + & fmap (!) + + let + nsForm nt + | maybe False ntHidden $ ntSection nt + = pure $ notificationAllowed def nt + | nt `elem` forcedTriggers + = aforced checkBoxField (fslI nt) (notificationAllowed def nt) + | otherwise + = apopt checkBoxField (fslI nt) (flip notificationAllowed nt <$> template) + + ntSection = \case + NTSubmissionRatedGraded -> Just NTKCourseParticipant + NTSubmissionRated -> Just NTKCourseParticipant + NTSheetActive -> Just NTKCourseParticipant + NTSheetSoonInactive -> Just NTKCourseParticipant + NTSheetInactive -> Just NTKLecturer + NTCorrectionsAssigned -> Just NTKCorrector + NTCorrectionsNotDistributed -> Just NTKLecturer + NTUserRightsUpdate -> Just NTKAll + NTUserAuthModeUpdate -> Just NTKAll + -- _other -> Nothing + + forcedTriggers = [NTUserRightsUpdate, NTUserAuthModeUpdate] + + aFormToWForm $ NotificationSettings <$> sectionedFuncForm ntSection nsForm (fslI MsgNotificationSettings) False data ButtonResetTokens = BtnResetTokens diff --git a/src/Handler/Sheet.hs b/src/Handler/Sheet.hs index 858a15a42..b6fc50cfa 100644 --- a/src/Handler/Sheet.hs +++ b/src/Handler/Sheet.hs @@ -899,7 +899,7 @@ correctorInvitationConfig = InvitationConfig{..} invitationRoute (Entity _ Sheet{..}) _ = do Course{..} <- get404 sheetCourse return $ CSheetR courseTerm courseSchool courseShorthand sheetName SCorrInviteR - invitationResolveFor = do + invitationResolveFor _ = do Just (CSheetR tid csh ssh shn SCorrInviteR) <- getCurrentRoute fetchSheetId tid csh ssh shn invitationSubject (Entity _ Sheet{..}) _ = do diff --git a/src/Handler/Submission.hs b/src/Handler/Submission.hs index 3d8d4c0e8..fa8decc7f 100644 --- a/src/Handler/Submission.hs +++ b/src/Handler/Submission.hs @@ -89,7 +89,7 @@ submissionUserInvitationConfig = InvitationConfig{..} Course{..} <- getJust sheetCourse cID <- encrypt subId return $ CSubmissionR courseTerm courseSchool courseShorthand sheetName cID SInviteR - invitationResolveFor = do + invitationResolveFor _ = do Just (CSubmissionR _tid _ssh _csh _shn cID SInviteR) <- getCurrentRoute subId <- decrypt cID bool notFound (return subId) =<< existsKey subId diff --git a/src/Handler/Tutorial.hs b/src/Handler/Tutorial.hs index 395f1d44b..5232dad17 100644 --- a/src/Handler/Tutorial.hs +++ b/src/Handler/Tutorial.hs @@ -1,6 +1,8 @@ {-# OPTIONS_GHC -fno-warn-orphans #-} -module Handler.Tutorial where +module Handler.Tutorial + ( module Handler.Tutorial + ) where import Import import Handler.Utils @@ -28,6 +30,8 @@ import Utils.Lens import Data.Aeson hiding (Result(..)) import Text.Hamlet (ihamlet) +import Handler.Tutorial.Users as Handler.Tutorial + {-# ANN module ("Hlint: ignore Redundant void" :: String) #-} @@ -249,7 +253,7 @@ tutorInvitationConfig = InvitationConfig{..} invitationRoute (Entity _ Tutorial{..}) _ = do Course{..} <- get404 tutorialCourse return $ CTutorialR courseTerm courseSchool courseShorthand tutorialName TInviteR - invitationResolveFor = do + invitationResolveFor _ = do Just (CTutorialR tid csh ssh tutn TInviteR) <- getCurrentRoute fetchTutorialId tid csh ssh tutn invitationSubject (Entity _ Tutorial{..}) _ = do diff --git a/src/Handler/Tutorial/Users.hs b/src/Handler/Tutorial/Users.hs new file mode 100644 index 000000000..3650755d5 --- /dev/null +++ b/src/Handler/Tutorial/Users.hs @@ -0,0 +1,73 @@ +module Handler.Tutorial.Users + ( getTUsersR, postTUsersR + ) where + +import Import + +import Utils.Lens +import Utils.Form +-- import Utils.DB +import Handler.Utils +import Handler.Utils.Tutorial +import Handler.Utils.Table.Columns +import Database.Persist.Sql (deleteWhereCount) + +import qualified Data.CaseInsensitive as CI +import Data.Function ((&)) + +import qualified Data.Set as Set + +import qualified Database.Esqueleto as E + +import Handler.Course.Users + + +data TutorialUserAction = TutorialUserSendMail | TutorialUserDeregister + deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic, Typeable) + +instance Universe TutorialUserAction +instance Finite TutorialUserAction +nullaryPathPiece ''TutorialUserAction $ camelToPathPiece' 2 +embedRenderMessage ''UniWorX ''TutorialUserAction id + + +getTUsersR, postTUsersR :: TermId -> SchoolId -> CourseShorthand -> TutorialName -> Handler Html +getTUsersR = postTUsersR +postTUsersR tid ssh csh tutn = do + (Entity tutid Tutorial{..}, (participantRes, participantTable)) <- runDB $ do + tut@(Entity tutid _) <- fetchTutorial tid ssh csh tutn + let colChoices = mconcat + [ dbSelect (applying _2) id (return . view (hasEntity . _entityKey)) + , colUserName + , colUserEmail + , colUserMatriclenr + , colUserDegreeShort + , colUserField + , colUserSemester + ] + psValidator = def + & defaultSortingByName + & restrictSorting (\name _ -> none (== name) ["note"]) -- We need to be careful to restrict allowed sorting/filter to not expose sensitive information + isInTut q = E.exists . E.from $ \tutorialParticipant -> + E.where_ $ tutorialParticipant E.^. TutorialParticipantUser E.==. queryUser q E.^. UserId + E.&&. tutorialParticipant E.^. TutorialParticipantTutorial E.==. E.val tutid + cid <- getKeyBy404 $ TermSchoolCourseShort tid ssh csh + table <- makeCourseUserTable cid isInTut colChoices psValidator + return (tut, table) + + formResult participantRes $ \case + (TutorialUserSendMail, selectedUsers) -> do + cids <- traverse encrypt $ Set.toList selectedUsers :: Handler [CryptoUUIDUser] + redirect (CTutorialR tid ssh csh tutn TCommR, [(toPathPiece GetRecipient, toPathPiece cID) | cID <- cids]) + (TutorialUserDeregister,selectedUsers) -> do + nrDel <- runDB $ deleteWhereCount + [ TutorialParticipantTutorial ==. tutid + , TutorialParticipantUser <-. Set.toList selectedUsers + ] + addMessageI Success $ MsgTutorialUsersDeregistered nrDel + redirect $ CTutorialR tid ssh csh tutn TUsersR + + let heading = prependCourseTitle tid ssh csh $ CI.original tutorialName + siteLayoutMsg heading $ do + setTitleI heading + $(widgetFile "tutorial-participants") diff --git a/src/Handler/Users.hs b/src/Handler/Users.hs index 30470cf3a..804359e44 100644 --- a/src/Handler/Users.hs +++ b/src/Handler/Users.hs @@ -1,3 +1,5 @@ +{-# OPTIONS_GHC -fno-warn-orphans #-} + module Handler.Users where import Import @@ -5,6 +7,11 @@ import Import import Jobs -- import Data.Text import Handler.Utils +import Handler.Utils.Tokens +import Handler.Utils.Users +import Handler.Utils.Invitations + +import qualified Auth.LDAP as Auth import Utils.Lens @@ -18,6 +25,13 @@ import qualified Database.Esqueleto.Utils as E import Handler.Profile (makeProfileData) +import qualified Yesod.Auth.Util.PasswordStore as PWStore + +import qualified Data.ByteString.Base64 as Base64 + +import Text.Hamlet (ihamlet) +import Data.Aeson hiding (Result(..)) + hijackUserForm :: CryptoUUIDUser -> Form () hijackUserForm cID csrf = do @@ -45,6 +59,7 @@ getUsersR = do -- , sortable (Just "last-name") (i18nCell MsgName) $ \DBRow{ dbrOutput = Entity uid User{..} } -> anchorCellM -- (AdminUserR <$> encrypt uid) -- (toWidget . display $ last $ impureNonNull $ words $ userDisplayName) + , sortable (Just "auth-ldap") (i18nCell MsgAuthMode) $ \DBRow{ dbrOutput = Entity _ User{..} } -> i18nCell userAuthentication , sortable Nothing (i18nCell MsgAdminFor) $ \DBRow{ dbrOutput = Entity uid _ } -> flip (set' cellContents) mempty $ do schools <- lift . E.select . E.from $ \(school `E.InnerJoin` userAdmin) -> do E.on $ school E.^. SchoolId E.==. userAdmin E.^. UserAdminSchool @@ -106,6 +121,9 @@ getUsersR = do , ( "matriculation" , SortColumn $ \user -> user E.^. UserMatrikelnummer ) + , ( "auth-ldap" + , SortColumn $ \user -> user E.^. UserAuthentication E.!=. E.val AuthLDAP + ) ] , dbtFilter = Map.fromList -- OverloadedLists does not work with the templates [ ( "user-search", FilterColumn $ \user (criteria :: Set.Set Text) -> @@ -117,6 +135,12 @@ getUsersR = do | Set.null criteria -> E.true -- TODO: why can this be eFalse and work still? | otherwise -> E.any (\c -> user E.^. UserMatrikelnummer `E.hasInfix` E.val c) criteria ) + , ( "auth-ldap", FilterColumn $ \user (criterion :: Last Bool) -> if + | Just crit <- getLast criterion + -> (user E.^. UserAuthentication E.==. E.val AuthLDAP) E.==. E.val crit + | otherwise + -> E.true + ) , ( "school", FilterColumn $ \user criterion -> if | Set.null criterion -> E.val True :: E.SqlExpr (E.Value Bool) | otherwise -> let schools = E.valList (Set.toList criterion) in @@ -134,7 +158,7 @@ getUsersR = do [ prismAForm (singletonFilter "user-search") mPrev $ aopt textField (fslI MsgName) -- , prismAForm (singletonFilter "matriculation" ) mPrev $ aopt textField (fslI MsgMatrikelNr) , prismAForm (singletonFilter "matriculation" ) mPrev $ aopt matriculationField (fslI MsgMatrikelNr) - + , prismAForm (singletonFilter "auth-ldap" . maybePrism _PathPiece) mPrev $ aopt (lift `hoistField` radioFieldList [(MsgAuthPWHash "", False), (MsgAuthLDAP, True)]) (fslI MsgAuthMode) , prismAForm (singletonFilter "school" ) mPrev $ aopt (lift `hoistField` selectFieldList schoolOptions) (fslI MsgCourseSchool) ] , dbtStyle = def { dbsFilterLayout = defaultDBSFilterLayout } @@ -160,6 +184,18 @@ postAdminHijackUserR cID = do maybe (redirect UsersR) return ret +data ButtonAuthMode = BtnAuthLDAP | BtnAuthPWHash | BtnPasswordReset + deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic, Typeable) +instance Universe ButtonAuthMode +instance Finite ButtonAuthMode + +nullaryPathPiece ''ButtonAuthMode $ camelToPathPiece' 1 +embedRenderMessage ''UniWorX ''ButtonAuthMode id + +instance Button UniWorX ButtonAuthMode where + btnClasses _ = [BCIsButton] + + getAdminUserR, postAdminUserR :: CryptoUUIDUser -> Handler Html getAdminUserR = postAdminUserR postAdminUserR uuid = do @@ -196,9 +232,13 @@ postAdminUserR uuid = do let result = forM boxRights $ \(Entity sid _, (resAdmin,_), (resLecturer, _)) -> (,,) <$> pure sid <*> resAdmin <*> resLecturer return (result,$(widgetFile "widgets/user-rights-form/user-rights-form")) + userAuthenticationForm :: Form ButtonAuthMode + userAuthenticationForm = buttonForm' $ if + | userAuthentication == AuthLDAP -> [BtnAuthPWHash] + | otherwise -> [BtnAuthLDAP, BtnPasswordReset] let userRightsAction changes = do - void . runDB $ - forM changes $ \(sid, userAdmin, userLecturer) -> + runDBJobs $ do + forM_ changes $ \(sid, userAdmin, userLecturer) -> if Set.notMember sid adminSchools then return () else do @@ -209,21 +249,70 @@ postAdminUserR uuid = do then void . insertUnique $ UserLecturer uid sid else deleteBy $ UniqueSchoolLecturer uid sid -- Note: deleteWhere would not work well here since we filter by adminSchools - queueJob' . JobQueueNotification $ NotificationUserRightsUpdate uid (over _1 (schoolShorthand . entityVal) <$> userRights) -- original rights to check for difference + queueDBJob . JobQueueNotification $ NotificationUserRightsUpdate uid (over _1 (schoolShorthand . entityVal) <$> userRights) -- original rights to check for difference addMessageI Info MsgAccessRightsSaved - ((result, formWidget),formEnctype) <- runFormPost userRightsForm - let form = wrapForm formWidget def + redirect $ AdminUserR uuid + + userAuthenticationAction = \case + BtnAuthLDAP -> do + let + campusHandler :: MonadPlus m => Auth.CampusUserException -> m a + campusHandler _ = mzero + campusResult <- runMaybeT . handle campusHandler $ do + (Just pool, Just conf) <- getsYesod $ (,) <$> view _appLdapPool <*> view _appLdapConf + let + campusLogin :: AuthPlugin UniWorX + campusLogin = Auth.campusLogin conf pool + void . Auth.campusUser conf pool $ Creds (apName campusLogin) (CI.original userIdent) [] + case campusResult of + Nothing -> addMessageI Error MsgAuthLDAPInvalidLookup + _other + | is _AuthLDAP userAuthentication + -> addMessageI Info MsgAuthLDAPAlreadyConfigured + Just () -> do + runDBJobs $ do + update uid [ UserAuthentication =. AuthLDAP ] + queueDBJob . JobQueueNotification $ NotificationUserAuthModeUpdate uid userAuthentication + + addMessageI Success MsgAuthLDAPConfigured + redirect $ AdminUserR uuid + BtnAuthPWHash -> do + if + | is _AuthPWHash userAuthentication + -> addMessageI Info MsgAuthPWHashAlreadyConfigured + | otherwise + -> do + runDBJobs $ do + update uid [ UserAuthentication =. AuthPWHash "" ] + queueDBJob . JobQueueNotification $ NotificationUserAuthModeUpdate uid userAuthentication + queueDBJob $ JobSendPasswordReset uid + + addMessageI Success MsgAuthPWHashConfigured + redirect $ AdminUserR uuid + BtnPasswordReset -> do + queueJob' $ JobSendPasswordReset uid + addMessageI Success MsgPasswordResetQueued + redirect $ AdminUserR uuid + ((rightsResult, rightsFormWidget),rightsFormEnctype) <- runFormPost userRightsForm + ((authResult, authFormWidget),authFormEnctype) <- runFormPost userAuthenticationForm + let rightsForm = wrapForm rightsFormWidget def { formAction = Just . SomeRoute $ AdminUserR uuid - , formEncoding = formEnctype + , formEncoding = rightsFormEnctype } - formResult result userRightsAction + authForm = wrapForm authFormWidget def + { formAction = Just . SomeRoute $ AdminUserR uuid + , formEncoding = authFormEnctype + , formSubmit = FormNoSubmit + } + formResult rightsResult userRightsAction + formResult authResult userAuthenticationAction let heading = - [whamlet|_{MsgAccessRightsFor} ^{nameWidget userDisplayName userSurname}|] + [whamlet|_{MsgAdminUserHeadingFor} ^{nameEmailWidget userEmail userDisplayName userSurname}|] -- Delete Button needed in data-delete - (btnWgt, btnEnctype) <- generateFormPost (identifyForm FIDUserDelete buttonForm :: Form ButtonDelete) - let btnForm = wrapForm btnWgt def + (deleteWgt, deleteEnctype) <- generateFormPost (identifyForm FIDUserDelete buttonForm :: Form ButtonDelete) + let deleteForm = wrapForm deleteWgt def { formAction = Just $ SomeRoute $ AdminUserDeleteR uuid - , formEncoding = btnEnctype + , formEncoding = deleteEnctype , formSubmit = FormNoSubmit } userDataWidget <- runDB $ makeProfileData $ Entity uid user @@ -300,3 +389,149 @@ deleteUser duid = do E.where_ $ subGroupUser E.^. SubmissionGroupUserSubmissionGroup E.==. submissionGroup E.^. SubmissionGroupId E.&&. subGroupUser E.^. SubmissionGroupUserUser E.!=. E.val duid + +getUserPasswordR, postUserPasswordR :: CryptoUUIDUser -> Handler Html +getUserPasswordR = postUserPasswordR +postUserPasswordR cID = do + tUid <- decrypt cID + User{..} <- runDB $ get404 tUid + PWHashConf{..} <- getsYesod $ view _appAuthPWHash + isModal <- hasCustomHeader HeaderIsModal + + isAdmin <- hasWriteAccessTo $ AdminUserR cID + + requireCurrent <- maybeT (return True) $ asum + [ False <$ guard (isn't _AuthPWHash userAuthentication) + , False <$ guard isAdmin + , do + authMode <- Base64.decodeLenient . encodeUtf8 <$> MaybeT maybeCurrentTokenRestrictions + unless (authMode `constEq` computeUserAuthenticationDigest userAuthentication) . lift $ + invalidArgsI [MsgUnauthorizedPasswordResetToken] + return False + ] + + ((passResult, passFormWidget), passEnctype) <- runFormPost . formEmbedJwtPost . renderAForm FormStandard . wFormToAForm $ do + currentResult <- if + | AuthPWHash (encodeUtf8 -> pwHash) <- userAuthentication + , requireCurrent + -> wreq + (checkMap (bool (Left MsgCurrentPasswordInvalid) (Right ()) . flip (PWStore.verifyPasswordWith pwHashAlgorithm (2^)) pwHash . encodeUtf8) (const "") passwordField) + (fslI MsgCurrentPassword) + Nothing + | otherwise + -> return $ FormSuccess () + + newResult <- do + resA <- wreq passwordField (fslI MsgNewPassword) Nothing + wreq (checkBool ((== resA) . FormSuccess) MsgPasswordRepeatInvalid passwordField) (fslI MsgNewPasswordRepeat) Nothing + + return . fmap encodeUtf8 $ currentResult *> newResult + + formResultModal passResult (bool ProfileR (UserPasswordR cID) isAdmin) $ \newPass -> do + newHash <- fmap decodeUtf8 . liftIO $ PWStore.makePasswordWith pwHashAlgorithm newPass pwHashStrength + liftHandlerT . runDB $ update tUid [ UserAuthentication =. AuthPWHash newHash ] + tell . pure =<< messageI Success MsgPasswordChangedSuccess + + siteLayout [whamlet|_{MsgUserPasswordHeadingFor} ^{nameEmailWidget userEmail userDisplayName userSurname}|] $ + wrapForm passFormWidget def + { formAction = Just . SomeRoute $ UserPasswordR cID + , formEncoding = passEnctype + , formAttrs = [ asyncSubmitAttr | isModal ] + } + + +instance IsInvitableJunction UserLecturer where + type InvitationFor UserLecturer = School + data InvitableJunction UserLecturer = JunctionUserLecturer + deriving (Eq, Ord, Read, Show, Generic, Typeable) + data InvitationDBData UserLecturer = InvDBDataUserLecturer + deriving (Eq, Ord, Read, Show, Generic, Typeable) + data InvitationTokenData UserLecturer = InvTokenDataUserLecturer + { invTokenUserLecturerSchool :: SchoolShorthand + } + deriving (Eq, Ord, Read, Show, Generic, Typeable) + + _InvitableJunction = iso + (\UserLecturer{..} -> (userLecturerUser, userLecturerSchool, JunctionUserLecturer)) + (\(userLecturerUser, userLecturerSchool, JunctionUserLecturer) -> UserLecturer{..}) + +instance ToJSON (InvitableJunction UserLecturer) where + toJSON = genericToJSON defaultOptions { constructorTagModifier = camelToPathPiece' 1 } + toEncoding = genericToEncoding defaultOptions { constructorTagModifier = camelToPathPiece' 1 } +instance FromJSON (InvitableJunction UserLecturer) where + parseJSON = genericParseJSON defaultOptions { constructorTagModifier = camelToPathPiece' 1 } + +instance ToJSON (InvitationDBData UserLecturer) where + toJSON = genericToJSON defaultOptions { constructorTagModifier = camelToPathPiece' 3 } + toEncoding = genericToEncoding defaultOptions { constructorTagModifier = camelToPathPiece' 3 } +instance FromJSON (InvitationDBData UserLecturer) where + parseJSON = genericParseJSON defaultOptions { constructorTagModifier = camelToPathPiece' 3 } + +instance ToJSON (InvitationTokenData UserLecturer) where + toJSON = genericToJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 3 } + toEncoding = genericToEncoding defaultOptions { fieldLabelModifier = camelToPathPiece' 3 } +instance FromJSON (InvitationTokenData UserLecturer) where + parseJSON = genericParseJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 3 } + +lecturerInvitationConfig :: InvitationConfig UserLecturer +lecturerInvitationConfig = InvitationConfig{..} + where + invitationRoute _ _ = return AdminLecturerInviteR + invitationResolveFor InvTokenDataUserLecturer{..} = return $ SchoolKey invTokenUserLecturerSchool + invitationSubject (Entity _ School{..}) _ = return . SomeMessage $ MsgMailSubjectSchoolLecturerInvitation schoolName + invitationHeading (Entity _ School{..}) _ = return . SomeMessage $ MsgMailSchoolLecturerInviteHeading schoolName + invitationExplanation _ _ = [ihamlet|_{SomeMessage MsgSchoolLecturerInviteExplanation}|] + invitationTokenConfig _ _ = do + itAuthority <- liftHandlerT requireAuthId + return $ InvitationTokenConfig itAuthority Nothing Nothing Nothing + invitationRestriction _ _ = return Authorized + invitationForm _ _ _ = pure $ (JunctionUserLecturer, ()) + invitationInsertHook _ _ _ _ = id + invitationSuccessMsg (Entity _ School{..}) _ = return . SomeMessage $ MsgSchoolLecturerInvitationAccepted schoolName + invitationUltDest (Entity ssh _) _ = do + currentTerm <- E.select . E.from $ \term -> do + E.where_ $ term E.^. TermActive + E.orderBy [E.desc $ term E.^. TermName] + E.limit 1 + return $ term E.^. TermId + return . SomeRoute $ case currentTerm of + [E.Value tid] -> TermSchoolCourseListR tid ssh + _other -> CourseListR + + +getAdminNewLecturerInviteR, postAdminNewLecturerInviteR :: Handler Html +getAdminNewLecturerInviteR = postAdminNewLecturerInviteR +postAdminNewLecturerInviteR = do + uid <- requireAuthId + userSchools <- runDB . E.select . E.from $ \userAdmin -> do + E.where_ $ userAdmin E.^. UserAdminUser E.==. E.val uid + return $ userAdmin E.^. UserAdminSchool + + ((invitesResult, invitesWgt), invitesEncoding) <- runFormPost . renderWForm FormStandard $ do + school <- wreq (schoolFieldFor $ map E.unValue userSchools) (fslI MsgLecturerInviteSchool) Nothing + users <- wreq (multiUserField False Nothing) (fslI MsgLecturerInviteField & setTooltip MsgMultiEmailFieldTip) Nothing + return $ (,) <$> school <*> users + + formResultModal invitesResult UsersR $ \(schoolId, users) -> do + let (emails, uids) = partitionEithers $ Set.toList users + lift . runDBJobs $ do + forM_ uids $ \lecId -> + void . insertUnique $ UserLecturer lecId schoolId + + sinkInvitationsF lecturerInvitationConfig [ (mail, schoolId, (InvDBDataUserLecturer, InvTokenDataUserLecturer $ unSchoolKey schoolId)) | mail <- emails ] + + unless (null emails) $ + tell . pure <=< messageI Success . MsgLecturersInvited $ length emails + unless (null uids) $ + tell . pure <=< messageI Success . MsgLecturersAdded $ length uids + + siteLayoutMsg MsgLecturerInviteHeading $ do + setTitleI MsgLecturerInviteHeading + wrapForm invitesWgt def + { formEncoding = invitesEncoding + , formAction = Just $ SomeRoute AdminNewLecturerInviteR + } + +getAdminLecturerInviteR, postAdminLecturerInviteR :: Handler Html +getAdminLecturerInviteR = postAdminLecturerInviteR +postAdminLecturerInviteR = invitationR lecturerInvitationConfig diff --git a/src/Handler/Utils/Form.hs b/src/Handler/Utils/Form.hs index b7548543c..d6c80900c 100644 --- a/src/Handler/Utils/Form.hs +++ b/src/Handler/Utils/Form.hs @@ -855,16 +855,34 @@ boolField = Field -funcForm :: forall k v m. - ( Finite k, Ord k - , MonadHandler m - , HandlerSite m ~ UniWorX - ) - => (k -> AForm m v) -> FieldSettings UniWorX -> Bool -> AForm m (k -> v) -funcForm mkForm FieldSettings{fsName = _, fsAttrs = _, ..} isRequired = formToAForm $ funcFieldView =<< renderAForm FormStandard funcForm' mempty +sectionedFuncForm :: forall k v m sec. + ( Finite k, Ord k + , MonadHandler m + , HandlerSite m ~ UniWorX + , RenderMessage UniWorX sec + , Ord sec + ) + => (k -> Maybe sec) -> (k -> AForm m v) -> FieldSettings UniWorX -> Bool -> AForm m (k -> v) +sectionedFuncForm mkSection mkForm FieldSettings{fsName = _, fsAttrs = _, ..} isRequired = formToAForm $ funcFieldView =<< renderAForm FormStandard funcForm' mempty where funcForm' :: AForm m (k -> v) - funcForm' = fmap (!) . sequenceA . Map.fromSet mkForm $ Set.fromList universeF + funcForm' = Set.fromList universeF + & foldr (\v -> Map.unionWith Set.union $ Map.singleton (mkSection v) (Set.singleton v)) Map.empty + & fmap (Map.fromSet mkForm) + & fmap sequenceA + & Map.foldrWithKey accSections (pure Map.empty) + & fmap (!) + accSections mSection optsForm acc = wFormToAForm $ do + (res, fs) <- wFormFields $ aFormToWForm optsForm + if + | not $ null fs + , Just section <- mSection + -> wformSection section + | otherwise + -> return () + lift $ tell fs + aFormToWForm $ Map.union <$> wFormToAForm (pure res) <*> acc + funcFieldView :: (FormResult (k -> v), Widget) -> MForm m (FormResult (k -> v), [FieldView UniWorX]) funcFieldView (res, fvInput) = do mr <- getMessageRender @@ -879,6 +897,15 @@ funcForm mkForm FieldSettings{fsName = _, fsAttrs = _, ..} isRequired = formToAF -- areq nsField (fslI MsgNotificationSettings) (stgNotficationSettings <$> template) +funcForm :: forall k v m. + ( Finite k, Ord k + , MonadHandler m + , HandlerSite m ~ UniWorX + ) + => (k -> AForm m v) -> FieldSettings UniWorX -> Bool -> AForm m (k -> v) +funcForm = sectionedFuncForm $ const (Nothing :: Maybe Text) + + fsm :: RenderMessage UniWorX msg => msg -> FieldSettings UniWorX -- DEPRECATED @@ -1000,3 +1027,51 @@ multiUserField onlySuggested suggestions = Field{..} [] -> return $ Left email [E.Value uid] -> return $ Right uid _other -> fail "Ambiguous e-mail addr" + +examResultField :: forall m res. + ( MonadHandler m + , HandlerSite m ~ UniWorX + , PathPiece res + ) + => Field m res -> Field m (ExamResult' res) +examResultField innerField = Field + { fieldEnctype = UrlEncoded <> fieldEnctype innerField + , fieldParse = \ts fs -> if + | [t] <- ts + , Just res <- fromPathPiece t + , is _ExamNoShow res || is _ExamVoided res + -> return . Right $ Just res + | otherwise + -> fmap (fmap ExamAttended) <$> fieldParse innerField (filter (not . (`elem` ["attended", "no-show", "voided"])) ts) fs + , fieldView = \theId name attrs val isReq -> do + innerId <- newIdent + let + val' :: ExamResult' (Either Text res) + val' = either (ExamAttended . Left) (fmap Right) val + innerVal :: Either Text res + innerVal = val >>= maybe (Left "") return . preview _ExamAttended + [whamlet| + $newline never +