diff --git a/CHANGELOG.md b/CHANGELOG.md index c38052ada..b4f2bc1d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,149 @@ 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. +### [7.3.2](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/compare/v7.3.1...v7.3.2) (2019-10-01) + + +### Bug Fixes + +* **exam-users:** make csv import much more lenient ([2ddb566](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/2ddb566)) +* **mail:** honor userCsvOptions and userDisplayEmail ([89adf7f](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/89adf7f)) + + + +### [7.3.1](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/compare/v7.3.0...v7.3.1) (2019-09-30) + + +### Bug Fixes + +* **course-edit:** edit courses without being school-wide lecturer ([d7d1f27](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/d7d1f27)), closes [#464](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/issues/464) + + + +## [7.3.0](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/compare/v7.2.2...v7.3.0) (2019-09-30) + + +### Bug Fixes + +* **course-application:** better display of priorities ([64f7715](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/64f7715)) + + +### Features + +* **csv:** allow customisation of csv-export-options ([95ceedd](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/95ceedd)) + + + +### [7.2.2](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/compare/v7.2.1...v7.2.2) (2019-09-30) + + +### Bug Fixes + +* **authorisation:** keep showing allocations (ro) to lecturers ([c8e1d51](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/c8e1d51)) + + + +### [7.2.1](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/compare/v7.2.0...v7.2.1) (2019-09-28) + + +### Bug Fixes + +* fix build ([69f4a80](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/69f4a80)) +* fix tutorial registration group applying globally ([d2ba173](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/d2ba173)) + + + +## [7.2.0](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/compare/v7.1.2...v7.2.0) (2019-09-27) + + +### Bug Fixes + +* bump changelog ([60a7bb2](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/60a7bb2)) +* don't treat ExamBonusManual as override ([16abcd2](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/16abcd2)) + + +### Features + +* **course-applications:** automatic acceptance of direct applicants ([620950d](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/620950d)) + + + +### [7.1.2](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/compare/v7.1.1...v7.1.2) (2019-09-26) + + +### Bug Fixes + +* **exams:** include bonus points in sum for exam participants ([2bc6894](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/2bc6894)) + + + +### [7.1.1](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/compare/v7.1.0...v7.1.1) (2019-09-26) + + +### Bug Fixes + +* fix build ([d13ace4](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/d13ace4)) + + + +## [7.1.0](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/compare/v7.0.0...v7.1.0) (2019-09-26) + + +### Bug Fixes + +* **datepicker:** select time from preselected date on edit ([d3375bb](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/d3375bb)) +* **jobs:** cleaner shutdown of job-pool-manager ([adc8d46](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/adc8d46)) + + +### Features + +* **exams:** re-introduce ExamBonusManual ([54e94a6](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/54e94a6)) + + + +## [7.0.0](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/compare/v6.11.1...v7.0.0) (2019-09-25) + + +### Bug Fixes + +* fix startup on unix-socket ([39f1295](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/39f1295)) +* improve async behaviour ([cc7a528](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/cc7a528)) +* make migration idempotent again ([9778404](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/9778404)) +* restore behaviour of waiting asynchronously for job-management ([5ebcd89](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/5ebcd89)) +* **communication:** make communication form more intuitive ([7a2b972](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/7a2b972)), closes [#387](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/issues/387) +* fix migration ([d2478a3](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/d2478a3)) +* fix migration & tests ([e05ea8e](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/e05ea8e)) +* migration ([4383eb1](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/4383eb1)) +* syntax ([7afd569](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/7afd569)) +* **migration:** drop more tables in w.a. for inconsistent 21→22 ([d79dca6](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/d79dca6)) +* typo ([fb1e42d](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/fb1e42d)) + + +### chore + +* bump versions ([67e3b38](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/67e3b38)) + + +### Features + +* **course:** additional crosslinking ([5eaba78](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/5eaba78)) +* **exam-users:** document part-* family of columns ([fe07a22](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/fe07a22)) +* **exams:** accept/reset computed results ([72342f1](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/72342f1)) +* **exams:** automatically compute examResults ([ea5a398](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/ea5a398)) +* **exams:** better display exam-result-information ([0ebda4d](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/0ebda4d)) +* **exams:** csv-import of ExamPartResults ([29f4e28](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/29f4e28)) +* **exams:** implement rounding of exambonus ([e97cd56](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/e97cd56)) +* **exams:** refine exam form ([014a17a](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/014a17a)) + + +### BREAKING CHANGES + +* yesod >=1.6 +* **exams:** examPartName no longer required +* **exams:** Introduces ExamPartNumbers + + + ### [6.11.1](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/compare/v6.11.0...v6.11.1) (2019-09-17) diff --git a/app/DevelMain.hs b/app/DevelMain.hs index 0a7a89562..b850b33b2 100644 --- a/app/DevelMain.hs +++ b/app/DevelMain.hs @@ -67,7 +67,7 @@ update = do restartAppInNewThread tidStore = modifyStoredIORef tidStore $ \tid -> do killThread tid withStore doneStore takeMVar - readStore doneStore >>= start + withStore doneStore start -- | Start the server in a separate thread. @@ -77,10 +77,7 @@ update = do (port, site, app) <- getApplicationRepl resourceForkIO $ do finally (liftIO $ runSettings (setPort port defaultSettings) app) - -- Note that this implies concurrency - -- between shutdownApp and the next app that is starting. - -- Normally this should be fine - (liftIO $ putMVar done () >> shutdownApp site) + (liftIO $ shutdownApp site `finally` putMVar done ()) -- | kill the server shutdown :: IO () diff --git a/config/test-settings.yml b/config/test-settings.yml index 23f59aed5..5fb61bedf 100644 --- a/config/test-settings.yml +++ b/config/test-settings.yml @@ -8,3 +8,5 @@ log-settings: destination: "test.log" auth-dummy-login: true + +job-workers: 1 diff --git a/frontend/src/utils/form/datepicker.js b/frontend/src/utils/form/datepicker.js index 11d7394bc..c116e8ea6 100644 --- a/frontend/src/utils/form/datepicker.js +++ b/frontend/src/utils/form/datepicker.js @@ -24,17 +24,6 @@ const FORM_DATE_FORMAT_MOMENT = { 'datetime-local': `${FORM_DATE_FORMAT_DATE_MOMENT} ${FORM_DATE_FORMAT_TIME_MOMENT}`, }; -/** - * Takes a string representation of a date and a format string and parses the given date to a Date object. - * If the date string is not valid (i.e. cannot be parsed with the given format string), returns undefined. - * @param {*} dateStr string representation of a date - * @param {*} dateFormat format string of the date - */ -function parseDateWithFormat(dateStr, dateFormat) { - const parsedMomentDate = moment(dateStr, dateFormat); - if (parsedMomentDate.isValid()) return parsedMomentDate.toDate(); -} - /** * Takes a string representation of a date, an input ('previous') format and a desired output format and returns a reformatted date string. * If the date string is not valid (i.e. cannot be parsed with the given input format string), returns the original date string; @@ -136,6 +125,9 @@ export class Datepicker { throw new Error('Datepicker utility called on unsupported element!'); } + // format any existing dates to fancy display format on pageload + this.formatElementValue(true); + // initialize tail.datetime (datepicker) instance this.datepickerInstance = datetime(this._element, { ...datepickerGlobalConfig, ...datepickerConfig }); @@ -187,9 +179,6 @@ export class Datepicker { // format the date value of the form input element of this datepicker before form submission this._element.form.addEventListener('submit', () => this.formatElementValue()); - - // format any existing dates to fancy display format on pageload - this.formatElementValue(true); } destroy() { @@ -201,22 +190,21 @@ export class Datepicker { * @param {*} toFancy optional target format switch (boolean value; default is false). If set to a truthy value, formats the element value to fancy instead of internal date format. */ formatElementValue(toFancy) { - const dp = this.datepickerInstance; if (this._element.value) { - if (toFancy) { - const parsedDate = parseDateWithFormat(this._element.value, FORM_DATE_FORMAT[this.elementType]); - if (parsedDate) dp.selectDate(); - } else { - this._element.value = this.unformat(); - } + this._element.value = this.unformat(toFancy); } } + + /** * Returns a datestring in internal format from the current state of the input element value. + * @param {*} toFancy Format date from internal to fancy or vice versa. When omitted, toFancy is falsy and results in fancy -> internal */ - unformat() { - return reformatDateString(this._element.value, FORM_DATE_FORMAT_MOMENT[this.elementType], FORM_DATE_FORMAT[this.elementType]); + unformat(toFancy) { + const formatIn = toFancy ? FORM_DATE_FORMAT[this.elementType] : FORM_DATE_FORMAT_MOMENT[this.elementType]; + const formatOut = toFancy ? FORM_DATE_FORMAT_MOMENT[this.elementType] : FORM_DATE_FORMAT[this.elementType]; + return reformatDateString(this._element.value, formatIn, formatOut); } /** diff --git a/haddock.sh b/haddock.sh index 13bb626e0..00308065f 100755 --- a/haddock.sh +++ b/haddock.sh @@ -15,4 +15,4 @@ if [[ -d .stack-work-doc ]]; then trap move-back EXIT fi -stack build --fast --flag uniworx:library-only --flag uniworx:dev --haddock --haddock-hyperlink-source --haddock-deps --haddock-internal +stack build --fast --flag uniworx:library-only --flag uniworx:dev --haddock --haddock-hyperlink-source --haddock-deps --haddock-internal ${@} diff --git a/is-clean.sh b/is-clean.sh index b63b54f46..4bcf4bd7d 100755 --- a/is-clean.sh +++ b/is-clean.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash +[[ -n "${FORCE_RELEASE}" ]] && exit 0 + set -e if [ -n "$(git status --porcelain)" ]; then diff --git a/messages/uniworx/de.msg b/messages/uniworx/de.msg index 49ee66512..aac9afe06 100644 --- a/messages/uniworx/de.msg +++ b/messages/uniworx/de.msg @@ -173,6 +173,7 @@ CourseApplicationTemplateApplication: Bewerbungsvorlage(n) CourseApplicationTemplateRegistration: Anmeldungsvorlage(n) CourseApplicationTemplateArchiveName tid@TermId ssh@SchoolId csh@CourseShorthand: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-bewerbungsvorlagen CourseApplication: Bewerbung +CourseApplicationIsParticipant: Kursteilnehmer CourseApplicationExists: Sie haben sich bereits für diesen Kurs beworben CourseApplicationInvalidAction: Angegeben Aktion kann nicht durchgeführt werden @@ -1135,10 +1136,14 @@ NavigationFavourites: Favoriten CommSubject: Betreff CommBody: Nachricht +CommBodyTip: Das Eingabefeld akzeptiert derzeit ausschließlich Html. U.A. Zeilumbrüche werden dementsprechend ignoriert und müssen manuell mit
eingefügt werden. CommRecipients: Empfänger CommRecipientsTip: Sie selbst erhalten immer eine Kopie der Nachricht +CommRecipientsList: Die an Sie selbst verschickte Kopie der Nachricht wird, zu Archivierungszwecken, eine vollständige Liste aller Empfänger enthalten. Die Empfängerliste wird im CSV-Format and die E-Mail angehängt. Andere Empfänger erhalten die Liste nicht. Bitte entfernen Sie dementsprechend den Anhang bevor Sie die E-Mail weiterleiten oder anderweitig mit Dritten teilen. CommDuplicateRecipients n@Int: #{n} #{pluralDE n "doppelter" "doppelte"} Empfänger ignoriert CommSuccess n@Int: Nachricht wurde an #{n} Empfänger versandt +CommUndisclosedRecipients: Verborgene Empfänger +CommAllRecipients: alle-empfaenger CommCourseHeading: Kursmitteilung CommTutorialHeading: Tutorium-Mitteilung @@ -1346,6 +1351,7 @@ ExamBonus: Bonuspunkte-System ExamBonusRule: Prüfungsbonus aus Übungsbetrieb ExamNoBonus': Kein automatischer Bonus ExamBonusPoints': Umrechnung von Übungspunkten +ExamBonusManual': Manuelle Berechnung ExamBonusAchieved: Bonuspunkte @@ -1415,6 +1421,7 @@ ExamEdited exam@ExamName: #{exam} erfolgreich bearbeitet ExamNoShow: Nicht erschienen ExamVoided: Entwertet +ExamBonusManualParticipants: Von den Kursverwaltern manuell berechnet ExamBonusPoints possible@Points: Maximal #{showFixed True possible} Prüfungspunkte ExamBonusPointsPassed possible@Points: Maximal #{showFixed True possible} Prüfungspunkte, falls die Prüfung auch ohne Bonus bereits bestanden ist @@ -1494,8 +1501,8 @@ Proportion c@Text of@Text prop@Rational: #{c}/#{of} (#{rationalToFixed2 (100 * p ExamUserCsvName tid@TermId ssh@SchoolId csh@CourseShorthand examn@ExamName: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-#{foldedCase examn}-teilnehmer CourseApplicationsTableCsvName tid@TermId ssh@SchoolId csh@CourseShorthand: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-bewerbungen -CsvColumnsExplanationsLabel: Spalten -CsvColumnsExplanationsTip: Bedeutung der in der CSV-Datei enthaltenen Spalten +CsvColumnsExplanationsLabel: Spalten- & Zellenformat +CsvColumnsExplanationsTip: Bedeutung und Format der in der CSV-Datei enthaltenen Spalten CsvColumnExamUserSurname: Nachname(n) des Teilnehmers CsvColumnExamUserFirstName: Vorname(n) des Teilnehmers CsvColumnExamUserName: Voller Name des Teilnehmers (gewöhnlicherweise inkl. Vor- und Nachname(n)) @@ -1525,7 +1532,7 @@ CsvColumnApplicationsSemester: Fachsemester des Bewerbes im assoziierten Studien CsvColumnApplicationsText: Text-Bewerbung CsvColumnApplicationsHasFiles: Hat der Bewerber Dateien zu seiner Bewerbung eingereicht (siehe ZIP-Archiv aller Bewerbungsdateien)? CsvColumnApplicationsVeto: Bewerber mit Veto werden garantiert nicht dem Kurs zugeteilt; "veto" oder leer -CsvColumnApplicationsRating: Bewertung der Bewerbung; "1.0", "1.3", "1.7", ..., "4.0", "5.0" +CsvColumnApplicationsRating: Bewertung der Bewerbung; "1.0", "1.3", "1.7", ..., "4.0", "5.0" (Leer wird behandelt wie eine Note zwischen 2.3 und 2.7) CsvColumnApplicationsComment: Kommentar zur Bewerbung; je nach Kurs-Einstellungen entweder nur als Notiz für die Kursverwalter oder Feedback für den Bewerber Action: Aktion @@ -1785,3 +1792,38 @@ ExamClosedSince time@Text: Klausur abgeschlossen seit #{time} LecturerInfoTooltipNew: Neues Feature LecturerInfoTooltipProblem: Noch nicht implementiertes Feature oder Feature mit bekannten Problemen + +BtnAcceptApplications: Bewerbungen akzeptieren +BtnAcceptApplicationsTip: Mit dem untigen Knopf können Sie den Kurs (höchstens bis zur angegeben Maximalkapazität, falls eingestellt) mit Bewerbern auffüllen. Die Bewertungen der Bewerbungen werden dabei berücksichtigt (Unbewertet wird behandelt wie eine Note zwischen 2.3 und 2.7). Bewerber mit Veto oder 5.0 werden nicht angemeldet. +AcceptApplicationsMode: Bewerbungen akzeptieren +AcceptApplicationsModeTip: Sollen akzeptierte Bewerber direkt als Teilnehmer im Kurs eingetragen werden oder sollen Einladungen per E-Mail verschickt werden? +AcceptApplicationsDirect: Direkt anmelden +AcceptApplicationsInvite: Einladungen verschicken +AcceptApplicationsSecondary: Gleichstände auflösen +AcceptApplicationsSecondaryTip: Wenn es im Laufe des Verfahrens mehrere Bewerber mit der selben Bewertung für den selben Platz gibt, wie soll der Gleichstand aufgelöst werden? +AcceptApplicationsSecondaryRandom: Zufällig +AcceptApplicationsSecondaryTime: Nach Zeitpunkt der Bewerbung + +CsvOptions: CSV-Optionen +CsvOptionsTip: Diese Einstellungen betreffen nur den CSV-Export; beim Import werden die verwendeten Einstellungen automatisch ermittelt. +CsvPresetRFC: Standard-Konform (RFC 4180) +CsvPresetExcel: Excel-Kompatibel +CsvCustom: Benutzerdefiniert +CsvDelimiter: Trennzeichen +CsvUseCrLf: Zeilenumbrüche +CsvQuoting: Quoting +CsvQuotingTip: Wann sollen Anführungszeichen (") um Felder platziert werden, um Interpretation von im Feld enthaltenen Zeichen als Trennzeichen zu verhindern? +CsvDelimiterNull: Null-Byte +CsvDelimiterTab: Tabulator +CsvDelimiterComma: Komma +CsvDelimiterColon: Doppelpunkt +CsvDelimiterBar: Senkrechter Strich +CsvDelimiterSpace: Leerzeichen +CsvDelimiterUnitSep: Teilgruppentrennzeichen +CsvCrLf: DOS (CRLF) +CsvLf: Unix (LF) +CsvQuoteNone: Nie +CsvQuoteMinimal: Nur wenn nötig +CsvQuoteAll: Immer +CsvOptionsUpdated: CSV-Optionen erfolgreich angepasst +CsvChangeOptionsLabel: Export-Optionen diff --git a/models/courses b/models/courses index 758f6980d..260c4254f 100644 --- a/models/courses +++ b/models/courses @@ -20,7 +20,7 @@ Course -- Information about a single course; contained info is always visible applicationsRequired Bool default=false applicationsInstructions Html Maybe applicationsText Bool default=false - applicationsFiles UploadMode "default='{ \"mode\": \"no-upload\" }'::jsonb" + applicationsFiles UploadMode "default='{\"mode\": \"no-upload\"}'::jsonb" applicationsRatingsVisible Bool default=false TermSchoolCourseShort term school shorthand -- shorthand must be unique within school and semester TermSchoolCourseName term school name -- name must be unique within school and semester diff --git a/models/users b/models/users index 22c14f1dc..14c0ddc2e 100644 --- a/models/users +++ b/models/users @@ -30,6 +30,7 @@ User json -- Each Uni2work user has a corresponding row in this table; create mailLanguages MailLanguages "default='[]'::jsonb" -- Preferred language for eMail; i18n not yet implemented; user-defined notificationSettings NotificationSettings -- Bit-array for which events email notifications are requested by user; user-defined warningDays NominalDiffTime default=1209600 -- timedistance to pending deadlines for homepage infos + csvOptions CsvOptions "default='{}'::jsonb" UniqueAuthentication ident -- Column 'ident' can be used as a row-key in this table UniqueEmail email -- Column 'email' can be used as a row-key in this table deriving Show Eq Ord Generic -- Haskell-specific settings for runtime-value representing a row in memory diff --git a/package-lock.json b/package-lock.json index 67c13819f..e159c21a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "uni2work", - "version": "6.11.1", + "version": "7.3.2", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 14784856d..9d2dd5e34 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "uni2work", - "version": "6.11.1", + "version": "7.3.2", "description": "", "keywords": [], "author": "", diff --git a/package.yaml b/package.yaml index f7445cd95..9b0b96354 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: uniworx -version: 6.11.1 +version: 7.3.2 dependencies: - base >=4.9.1.0 && <5 diff --git a/routes b/routes index 79b77524e..f056b1f98 100644 --- a/routes +++ b/routes @@ -72,6 +72,7 @@ /user/profile ProfileDataR GET !free /user/authpreds AuthPredsR GET POST !free /user/set-display-email SetDisplayEmailR GET POST !free +/user/csv-options CsvOptionsR GET POST !free /exam-office ExamOfficeR !exam-office: / EOExamsR GET diff --git a/src/Application.hs b/src/Application.hs index ab50d34e6..41ca6fed4 100644 --- a/src/Application.hs +++ b/src/Application.hs @@ -24,7 +24,7 @@ import Language.Haskell.TH.Syntax (qLocation) import Network.Wai (Middleware) import Network.Wai.Handler.Warp (Settings, defaultSettings, defaultShouldDisplayException, - runSettingsSocket, setHost, + runSettings, runSettingsSocket, setHost, setBeforeMainLoop, setOnException, setPort, getPort) import Data.Streaming.Network (bindPortTCP) @@ -74,14 +74,15 @@ import qualified Database.Memcached.Binary.IO as Memcached import qualified System.Systemd.Daemon as Systemd import System.Environment (lookupEnv) import System.Posix.Process (getProcessID) -import System.Posix.Signals (SignalInfo(..), installHandler, sigTERM) +import System.Posix.Signals (SignalInfo(..), installHandler, sigTERM, sigINT) import qualified System.Posix.Signals as Signals (Handler(..)) -import Network.Socket (socketPort) +import Network.Socket (socketPort, Socket, PortNumber) import qualified Network.Socket as Socket (close) import Control.Concurrent.STM.Delay import Control.Monad.STM (retry) +import Control.Monad.Trans.Cont (runContT, callCC) import qualified Data.Set as Set @@ -366,11 +367,20 @@ develMain = runResourceT $ do wsettings <- liftIO . getDevSettings $ warpSettings foundation app <- makeApplication foundation + let + awaitTermination :: IO () + awaitTermination + = flip runContT return . forever $ do + lift $ threadDelay 100e3 + whenM (lift $ doesFileExist "yesod-devel/devel-terminate") $ + callCC ($ ()) + + void . liftIO $ installHandler sigINT (Signals.Catch $ return ()) Nothing runAppLoggingT foundation $ handleJobs foundation - liftIO . develMainHelper $ return (wsettings, app) + void . liftIO $ awaitTermination `race` runSettings wsettings app -- | The @main@ function for an executable running this site. -appMain :: MonadUnliftIO m => m () +appMain :: forall m. (MonadUnliftIO m, MonadMask m) => m () appMain = runResourceT $ do settings <- getAppSettings @@ -398,7 +408,7 @@ appMain = runResourceT $ do $logInfoS "bind" [st|Listening on #{tshow host} port #{tshow port} as per configuration|] liftIO $ pure <$> bindPortTCP port host - $logDebugS "bind" . tshow =<< mapM (liftIO . socketPort) sockets + $logDebugS "bind" . tshow =<< mapM (liftIO . try . socketPort :: Socket -> _ (Either SomeException PortNumber)) sockets mainThreadId <- myThreadId liftIO . void . flip (installHandler sigTERM) Nothing . Signals.CatchInfo $ \SignalInfo{..} -> runAppLoggingT foundation $ do @@ -462,7 +472,7 @@ appMain = runResourceT $ do foundationStoreNum :: Word32 foundationStoreNum = 2 -getApplicationRepl :: (MonadResource m, MonadUnliftIO m) => m (Int, UniWorX, Application) +getApplicationRepl :: (MonadResource m, MonadUnliftIO m, MonadMask m) => m (Int, UniWorX, Application) getApplicationRepl = do settings <- getAppDevSettings foundation <- makeFoundation settings diff --git a/src/Foundation.hs b/src/Foundation.hs index a9d2d1df0..e3de692ef 100644 --- a/src/Foundation.hs +++ b/src/Foundation.hs @@ -315,6 +315,8 @@ embedRenderMessage ''UniWorX ''UploadModeDescr id embedRenderMessage ''UniWorX ''SecretJSONFieldException id embedRenderMessage ''UniWorX ''AFormMessage $ concat . drop 2 . splitCamel embedRenderMessage ''UniWorX ''SchoolFunction id +embedRenderMessage ''UniWorX ''CsvPreset id +embedRenderMessage ''UniWorX ''Quoting ("Csv" <>) embedRenderMessage ''UniWorX ''AuthenticationMode id @@ -933,7 +935,7 @@ tagAccessPredicate AuthTime = APDB $ \mAuthId route _ -> case route of return Authorized r -> $unsupportedAuthPredicate AuthTime r -tagAccessPredicate AuthStaffTime = APDB $ \_ route _ -> case route of +tagAccessPredicate AuthStaffTime = APDB $ \_ route isWrite -> case route of CApplicationR tid ssh csh _ _ -> maybeT (unauthorizedI MsgUnauthorizedApplicationTime) $ do course <- $cachedHereBinary (tid, ssh, csh) . MaybeT . getKeyBy $ TermSchoolCourseShort tid ssh csh allocationCourse <- $cachedHereBinary course . lift . getBy $ UniqueAllocationCourse course @@ -944,7 +946,8 @@ tagAccessPredicate AuthStaffTime = APDB $ \_ route _ -> case route of Just Allocation{..} -> do cTime <- liftIO getCurrentTime guard $ NTop allocationStaffAllocationFrom <= NTop (Just cTime) - guard $ NTop (Just cTime) <= NTop allocationStaffAllocationTo + when isWrite $ + guard $ NTop (Just cTime) <= NTop allocationStaffAllocationTo return Authorized @@ -1197,10 +1200,11 @@ tagAccessPredicate AuthRegisterGroup = APDB $ \mAuthId route _ -> case route of (Nothing, _) -> return Authorized (_, Nothing) -> return AuthenticationRequired (Just rGroup, Just uid) -> do - [E.Value hasOther] <- $cachedHereBinary (uid, rGroup) . lift . E.select . return . E.exists . E.from $ \(tutorial `E.InnerJoin` participant) -> do + hasOther <- $cachedHereBinary (uid, rGroup) . lift . E.selectExists . E.from $ \(tutorial `E.InnerJoin` participant) -> do E.on $ tutorial E.^. TutorialId E.==. participant E.^. TutorialParticipantTutorial - E.where_ $ participant E.^. TutorialParticipantUser E.==. E.val uid - E.&&. tutorial E.^. TutorialRegGroup E.==. E.just (E.val rGroup) + E.&&. tutorial E.^. TutorialCourse E.==. E.val tutorialCourse + E.&&. tutorial E.^. TutorialRegGroup E.==. E.just (E.val rGroup) + E.&&. participant E.^. TutorialParticipantUser E.==. E.val uid guard $ not hasOther return Authorized r -> $unsupportedAuthPredicate AuthRegisterGroup r @@ -3285,6 +3289,7 @@ upsertCampusUser ldapData Creds{..} = do , userWarningDays = userDefaultWarningDays , userNotificationSettings = def , userMailLanguages = def + , userCsvOptions = def , userTokensIssuedAfter = Nothing , userCreated = now , userLastLdapSynchronisation = Just now diff --git a/src/Handler/Allocation/Application.hs b/src/Handler/Allocation/Application.hs index d9e48239a..912fd8450 100644 --- a/src/Handler/Allocation/Application.hs +++ b/src/Handler/Allocation/Application.hs @@ -82,8 +82,7 @@ applicationForm maId@(is _Just -> isAlloc) cid uid ApplicationFormMode{..} csrf coursesNum <- fromIntegral . fromMaybe 1 <$> for maId (\aId -> count [AllocationCourseAllocation ==. aId]) course <- getJust cid (fromMaybe 0 -> maxPrio) <- fmap ((>>= E.unValue) . listToMaybe) . E.select . E.from $ \courseApplication -> do - E.where_ $ courseApplication E.^. CourseApplicationCourse E.==. E.val cid - E.&&. courseApplication E.^. CourseApplicationUser E.==. E.val uid + E.where_ $ courseApplication E.^. CourseApplicationUser E.==. E.val uid E.&&. courseApplication E.^. CourseApplicationAllocation E.==. E.val maId E.&&. E.not_ (E.isNothing $ courseApplication E.^. CourseApplicationAllocationPriority) return . E.joinV . E.max_ $ courseApplication E.^. CourseApplicationAllocationPriority diff --git a/src/Handler/Course/Application/List.hs b/src/Handler/Course/Application/List.hs index 312ff9d02..f3f8de21b 100644 --- a/src/Handler/Course/Application/List.hs +++ b/src/Handler/Course/Application/List.hs @@ -25,6 +25,10 @@ import qualified Data.Map as Map import qualified Data.Conduit.List as C +import Handler.Course.ParticipantInvite + +import Jobs.Queue + type CourseApplicationsTableExpr = ( E.SqlExpr (Entity CourseApplication) `E.InnerJoin` E.SqlExpr (Entity User) @@ -34,41 +38,49 @@ type CourseApplicationsTableExpr = ( E.SqlExpr (Entity CourseApplic `E.InnerJoin` E.SqlExpr (Maybe (Entity StudyTerms)) `E.InnerJoin` E.SqlExpr (Maybe (Entity StudyDegree)) ) + `E.LeftOuterJoin` E.SqlExpr (Maybe (Entity CourseParticipant)) type CourseApplicationsTableData = DBRow ( Entity CourseApplication , Entity User - , E.Value Bool -- hasFiles + , Bool -- hasFiles , Maybe (Entity Allocation) , Maybe (Entity StudyFeatures) , Maybe (Entity StudyTerms) , Maybe (Entity StudyDegree) + , Bool -- isParticipant ) courseApplicationsIdent :: Text courseApplicationsIdent = "applications" queryCourseApplication :: Getter CourseApplicationsTableExpr (E.SqlExpr (Entity CourseApplication)) -queryCourseApplication = to $ $(sqlIJproj 2 1) . $(sqlLOJproj 3 1) +queryCourseApplication = to $ $(sqlIJproj 2 1) . $(sqlLOJproj 4 1) queryUser :: Getter CourseApplicationsTableExpr (E.SqlExpr (Entity User)) -queryUser = to $ $(sqlIJproj 2 2) . $(sqlLOJproj 3 1) +queryUser = to $ $(sqlIJproj 2 2) . $(sqlLOJproj 4 1) queryHasFiles :: Getter CourseApplicationsTableExpr (E.SqlExpr (E.Value Bool)) -queryHasFiles = to $ hasFiles . $(sqlIJproj 2 1) . $(sqlLOJproj 3 1) +queryHasFiles = to $ hasFiles . $(sqlIJproj 2 1) . $(sqlLOJproj 4 1) where hasFiles appl = E.exists . E.from $ \courseApplicationFile -> E.where_ $ courseApplicationFile E.^. CourseApplicationFileApplication E.==. appl E.^. CourseApplicationId queryAllocation :: Getter CourseApplicationsTableExpr (E.SqlExpr (Maybe (Entity Allocation))) -queryAllocation = to $(sqlLOJproj 3 2) +queryAllocation = to $(sqlLOJproj 4 2) queryStudyFeatures :: Getter CourseApplicationsTableExpr (E.SqlExpr (Maybe (Entity StudyFeatures))) -queryStudyFeatures = to $ $(sqlIJproj 3 1) . $(sqlLOJproj 3 3) +queryStudyFeatures = to $ $(sqlIJproj 3 1) . $(sqlLOJproj 4 3) queryStudyTerms :: Getter CourseApplicationsTableExpr (E.SqlExpr (Maybe (Entity StudyTerms))) -queryStudyTerms = to $ $(sqlIJproj 3 2) . $(sqlLOJproj 3 3) +queryStudyTerms = to $ $(sqlIJproj 3 2) . $(sqlLOJproj 4 3) queryStudyDegree :: Getter CourseApplicationsTableExpr (E.SqlExpr (Maybe (Entity StudyDegree))) -queryStudyDegree = to $ $(sqlIJproj 3 3) . $(sqlLOJproj 3 3) +queryStudyDegree = to $ $(sqlIJproj 3 3) . $(sqlLOJproj 4 3) + +queryCourseParticipant :: Getter CourseApplicationsTableExpr (E.SqlExpr (Maybe (Entity CourseParticipant))) +queryCourseParticipant = to $(sqlLOJproj 4 4) + +queryIsParticipant :: Getter CourseApplicationsTableExpr (E.SqlExpr (E.Value Bool)) +queryIsParticipant = to $ E.not_ . E.isNothing . (E.?. CourseParticipantId) . $(sqlLOJproj 4 4) resultCourseApplication :: Lens' CourseApplicationsTableData (Entity CourseApplication) resultCourseApplication = _dbrOutput . _1 @@ -77,7 +89,7 @@ resultUser :: Lens' CourseApplicationsTableData (Entity User) resultUser = _dbrOutput . _2 resultHasFiles :: Lens' CourseApplicationsTableData Bool -resultHasFiles = _dbrOutput . _3 . _Value +resultHasFiles = _dbrOutput . _3 resultAllocation :: Traversal' CourseApplicationsTableData (Entity Allocation) resultAllocation = _dbrOutput . _4 . _Just @@ -91,6 +103,9 @@ resultStudyTerms = _dbrOutput . _6 . _Just resultStudyDegree :: Traversal' CourseApplicationsTableData (Entity StudyDegree) resultStudyDegree = _dbrOutput . _7 . _Just +resultIsParticipant :: Lens' CourseApplicationsTableData Bool +resultIsParticipant = _dbrOutput . _8 + newtype CourseApplicationsTableVeto = CourseApplicationsTableVeto Bool deriving (Eq, Ord, Read, Show, Generic, Typeable) @@ -205,12 +220,44 @@ data CourseApplicationsTableCsvException instance Exception CourseApplicationsTableCsvException embedRenderMessage ''UniWorX ''CourseApplicationsTableCsvException id + +data ButtonAcceptApplications = BtnAcceptApplications + deriving (Enum, Eq, Ord, Bounded, Read, Show, Generic, Typeable) +instance Universe ButtonAcceptApplications +instance Finite ButtonAcceptApplications + +nullaryPathPiece ''ButtonAcceptApplications $ camelToPathPiece' 1 + +embedRenderMessage ''UniWorX ''ButtonAcceptApplications id +instance Button UniWorX ButtonAcceptApplications where + btnClasses BtnAcceptApplications = [BCIsButton] + +data AcceptApplicationsMode = AcceptApplicationsInvite + | AcceptApplicationsDirect + deriving (Enum, Eq, Ord, Bounded, Read, Show, Generic, Typeable) +instance Universe AcceptApplicationsMode +instance Finite AcceptApplicationsMode + +nullaryPathPiece ''AcceptApplicationsMode $ camelToPathPiece' 2 + +embedRenderMessage ''UniWorX ''AcceptApplicationsMode id + +data AcceptApplicationsSecondary = AcceptApplicationsSecondaryRandom + | AcceptApplicationsSecondaryTime + deriving (Enum, Eq, Ord, Bounded, Read, Show, Generic, Typeable) +instance Universe AcceptApplicationsSecondary +instance Finite AcceptApplicationsSecondary + +nullaryPathPiece ''AcceptApplicationsSecondary $ camelToPathPiece' 3 + +embedRenderMessage ''UniWorX ''AcceptApplicationsSecondary id + getCApplicationsR, postCApplicationsR :: TermId -> SchoolId -> CourseShorthand -> Handler Html getCApplicationsR = postCApplicationsR postCApplicationsR tid ssh csh = do - (table, allocationsBounds) <- runDB $ do + (table, allocationsBounds, mayAccept) <- runDB $ do Entity cid Course{..} <- getBy404 $ TermSchoolCourseShort tid ssh csh csvName <- getMessageRender <*> pure (MsgCourseApplicationsTableCsvName tid ssh csh) @@ -237,31 +284,43 @@ postCApplicationsR tid ssh csh = do studyFeatures <- view queryStudyFeatures studyTerms <- view queryStudyTerms studyDegree <- view queryStudyDegree + courseParticipant <- view queryCourseParticipant lift $ do + E.on $ E.just (user E.^. UserId) E.==. courseParticipant E.?. CourseParticipantUser + E.&&. courseParticipant E.?. CourseParticipantCourse E.==. E.just (E.val cid) E.on $ studyDegree E.?. StudyDegreeId E.==. studyFeatures E.?. StudyFeaturesDegree E.on $ studyTerms E.?. StudyTermsId E.==. studyFeatures E.?. StudyFeaturesField E.on $ studyFeatures E.?. StudyFeaturesId E.==. courseApplication E.^. CourseApplicationField E.on $ courseApplication E.^. CourseApplicationAllocation E.==. allocation E.?. AllocationId E.on $ user E.^. UserId E.==. courseApplication E.^. CourseApplicationUser - E.where_ $ courseApplication E.^. CourseApplicationCourse E.==. E.val cid + E.&&. courseApplication E.^. CourseApplicationCourse E.==. E.val cid - return (courseApplication, user, hasFiles, allocation, studyFeatures, studyTerms, studyDegree) + return ( courseApplication + , user + , hasFiles + , allocation + , studyFeatures + , studyTerms + , studyDegree + , E.not_ . E.isNothing $ courseParticipant E.?. CourseParticipantId + ) dbtProj :: DBRow _ -> MaybeT (YesodDB UniWorX) CourseApplicationsTableData dbtProj = runReaderT $ do - appId <- view $ resultCourseApplication . _entityKey + appId <- view $ _dbrOutput . _1 . _entityKey cID <- encrypt appId guardM . hasReadAccessTo $ CApplicationR tid ssh csh cID CAEditR - view id + asks $ over (_dbrOutput . _3) E.unValue . over (_dbrOutput . _8) E.unValue dbtRowKey = view $ queryCourseApplication . to (E.^. CourseApplicationId) dbtColonnade :: Colonnade Sortable _ _ dbtColonnade = mconcat - [ emptyOpticColonnade (resultAllocation . _entityVal) $ \l -> anchorColonnade (views l allocationLink) $ colAllocationShorthand (l . _allocationShorthand) + [ sortable (Just "participant") (i18nCell MsgCourseApplicationIsParticipant) $ bool mempty (cell $ toWidget iconOK) . view resultIsParticipant + , emptyOpticColonnade (resultAllocation . _entityVal) $ \l -> anchorColonnade (views l allocationLink) $ colAllocationShorthand (l . _allocationShorthand) , anchorColonnadeM (views (resultCourseApplication . _entityKey) applicationLink) $ colApplicationId (resultCourseApplication . _entityKey) , anchorColonnadeM (views (resultUser . _entityKey) participantLink) $ colUserDisplayName (resultUser . _entityVal . $(multifocusL 2) _userDisplayName _userSurname) , colUserMatriculation (resultUser . _entityVal . _userMatrikelnummer) @@ -276,7 +335,8 @@ postCApplicationsR tid ssh csh = do ] dbtSorting = mconcat - [ sortAllocationShorthand $ queryAllocation . to (E.?. AllocationShorthand) + [ singletonMap "participant" . SortColumn $ view queryIsParticipant + , sortAllocationShorthand $ queryAllocation . to (E.?. AllocationShorthand) , sortUserName' $ $(multifocusG 2) (queryUser . to (E.^. UserDisplayName)) (queryUser . to (E.^. UserSurname)) , sortUserMatriculation $ queryUser . to (E.^. UserMatrikelnummer) , sortStudyTerms queryStudyTerms @@ -566,12 +626,67 @@ postCApplicationsR tid ssh csh = do || numFirstChoice' /= numFirstChoice ] - (, allocationsBounds) <$> dbTableWidget' psValidator DBTable{..} + mayAccept <- hasWriteAccessTo $ CourseR tid ssh csh CAddUserR + + (, allocationsBounds, mayAccept) <$> dbTableWidget' psValidator DBTable{..} now <- liftIO getCurrentTime let title = prependCourseTitle tid ssh csh MsgCourseApplicationsListTitle registrationOpen = maybe True (now <) + + ((acceptRes, acceptWgt'), acceptEnc) <- runFormPost . identifyForm BtnAcceptApplications . renderAForm FormStandard $ + (,) <$> apopt (selectField optionsFinite) (fslI MsgAcceptApplicationsMode & setTooltip MsgAcceptApplicationsModeTip) (Just AcceptApplicationsInvite) + <*> apopt (selectField optionsFinite) (fslI MsgAcceptApplicationsSecondary & setTooltip MsgAcceptApplicationsSecondaryTip) (Just AcceptApplicationsSecondaryTime) + + let acceptWgt = wrapForm' BtnAcceptApplications acceptWgt' def + { formSubmit = FormSubmit + , formAction = Just . SomeRoute $ CourseR tid ssh csh CApplicationsR + , formEncoding = acceptEnc + } + + when mayAccept $ + formResult acceptRes $ \(invMode, appsSecOrder) -> do + runDBJobs $ do + Entity cid Course{..} <- getBy404 $ TermSchoolCourseShort tid ssh csh + participants <- count [ CourseParticipantCourse ==. cid ] + let openCapacity = subtract participants <$> courseCapacity + + applications <- E.select . E.from $ \(user `E.InnerJoin` application) -> do + E.on $ user E.^. UserId E.==. application E.^. CourseApplicationUser + + E.where_ $ application E.^. CourseApplicationCourse E.==. E.val cid + E.&&. E.isNothing (application E.^. CourseApplicationAllocation) + E.&&. E.not_ (application E.^. CourseApplicationRatingVeto) + E.&&. E.maybe E.true (`E.in_` E.valList (filter (view $ passingGrade . _Wrapped) universeF)) (application E.^. CourseApplicationRatingPoints ) + + E.where_ . E.not_ . E.exists . E.from $ \participant -> + E.where_ $ participant E.^. CourseParticipantCourse E.==. E.val cid + E.&&. participant E.^. CourseParticipantUser E.==. user E.^. UserId + + return (user, application) + + let + ratingL = _2 . _entityVal . _courseApplicationRatingPoints . to (Down . ExamGradeDefCenter) + cmp = case appsSecOrder of + AcceptApplicationsSecondaryTime + -> comparing . view $ $(multifocusG 2) ratingL (_2 . _entityVal . _courseApplicationTime) + AcceptApplicationsSecondaryRandom + -> comparing $ view ratingL + sortedApplications <- unstableSortBy cmp applications + + let applicants = sortedApplications + & nubOn (view $ _1 . _entityKey) + & maybe id take openCapacity + & setOf (case invMode of + AcceptApplicationsDirect -> folded . _1 . _entityKey . to Right + AcceptApplicationsInvite -> folded . _1 . _entityVal . _userEmail . to Left + ) + + mapM_ addMessage' <=< execWriterT $ registerUsers cid applicants + redirect $ CourseR tid ssh csh CUsersR + + siteLayoutMsg title $ do setTitleI title $(widgetFile "course/applications-list") diff --git a/src/Handler/Course/Edit.hs b/src/Handler/Course/Edit.hs index fcb45369e..a670a8e1f 100644 --- a/src/Handler/Course/Edit.hs +++ b/src/Handler/Course/Edit.hs @@ -107,12 +107,13 @@ makeCourseForm miButtonAction template = identifyForm FIDcourse . validateFormDB MsgRenderer mr <- getMsgRenderer uid <- liftHandler requireAuthId - (lecturerSchools, adminSchools) <- liftHandler . runDB $ do + (lecturerSchools, adminSchools, oldSchool) <- liftHandler . runDB $ do lecturerSchools <- map (userFunctionSchool . entityVal) <$> selectList [UserFunctionUser ==. uid, UserFunctionFunction <-. [SchoolLecturer]] [] protoAdminSchools <- map (userFunctionSchool . entityVal) <$> selectList [UserFunctionUser ==. uid, UserFunctionFunction <-. [SchoolAdmin]] [] adminSchools <- filterM (hasWriteAccessTo . flip SchoolR SchoolEditR) protoAdminSchools - return (lecturerSchools, adminSchools) - let userSchools = nub $ lecturerSchools ++ adminSchools + oldSchool <- forM (cfCourseId =<< template) $ fmap courseSchool . getJust + return (lecturerSchools, adminSchools, oldSchool) + let userSchools = nub . maybe id (:) oldSchool $ lecturerSchools ++ adminSchools termsField <- case template of -- Change of term is only allowed if user may delete the course (i.e. no participants) or admin diff --git a/src/Handler/Course/ParticipantInvite.hs b/src/Handler/Course/ParticipantInvite.hs index e2962ac0b..9459346a3 100644 --- a/src/Handler/Course/ParticipantInvite.hs +++ b/src/Handler/Course/ParticipantInvite.hs @@ -4,6 +4,9 @@ module Handler.Course.ParticipantInvite ( InvitableJunction(..), InvitationDBData(..), InvitationTokenData(..) , getCInviteR, postCInviteR , getCAddUserR, postCAddUserR + , AddParticipantsResult(..) + , addParticipantsResultMessages + , registerUsers, registerUser ) where import Import @@ -96,16 +99,16 @@ participantInvitationConfig = InvitationConfig{..} return . SomeMessage $ MsgCourseParticipantInvitationAccepted (CI.original courseName) invitationUltDest (Entity _ Course{..}) _ = return . SomeRoute $ CourseR courseTerm courseSchool courseShorthand CShowR -data AddRecipientsResult = AddRecipientsResult +data AddParticipantsResult = AddParticipantsResult { aurAlreadyRegistered , aurNoUniquePrimaryField - , aurSuccess :: [UserEmail] + , aurSuccess :: Set UserId } deriving (Read, Show, Generic, Typeable) -instance Semigroup AddRecipientsResult where +instance Semigroup AddParticipantsResult where (<>) = mappenddefault -instance Monoid AddRecipientsResult where +instance Monoid AddParticipantsResult where mempty = memptydefault mappend = (<>) @@ -118,7 +121,9 @@ postCAddUserR tid ssh csh = do wreq (multiUserField (maybe True not $ formResultToMaybe enlist) Nothing) (fslI MsgCourseParticipantInviteField & setTooltip MsgMultiEmailFieldTip) Nothing - formResultModal usersToEnlist (CourseR tid ssh csh CUsersR) $ processUsers cid + formResultModal usersToEnlist (CourseR tid ssh csh CUsersR) $ + hoist runDBJobs . registerUsers cid + let heading = prependCourseTitle tid ssh csh MsgCourseParticipantsRegisterHeading @@ -128,57 +133,74 @@ postCAddUserR tid ssh csh = do { formEncoding , formAction = Just . SomeRoute $ CourseR tid ssh csh CAddUserR } - where - processUsers :: CourseId -> Set (Either UserEmail UserId) -> WriterT [Message] Handler () - processUsers cid users = do - let (emails,uids) = partitionEithers $ Set.toList users - AddRecipientsResult{..} <- lift . runDBJobs $ do - -- send Invitation eMails to unkown users - sinkInvitationsF participantInvitationConfig [(mail,cid,(InvDBDataParticipant,InvTokenDataParticipant)) | mail <- emails] - -- register known users - execWriterT $ mapM (registerUser cid) uids - unless (null emails) $ - tell . pure <=< messageI Success . MsgCourseParticipantsInvited $ length emails +registerUsers :: CourseId -> Set (Either UserEmail UserId) -> WriterT [Message] (YesodJobDB UniWorX) () +registerUsers cid users = do + let (emails,uids) = partitionEithers $ Set.toList users - unless (null aurAlreadyRegistered) $ do - let modalTrigger = [whamlet|_{MsgCourseParticipantsAlreadyRegistered (length aurAlreadyRegistered)}|] - modalContent = $(widgetFile "messages/courseInvitationAlreadyRegistered") - tell . pure <=< messageWidget Info $ msgModal modalTrigger (Right modalContent) + -- send Invitation eMails to unkown users + lift $ sinkInvitationsF participantInvitationConfig [(mail,cid,(InvDBDataParticipant,InvTokenDataParticipant)) | mail <- emails] + -- register known users + tell <=< lift . addParticipantsResultMessages <=< lift . execWriterT $ mapM_ (registerUser cid) uids - unless (null aurNoUniquePrimaryField) $ do - let modalTrigger = [whamlet|_{MsgCourseParticipantsRegisteredWithoutField (length aurNoUniquePrimaryField)}|] - modalContent = $(widgetFile "messages/courseInvitationRegisteredWithoutField") - tell . pure <=< messageWidget Warning $ msgModal modalTrigger (Right modalContent) + unless (null emails) $ + tell . pure <=< messageI Success . MsgCourseParticipantsInvited $ length emails - unless (null aurSuccess) $ - tell . pure <=< messageI Success . MsgCourseParticipantsRegistered $ length aurSuccess - registerUser :: CourseId -> UserId -> WriterT AddRecipientsResult (YesodJobDB UniWorX) () - registerUser cid uid = exceptT tell tell $ do - User{..} <- lift . lift $ getJust uid +addParticipantsResultMessages :: (MonadHandler m, HandlerSite m ~ UniWorX) + => AddParticipantsResult + -> ReaderT (YesodPersistBackend UniWorX) m [Message] +addParticipantsResultMessages AddParticipantsResult{..} = execWriterT $ do + (aurAlreadyRegistered', aurNoUniquePrimaryField') <- + (,) <$> fmap sort (lift . mapM (fmap userEmail . getJust) $ Set.toList aurAlreadyRegistered) + <*> fmap sort (lift . mapM (fmap userEmail . getJust) $ Set.toList aurNoUniquePrimaryField) - whenM (lift . lift . existsBy $ UniqueParticipant uid cid) $ - throwError $ mempty { aurAlreadyRegistered = pure userEmail } + unless (null aurAlreadyRegistered) $ do + let modalTrigger = [whamlet|_{MsgCourseParticipantsAlreadyRegistered (length aurAlreadyRegistered)}|] + modalContent = $(widgetFile "messages/courseInvitationAlreadyRegistered") + tell . pure <=< messageWidget Info $ msgModal modalTrigger (Right modalContent) - features <- lift . lift $ selectKeysList [ StudyFeaturesUser ==. uid, StudyFeaturesValid ==. True, StudyFeaturesType ==. FieldPrimary ] [] + unless (null aurNoUniquePrimaryField) $ do + let modalTrigger = [whamlet|_{MsgCourseParticipantsRegisteredWithoutField (length aurNoUniquePrimaryField)}|] + modalContent = $(widgetFile "messages/courseInvitationRegisteredWithoutField") + tell . pure <=< messageWidget Warning $ msgModal modalTrigger (Right modalContent) - let courseParticipantField - | [f] <- features = Just f - | otherwise = Nothing + unless (null aurSuccess) $ + tell . pure <=< messageI Success . MsgCourseParticipantsRegistered $ length aurSuccess - courseParticipantRegistration <- liftIO getCurrentTime - void . lift . lift . insert $ CourseParticipant - { courseParticipantCourse = cid - , courseParticipantUser = uid - , courseParticipantAllocated = False - , .. - } - lift . lift . audit $ TransactionCourseParticipantEdit cid uid - return $ case courseParticipantField of - Nothing -> mempty { aurNoUniquePrimaryField = pure userEmail } - Just _ -> mempty { aurSuccess = pure userEmail } +registerUser :: (MonadHandler m, HandlerSite m ~ UniWorX, MonadCatch m) + => CourseId + -> UserId + -> WriterT AddParticipantsResult (ReaderT (YesodPersistBackend UniWorX) m) () +registerUser cid uid = exceptT tell tell $ do + whenM (lift . lift . existsBy $ UniqueParticipant uid cid) $ + throwError $ mempty { aurAlreadyRegistered = Set.singleton uid } + + features <- lift . lift $ selectKeysList [ StudyFeaturesUser ==. uid, StudyFeaturesValid ==. True, StudyFeaturesType ==. FieldPrimary ] [] + applications <- lift . lift $ selectList [ CourseApplicationCourse ==. cid, CourseApplicationUser ==. uid ] [] + + let courseParticipantField + | [f] <- features + = Just f + | [f'] <- nub $ mapMaybe (courseApplicationField . entityVal) applications + , f' `elem` features + = Just f' + | otherwise + = Nothing + + courseParticipantRegistration <- liftIO getCurrentTime + void . lift . lift . insert $ CourseParticipant + { courseParticipantCourse = cid + , courseParticipantUser = uid + , courseParticipantAllocated = False + , .. + } + lift . lift . audit $ TransactionCourseParticipantEdit cid uid + + return $ case courseParticipantField of + Nothing -> mempty { aurNoUniquePrimaryField = Set.singleton uid } + Just _ -> mempty { aurSuccess = Set.singleton uid } getCInviteR, postCInviteR :: TermId -> SchoolId -> CourseShorthand -> Handler Html diff --git a/src/Handler/Exam/Show.hs b/src/Handler/Exam/Show.hs index 27c55a9b4..9ee2da005 100644 --- a/src/Handler/Exam/Show.hs +++ b/src/Handler/Exam/Show.hs @@ -72,11 +72,17 @@ getEShowR tid ssh csh examn = do examClosedShown = lecturerInfoShown sumMaxPoints = sum [ fromRational examPartWeight * mPoints | Entity _ ExamPart{..} <- examParts, let Just mPoints = examPartMaxPoints ] - sumPoints = getSum <$> foldMap (fmap Sum . examPartResultResult . entityVal) results noBonus = fromMaybe False $ do guardM $ bonusOnlyPassed <$> examBonusRule return . fromMaybe True $ result ^? _Just . _entityVal . _examResultResult . _examResult . passingGrade . _Wrapped . to not + + sumPoints = fmap getSum . mconcat $ catMaybes + [ Just $ foldMap (fmap Sum . examPartResultResult . entityVal) results + , guard (not noBonus) *> fmap (pure . Sum . examBonusBonus . entityVal) bonus + ] + + hasRegistration = any snd occurrences let examTimes = all (\(Entity _ ExamOccurrence{..}, _) -> Just examOccurrenceStart == examStart && examOccurrenceEnd == examEnd) occurrences diff --git a/src/Handler/Exam/Users.hs b/src/Handler/Exam/Users.hs index a7f4f71e0..87f8e1cb5 100644 --- a/src/Handler/Exam/Users.hs +++ b/src/Handler/Exam/Users.hs @@ -160,7 +160,7 @@ resultCourseNote = _dbrOutput . _10 . _Just resultAutomaticExamBonus :: Exam -> Map UserId SheetTypeSummary -> Fold ExamUserTableData Points -resultAutomaticExamBonus exam examBonus' = resultUser . _entityKey . folding (\uid -> examResultBonus <$> examBonusRule exam <*> examBonusPossible uid examBonus' <*> examBonusAchieved uid examBonus') +resultAutomaticExamBonus exam examBonus' = resultUser . _entityKey . folding (\uid -> examResultBonus <$> examBonusRule exam <*> pure (examBonusPossible uid examBonus') <*> pure (examBonusAchieved uid examBonus')) resultAutomaticExamResult :: Exam -> Map UserId SheetTypeSummary -> Fold ExamUserTableData ExamResultGrade resultAutomaticExamResult exam examBonus' = folding . runReader $ do @@ -396,7 +396,7 @@ postEUsersR tid ssh csh examn = do allBoni :: SheetGradeSummary allBoni = (mappend <$> normalSummary <*> bonusSummary) $ fold bonus - doBonus = is _Just examGradingRule || is _Just examBonusRule + doBonus = is _Just examBonusRule showPasses = doBonus && numSheetsPasses allBoni /= 0 showPoints = doBonus && getSum (numSheetsPoints allBoni) /= 0 @@ -494,14 +494,14 @@ postEUsersR tid ssh csh examn = do , 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 showPasses $ sortable Nothing (i18nCell MsgAchievedPasses) $ \(view $ resultUser . _entityKey -> uid) -> + let SheetGradeSummary{achievedPasses} = examBonusAchieved uid bonus + SheetGradeSummary{numSheetsPasses} = examBonusPossible uid bonus + in propCell (getSum achievedPasses) (getSum numSheetsPasses) + , guardOn showPoints $ sortable Nothing (i18nCell MsgAchievedPoints) $ \(view $ resultUser . _entityKey -> uid) -> + let SheetGradeSummary{achievedPoints} = examBonusAchieved uid bonus + SheetGradeSummary{sumSheetsPoints} = examBonusPossible uid bonus + in propCell (getSum achievedPoints) (getSum sumSheetsPoints) , guardOn doBonus $ sortable (Just "bonus") (i18nCell MsgExamBonusAchieved) . automaticCell $ resultExamBonus . _entityVal . _examBonusBonus . to Right <> resultAutomaticExamBonus' . to Left , pure $ mconcat [ sortable (Just $ fromText [st|part-#{toPathPiece examPartNumber}|]) (i18nCell $ MsgExamPartNumbered examPartNumber) $ maybe mempty i18nCell . preview (resultExamPartResult epId . _Just . _entityVal . _examPartResultResult) @@ -612,10 +612,10 @@ postEUsersR tid ssh csh examn = do <*> preview (resultStudyDegree . _entityVal . to (\StudyDegree{..} -> studyDegreeName <|> studyDegreeShorthand <|> Just (tshow studyDegreeKey)) . _Just) <*> preview (resultStudyFeatures . _entityVal . _studyFeaturesSemester) <*> preview (resultExamOccurrence . _entityVal . _examOccurrenceName) - <*> previews (resultUser . _entityKey . to (examBonusAchieved ?? bonus) . _Just . _achievedPoints . _Wrapped) (bool (const Nothing) Just showPoints) - <*> previews (resultUser . _entityKey . to (examBonusAchieved ?? bonus) . _Just . _achievedPasses . _Wrapped . integral) (bool (const Nothing) Just showPasses) - <*> previews (resultUser . _entityKey . to (examBonusPossible ?? bonus) . _Just . _sumSheetsPoints . _Wrapped) (bool (const Nothing) Just showPoints) - <*> previews (resultUser . _entityKey . to (examBonusPossible ?? bonus) . _Just . _numSheetsPasses . _Wrapped . integral) (bool (const Nothing) Just showPasses) + <*> previews (resultUser . _entityKey . to (examBonusAchieved ?? bonus) . _achievedPoints . _Wrapped) (bool (const Nothing) Just showPoints) + <*> previews (resultUser . _entityKey . to (examBonusAchieved ?? bonus) . _achievedPasses . _Wrapped . integral) (bool (const Nothing) Just showPasses) + <*> previews (resultUser . _entityKey . to (examBonusPossible ?? bonus) . _sumSheetsPoints . _Wrapped) (bool (const Nothing) Just showPoints) + <*> previews (resultUser . _entityKey . to (examBonusPossible ?? bonus) . _numSheetsPasses . _Wrapped . integral) (bool (const Nothing) Just showPasses) <*> previews (resultExamBonus . _entityVal . _examBonusBonus <> resultAutomaticExamBonus') (bool (const Nothing) Just doBonus) <*> (Map.fromList . map (over _1 examPartNumber . over (_2 . _Just) (examPartResultResult . entityVal)) <$> asks (toListOf resultExamParts)) <*> previews (resultExamResult . _entityVal . _examResultResult <> resultAutomaticExamResult') resultView @@ -645,7 +645,7 @@ postEUsersR tid ssh csh examn = do when (epNumber `elem` examPartNumbers) $ yield $ ExamUserCsvSetPartResultData uid epNumber (Just epRes) - when (is _Just . join $ csvEUserBonus dbCsvNew) $ + when (doBonus && is _Just (join $ csvEUserBonus dbCsvNew)) $ yield . ExamUserCsvSetBonusData False uid . join $ csvEUserBonus dbCsvNew when (is _Just $ csvEUserExamResult dbCsvNew) $ @@ -684,15 +684,18 @@ postEUsersR tid ssh csh examn = do newResult = fmap resultView <$> examGrade examVal (newBonus <|> oldBonus) =<< newResults oldResult = dbCsvOld ^? (resultExamResult . _entityVal . _examResultResult <> resultAutomaticExamResult') . to resultView - case newBonus of - _ | newBonus == oldBonus - -> return () - _ | is _Nothing newBonus - -> return () - Nothing - -> yield $ ExamUserCsvSetBonusData False uid newBonus - Just _ - -> yield $ ExamUserCsvSetBonusData True uid newBonus + when doBonus $ + case newBonus of + _ | newBonus == oldBonus + -> return () + _ | is _Nothing newBonus + -> return () + _ | Just ExamBonusManual{} <- examBonusRule + -> yield $ ExamUserCsvSetBonusData False uid newBonus + Nothing + -> yield $ ExamUserCsvSetBonusData False uid newBonus + Just _ + -> yield $ ExamUserCsvSetBonusData True uid newBonus case newResult of _ | csvEUserExamResult dbCsvNew == oldResult @@ -928,22 +931,31 @@ postEUsersR tid ssh csh examn = do guessUser :: ExamUserTableCsv -> DB (Bool, UserId) guessUser ExamUserTableCsv{..} = $cachedHereBinary (csvEUserMatriculation, csvEUserName, csvEUserSurname) $ do users <- E.select . E.from $ \user -> do - E.where_ . E.and $ catMaybes + E.where_ . E.or $ 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 + , (user E.^. UserDisplayName `E.hasInfix`) . E.val <$> csvEUserName + , (user E.^. UserSurname `E.hasInfix`) . E.val <$> csvEUserSurname + , (user E.^. UserFirstName `E.hasInfix`) . 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) + return (isCourseParticipant, user) + let users' = reverse $ sortBy closeness users + closeness :: (E.Value Bool, Entity User) -> (E.Value Bool, Entity User) -> Ordering + closeness = mconcat $ catMaybes + [ pure $ comparing (preview $ _2 . _entityVal . _userMatrikelnummer . only csvEUserMatriculation) + , pure $ comparing (view _1) + , csvEUserSurname <&> \surn -> comparing (preview $ _2 . _entityVal . _userSurname . to CI.mk . only (CI.mk surn)) + , csvEUserFirstName <&> \firstn -> comparing (preview $ _2 . _entityVal . _userFirstName . to CI.mk . only (CI.mk firstn)) + , csvEUserName <&> \dispn -> comparing (preview $ _2 . _entityVal . _userDisplayName . to CI.mk . only (CI.mk dispn)) + ] + case users' of + [(E.Value isPart, Entity uid _)] + -> return (isPart, uid) + (x@(E.Value isPart, Entity uid _) : x' : _) + | GT <- x `closeness` x' + -> return (isPart, uid) _other -> throwM ExamUserCsvExceptionNoMatchingUser diff --git a/src/Handler/Profile.hs b/src/Handler/Profile.hs index 13a0e9c81..57a49c428 100644 --- a/src/Handler/Profile.hs +++ b/src/Handler/Profile.hs @@ -1,4 +1,11 @@ -module Handler.Profile where +module Handler.Profile + ( getProfileR, postProfileR + , getProfileDataR, makeProfileData + , getAuthPredsR, postAuthPredsR + , getUserNotificationR, postUserNotificationR + , getSetDisplayEmailR, postSetDisplayEmailR + , getCsvOptionsR, postCsvOptionsR + ) where import Import @@ -796,3 +803,25 @@ postSetDisplayEmailR = do siteLayoutMsg MsgTitleChangeUserDisplayEmail $ do setTitleI MsgTitleChangeUserDisplayEmail $(i18nWidgetFile "set-display-email") + +getCsvOptionsR, postCsvOptionsR :: Handler Html +getCsvOptionsR = postCsvOptionsR +postCsvOptionsR = do + Entity uid User{userCsvOptions} <- requireAuth + + ((optionsRes, optionsWgt'), optionsEnctype) <- runFormPost . renderAForm FormStandard $ + csvOptionsForm (fslI MsgCsvOptions & setTooltip MsgCsvOptionsTip) (Just userCsvOptions) + + formResultModal optionsRes CsvOptionsR $ \opts -> do + lift . runDB $ update uid [ UserCsvOptions =. opts ] + tell . pure =<< messageI Success MsgCsvOptionsUpdated + + siteLayoutMsg MsgCsvOptions $ do + setTitleI MsgCsvOptions + + isModal <- hasCustomHeader HeaderIsModal + wrapForm optionsWgt' def + { formAction = Just $ SomeRoute CsvOptionsR + , formEncoding = optionsEnctype + , formAttrs = [ asyncSubmitAttr | isModal ] + } diff --git a/src/Handler/Sheet.hs b/src/Handler/Sheet.hs index 06d200c2a..7d521389f 100644 --- a/src/Handler/Sheet.hs +++ b/src/Handler/Sheet.hs @@ -686,7 +686,7 @@ defaultLoads shid = do return (sheetCorrector E.^. SheetCorrectorUser, sheetCorrector E.^. SheetCorrectorLoad, sheetCorrector E.^. SheetCorrectorState) where toMap :: [(E.Value UserId, E.Value Load, E.Value CorrectorState)] -> Loads - toMap = foldMap $ \(E.Value uid, E.Value load, E.Value state) -> Map.singleton (Right uid) (state, load) + toMap = foldMap $ \(E.Value uid, E.Value cLoad, E.Value cState) -> Map.singleton (Right uid) (cState, cLoad) correctorForm :: SheetId -> AForm Handler (Set (Either (Invitation' SheetCorrector) SheetCorrector)) @@ -809,7 +809,7 @@ correctorForm shid = wFormToAForm $ do postProcess' :: (Either UserEmail UserId, (CorrectorState, Load)) -> Either (Invitation' SheetCorrector) SheetCorrector postProcess' (Right sheetCorrectorUser, (sheetCorrectorState, sheetCorrectorLoad)) = Right SheetCorrector{..} - postProcess' (Left email, (state, load)) = Left (email, shid, (InvDBDataSheetCorrector load state, InvTokenDataSheetCorrector)) + postProcess' (Left email, (cState, load)) = Left (email, shid, (InvDBDataSheetCorrector load cState, InvTokenDataSheetCorrector)) filledData :: Maybe (Map ListPosition (Either UserEmail UserId, (CorrectorState, Load))) filledData = Just . Map.fromList . zip [0..] $ Map.toList loads -- TODO orderBy Name?! @@ -906,7 +906,7 @@ correctorInvitationConfig = InvitationConfig{..} itAuthority <- liftHandler requireAuthId return $ InvitationTokenConfig itAuthority Nothing Nothing Nothing invitationRestriction _ _ = return Authorized - invitationForm _ (InvDBDataSheetCorrector load state, _) _ = pure $ (JunctionSheetCorrector load state, ()) + invitationForm _ (InvDBDataSheetCorrector cLoad cState, _) _ = pure $ (JunctionSheetCorrector cLoad cState, ()) invitationInsertHook _ _ _ _ = id invitationSuccessMsg (Entity _ Sheet{..}) _ = return . SomeMessage $ MsgCorrectorInvitationAccepted sheetName invitationUltDest (Entity _ Sheet{..}) _ = do diff --git a/src/Handler/Tutorial.hs b/src/Handler/Tutorial.hs index 94a00e645..04fc02220 100644 --- a/src/Handler/Tutorial.hs +++ b/src/Handler/Tutorial.hs @@ -1,480 +1,13 @@ -{-# OPTIONS_GHC -fno-warn-orphans #-} - module Handler.Tutorial ( module Handler.Tutorial ) where -import Import -import Handler.Utils -import Handler.Utils.Tutorial -import Handler.Utils.Delete -import Handler.Utils.Communication -import Handler.Utils.Form.Occurrences -import Handler.Utils.Invitations -import Jobs.Queue - -import qualified Database.Esqueleto as E -import qualified Database.Esqueleto.Utils as E -import Database.Esqueleto.Utils.TH - -import Data.Map ((!)) -import qualified Data.Map as Map -import qualified Data.Set as Set - -import qualified Data.CaseInsensitive as CI - -import Data.Aeson hiding (Result(..)) -import Text.Hamlet (ihamlet) - -import Handler.Tutorial.Users as Handler.Tutorial - -{-# ANN module ("Hlint: ignore Redundant void" :: String) #-} - - -getCTutorialListR :: TermId -> SchoolId -> CourseShorthand -> Handler Html -getCTutorialListR tid ssh csh = do - Entity cid Course{..} <- runDB . getBy404 $ TermSchoolCourseShort tid ssh csh - - let - tutorialDBTable = DBTable{..} - where - dbtSQLQuery tutorial = do - E.where_ $ tutorial E.^. TutorialCourse E.==. E.val cid - let participants = E.sub_select . E.from $ \tutorialParticipant -> do - E.where_ $ tutorialParticipant E.^. TutorialParticipantTutorial E.==. tutorial E.^. TutorialId - return E.countRows :: E.SqlQuery (E.SqlExpr (E.Value Int)) - return (tutorial, participants) - dbtRowKey = (E.^. TutorialId) - dbtProj = return . over (_dbrOutput . _2) E.unValue - dbtColonnade = dbColonnade $ mconcat - [ sortable (Just "type") (i18nCell MsgTutorialType) $ \DBRow{ dbrOutput = (Entity _ Tutorial{..}, _) } -> textCell $ CI.original tutorialType - , sortable (Just "name") (i18nCell MsgTutorialName) $ \DBRow{ dbrOutput = (Entity _ Tutorial{..}, _) } -> anchorCell (CTutorialR tid ssh csh tutorialName TUsersR) [whamlet|#{tutorialName}|] - , sortable Nothing (i18nCell MsgTutorialTutors) $ \DBRow{ dbrOutput = (Entity tutid _, _) } -> sqlCell $ do - tutors <- fmap (map $(unValueN 3)) . E.select . E.from $ \(tutor `E.InnerJoin` user) -> do - E.on $ tutor E.^. TutorUser E.==. user E.^. UserId - E.where_ $ tutor E.^. TutorTutorial E.==. E.val tutid - return (user E.^. UserEmail, user E.^. UserDisplayName, user E.^. UserSurname) - return [whamlet| - $newline never -