Merge branch 'master' into info-lecturer

This commit is contained in:
Sarah Vaupel 2019-10-01 11:14:32 +02:00
commit 2205180350
74 changed files with 1732 additions and 811 deletions

View File

@ -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. 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) ### [6.11.1](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/compare/v6.11.0...v6.11.1) (2019-09-17)

View File

@ -67,7 +67,7 @@ update = do
restartAppInNewThread tidStore = modifyStoredIORef tidStore $ \tid -> do restartAppInNewThread tidStore = modifyStoredIORef tidStore $ \tid -> do
killThread tid killThread tid
withStore doneStore takeMVar withStore doneStore takeMVar
readStore doneStore >>= start withStore doneStore start
-- | Start the server in a separate thread. -- | Start the server in a separate thread.
@ -77,10 +77,7 @@ update = do
(port, site, app) <- getApplicationRepl (port, site, app) <- getApplicationRepl
resourceForkIO $ do resourceForkIO $ do
finally (liftIO $ runSettings (setPort port defaultSettings) app) finally (liftIO $ runSettings (setPort port defaultSettings) app)
-- Note that this implies concurrency (liftIO $ shutdownApp site `finally` putMVar done ())
-- between shutdownApp and the next app that is starting.
-- Normally this should be fine
(liftIO $ putMVar done () >> shutdownApp site)
-- | kill the server -- | kill the server
shutdown :: IO () shutdown :: IO ()

View File

@ -8,3 +8,5 @@ log-settings:
destination: "test.log" destination: "test.log"
auth-dummy-login: true auth-dummy-login: true
job-workers: 1

View File

@ -24,17 +24,6 @@ const FORM_DATE_FORMAT_MOMENT = {
'datetime-local': `${FORM_DATE_FORMAT_DATE_MOMENT} ${FORM_DATE_FORMAT_TIME_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. * 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; * 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!'); 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 // initialize tail.datetime (datepicker) instance
this.datepickerInstance = datetime(this._element, { ...datepickerGlobalConfig, ...datepickerConfig }); 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 // format the date value of the form input element of this datepicker before form submission
this._element.form.addEventListener('submit', () => this.formatElementValue()); this._element.form.addEventListener('submit', () => this.formatElementValue());
// format any existing dates to fancy display format on pageload
this.formatElementValue(true);
} }
destroy() { 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. * @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) { formatElementValue(toFancy) {
const dp = this.datepickerInstance;
if (this._element.value) { if (this._element.value) {
if (toFancy) { this._element.value = this.unformat(toFancy);
const parsedDate = parseDateWithFormat(this._element.value, FORM_DATE_FORMAT[this.elementType]);
if (parsedDate) dp.selectDate();
} else {
this._element.value = this.unformat();
}
} }
} }
/** /**
* Returns a datestring in internal format from the current state of the input element value. * 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() { unformat(toFancy) {
return reformatDateString(this._element.value, FORM_DATE_FORMAT_MOMENT[this.elementType], FORM_DATE_FORMAT[this.elementType]); 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);
} }
/** /**

View File

@ -15,4 +15,4 @@ if [[ -d .stack-work-doc ]]; then
trap move-back EXIT trap move-back EXIT
fi 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 ${@}

View File

@ -1,5 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
[[ -n "${FORCE_RELEASE}" ]] && exit 0
set -e set -e
if [ -n "$(git status --porcelain)" ]; then if [ -n "$(git status --porcelain)" ]; then

View File

@ -173,6 +173,7 @@ CourseApplicationTemplateApplication: Bewerbungsvorlage(n)
CourseApplicationTemplateRegistration: Anmeldungsvorlage(n) CourseApplicationTemplateRegistration: Anmeldungsvorlage(n)
CourseApplicationTemplateArchiveName tid@TermId ssh@SchoolId csh@CourseShorthand: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-bewerbungsvorlagen CourseApplicationTemplateArchiveName tid@TermId ssh@SchoolId csh@CourseShorthand: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-bewerbungsvorlagen
CourseApplication: Bewerbung CourseApplication: Bewerbung
CourseApplicationIsParticipant: Kursteilnehmer
CourseApplicationExists: Sie haben sich bereits für diesen Kurs beworben CourseApplicationExists: Sie haben sich bereits für diesen Kurs beworben
CourseApplicationInvalidAction: Angegeben Aktion kann nicht durchgeführt werden CourseApplicationInvalidAction: Angegeben Aktion kann nicht durchgeführt werden
@ -1135,10 +1136,14 @@ NavigationFavourites: Favoriten
CommSubject: Betreff CommSubject: Betreff
CommBody: Nachricht CommBody: Nachricht
CommBodyTip: Das Eingabefeld akzeptiert derzeit ausschließlich Html. U.A. Zeilumbrüche werden dementsprechend ignoriert und müssen manuell mit <br> eingefügt werden.
CommRecipients: Empfänger CommRecipients: Empfänger
CommRecipientsTip: Sie selbst erhalten immer eine Kopie der Nachricht 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 CommDuplicateRecipients n@Int: #{n} #{pluralDE n "doppelter" "doppelte"} Empfänger ignoriert
CommSuccess n@Int: Nachricht wurde an #{n} Empfänger versandt CommSuccess n@Int: Nachricht wurde an #{n} Empfänger versandt
CommUndisclosedRecipients: Verborgene Empfänger
CommAllRecipients: alle-empfaenger
CommCourseHeading: Kursmitteilung CommCourseHeading: Kursmitteilung
CommTutorialHeading: Tutorium-Mitteilung CommTutorialHeading: Tutorium-Mitteilung
@ -1346,6 +1351,7 @@ ExamBonus: Bonuspunkte-System
ExamBonusRule: Prüfungsbonus aus Übungsbetrieb ExamBonusRule: Prüfungsbonus aus Übungsbetrieb
ExamNoBonus': Kein automatischer Bonus ExamNoBonus': Kein automatischer Bonus
ExamBonusPoints': Umrechnung von Übungspunkten ExamBonusPoints': Umrechnung von Übungspunkten
ExamBonusManual': Manuelle Berechnung
ExamBonusAchieved: Bonuspunkte ExamBonusAchieved: Bonuspunkte
@ -1415,6 +1421,7 @@ ExamEdited exam@ExamName: #{exam} erfolgreich bearbeitet
ExamNoShow: Nicht erschienen ExamNoShow: Nicht erschienen
ExamVoided: Entwertet ExamVoided: Entwertet
ExamBonusManualParticipants: Von den Kursverwaltern manuell berechnet
ExamBonusPoints possible@Points: Maximal #{showFixed True possible} Prüfungspunkte 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 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 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 CourseApplicationsTableCsvName tid@TermId ssh@SchoolId csh@CourseShorthand: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-bewerbungen
CsvColumnsExplanationsLabel: Spalten CsvColumnsExplanationsLabel: Spalten- & Zellenformat
CsvColumnsExplanationsTip: Bedeutung der in der CSV-Datei enthaltenen Spalten CsvColumnsExplanationsTip: Bedeutung und Format der in der CSV-Datei enthaltenen Spalten
CsvColumnExamUserSurname: Nachname(n) des Teilnehmers CsvColumnExamUserSurname: Nachname(n) des Teilnehmers
CsvColumnExamUserFirstName: Vorname(n) des Teilnehmers CsvColumnExamUserFirstName: Vorname(n) des Teilnehmers
CsvColumnExamUserName: Voller Name des Teilnehmers (gewöhnlicherweise inkl. Vor- und Nachname(n)) 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 CsvColumnApplicationsText: Text-Bewerbung
CsvColumnApplicationsHasFiles: Hat der Bewerber Dateien zu seiner Bewerbung eingereicht (siehe ZIP-Archiv aller Bewerbungsdateien)? 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 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 CsvColumnApplicationsComment: Kommentar zur Bewerbung; je nach Kurs-Einstellungen entweder nur als Notiz für die Kursverwalter oder Feedback für den Bewerber
Action: Aktion Action: Aktion
@ -1785,3 +1792,38 @@ ExamClosedSince time@Text: Klausur abgeschlossen seit #{time}
LecturerInfoTooltipNew: Neues Feature LecturerInfoTooltipNew: Neues Feature
LecturerInfoTooltipProblem: Noch nicht implementiertes Feature oder Feature mit bekannten Problemen 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

View File

@ -20,7 +20,7 @@ Course -- Information about a single course; contained info is always visible
applicationsRequired Bool default=false applicationsRequired Bool default=false
applicationsInstructions Html Maybe applicationsInstructions Html Maybe
applicationsText Bool default=false applicationsText Bool default=false
applicationsFiles UploadMode "default='{ \"mode\": \"no-upload\" }'::jsonb" applicationsFiles UploadMode "default='{\"mode\": \"no-upload\"}'::jsonb"
applicationsRatingsVisible Bool default=false applicationsRatingsVisible Bool default=false
TermSchoolCourseShort term school shorthand -- shorthand must be unique within school and semester TermSchoolCourseShort term school shorthand -- shorthand must be unique within school and semester
TermSchoolCourseName term school name -- name must be unique within school and semester TermSchoolCourseName term school name -- name must be unique within school and semester

View File

@ -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 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 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 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 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 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 deriving Show Eq Ord Generic -- Haskell-specific settings for runtime-value representing a row in memory

2
package-lock.json generated
View File

@ -1,6 +1,6 @@
{ {
"name": "uni2work", "name": "uni2work",
"version": "6.11.1", "version": "7.3.2",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {

View File

@ -1,6 +1,6 @@
{ {
"name": "uni2work", "name": "uni2work",
"version": "6.11.1", "version": "7.3.2",
"description": "", "description": "",
"keywords": [], "keywords": [],
"author": "", "author": "",

View File

@ -1,5 +1,5 @@
name: uniworx name: uniworx
version: 6.11.1 version: 7.3.2
dependencies: dependencies:
- base >=4.9.1.0 && <5 - base >=4.9.1.0 && <5

1
routes
View File

@ -72,6 +72,7 @@
/user/profile ProfileDataR GET !free /user/profile ProfileDataR GET !free
/user/authpreds AuthPredsR GET POST !free /user/authpreds AuthPredsR GET POST !free
/user/set-display-email SetDisplayEmailR GET POST !free /user/set-display-email SetDisplayEmailR GET POST !free
/user/csv-options CsvOptionsR GET POST !free
/exam-office ExamOfficeR !exam-office: /exam-office ExamOfficeR !exam-office:
/ EOExamsR GET / EOExamsR GET

View File

@ -24,7 +24,7 @@ import Language.Haskell.TH.Syntax (qLocation)
import Network.Wai (Middleware) import Network.Wai (Middleware)
import Network.Wai.Handler.Warp (Settings, defaultSettings, import Network.Wai.Handler.Warp (Settings, defaultSettings,
defaultShouldDisplayException, defaultShouldDisplayException,
runSettingsSocket, setHost, runSettings, runSettingsSocket, setHost,
setBeforeMainLoop, setBeforeMainLoop,
setOnException, setPort, getPort) setOnException, setPort, getPort)
import Data.Streaming.Network (bindPortTCP) 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 qualified System.Systemd.Daemon as Systemd
import System.Environment (lookupEnv) import System.Environment (lookupEnv)
import System.Posix.Process (getProcessID) 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 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 qualified Network.Socket as Socket (close)
import Control.Concurrent.STM.Delay import Control.Concurrent.STM.Delay
import Control.Monad.STM (retry) import Control.Monad.STM (retry)
import Control.Monad.Trans.Cont (runContT, callCC)
import qualified Data.Set as Set import qualified Data.Set as Set
@ -366,11 +367,20 @@ develMain = runResourceT $ do
wsettings <- liftIO . getDevSettings $ warpSettings foundation wsettings <- liftIO . getDevSettings $ warpSettings foundation
app <- makeApplication 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 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. -- | The @main@ function for an executable running this site.
appMain :: MonadUnliftIO m => m () appMain :: forall m. (MonadUnliftIO m, MonadMask m) => m ()
appMain = runResourceT $ do appMain = runResourceT $ do
settings <- getAppSettings settings <- getAppSettings
@ -398,7 +408,7 @@ appMain = runResourceT $ do
$logInfoS "bind" [st|Listening on #{tshow host} port #{tshow port} as per configuration|] $logInfoS "bind" [st|Listening on #{tshow host} port #{tshow port} as per configuration|]
liftIO $ pure <$> bindPortTCP port host 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 mainThreadId <- myThreadId
liftIO . void . flip (installHandler sigTERM) Nothing . Signals.CatchInfo $ \SignalInfo{..} -> runAppLoggingT foundation $ do liftIO . void . flip (installHandler sigTERM) Nothing . Signals.CatchInfo $ \SignalInfo{..} -> runAppLoggingT foundation $ do
@ -462,7 +472,7 @@ appMain = runResourceT $ do
foundationStoreNum :: Word32 foundationStoreNum :: Word32
foundationStoreNum = 2 foundationStoreNum = 2
getApplicationRepl :: (MonadResource m, MonadUnliftIO m) => m (Int, UniWorX, Application) getApplicationRepl :: (MonadResource m, MonadUnliftIO m, MonadMask m) => m (Int, UniWorX, Application)
getApplicationRepl = do getApplicationRepl = do
settings <- getAppDevSettings settings <- getAppDevSettings
foundation <- makeFoundation settings foundation <- makeFoundation settings

View File

@ -315,6 +315,8 @@ embedRenderMessage ''UniWorX ''UploadModeDescr id
embedRenderMessage ''UniWorX ''SecretJSONFieldException id embedRenderMessage ''UniWorX ''SecretJSONFieldException id
embedRenderMessage ''UniWorX ''AFormMessage $ concat . drop 2 . splitCamel embedRenderMessage ''UniWorX ''AFormMessage $ concat . drop 2 . splitCamel
embedRenderMessage ''UniWorX ''SchoolFunction id embedRenderMessage ''UniWorX ''SchoolFunction id
embedRenderMessage ''UniWorX ''CsvPreset id
embedRenderMessage ''UniWorX ''Quoting ("Csv" <>)
embedRenderMessage ''UniWorX ''AuthenticationMode id embedRenderMessage ''UniWorX ''AuthenticationMode id
@ -933,7 +935,7 @@ tagAccessPredicate AuthTime = APDB $ \mAuthId route _ -> case route of
return Authorized return Authorized
r -> $unsupportedAuthPredicate AuthTime r 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 CApplicationR tid ssh csh _ _ -> maybeT (unauthorizedI MsgUnauthorizedApplicationTime) $ do
course <- $cachedHereBinary (tid, ssh, csh) . MaybeT . getKeyBy $ TermSchoolCourseShort tid ssh csh course <- $cachedHereBinary (tid, ssh, csh) . MaybeT . getKeyBy $ TermSchoolCourseShort tid ssh csh
allocationCourse <- $cachedHereBinary course . lift . getBy $ UniqueAllocationCourse course allocationCourse <- $cachedHereBinary course . lift . getBy $ UniqueAllocationCourse course
@ -944,7 +946,8 @@ tagAccessPredicate AuthStaffTime = APDB $ \_ route _ -> case route of
Just Allocation{..} -> do Just Allocation{..} -> do
cTime <- liftIO getCurrentTime cTime <- liftIO getCurrentTime
guard $ NTop allocationStaffAllocationFrom <= NTop (Just cTime) guard $ NTop allocationStaffAllocationFrom <= NTop (Just cTime)
guard $ NTop (Just cTime) <= NTop allocationStaffAllocationTo when isWrite $
guard $ NTop (Just cTime) <= NTop allocationStaffAllocationTo
return Authorized return Authorized
@ -1197,10 +1200,11 @@ tagAccessPredicate AuthRegisterGroup = APDB $ \mAuthId route _ -> case route of
(Nothing, _) -> return Authorized (Nothing, _) -> return Authorized
(_, Nothing) -> return AuthenticationRequired (_, Nothing) -> return AuthenticationRequired
(Just rGroup, Just uid) -> do (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.on $ tutorial E.^. TutorialId E.==. participant E.^. TutorialParticipantTutorial
E.where_ $ participant E.^. TutorialParticipantUser E.==. E.val uid E.&&. tutorial E.^. TutorialCourse E.==. E.val tutorialCourse
E.&&. tutorial E.^. TutorialRegGroup E.==. E.just (E.val rGroup) E.&&. tutorial E.^. TutorialRegGroup E.==. E.just (E.val rGroup)
E.&&. participant E.^. TutorialParticipantUser E.==. E.val uid
guard $ not hasOther guard $ not hasOther
return Authorized return Authorized
r -> $unsupportedAuthPredicate AuthRegisterGroup r r -> $unsupportedAuthPredicate AuthRegisterGroup r
@ -3285,6 +3289,7 @@ upsertCampusUser ldapData Creds{..} = do
, userWarningDays = userDefaultWarningDays , userWarningDays = userDefaultWarningDays
, userNotificationSettings = def , userNotificationSettings = def
, userMailLanguages = def , userMailLanguages = def
, userCsvOptions = def
, userTokensIssuedAfter = Nothing , userTokensIssuedAfter = Nothing
, userCreated = now , userCreated = now
, userLastLdapSynchronisation = Just now , userLastLdapSynchronisation = Just now

View File

@ -82,8 +82,7 @@ applicationForm maId@(is _Just -> isAlloc) cid uid ApplicationFormMode{..} csrf
coursesNum <- fromIntegral . fromMaybe 1 <$> for maId (\aId -> count [AllocationCourseAllocation ==. aId]) coursesNum <- fromIntegral . fromMaybe 1 <$> for maId (\aId -> count [AllocationCourseAllocation ==. aId])
course <- getJust cid course <- getJust cid
(fromMaybe 0 -> maxPrio) <- fmap ((>>= E.unValue) . listToMaybe) . E.select . E.from $ \courseApplication -> do (fromMaybe 0 -> maxPrio) <- fmap ((>>= E.unValue) . listToMaybe) . E.select . E.from $ \courseApplication -> do
E.where_ $ courseApplication E.^. CourseApplicationCourse E.==. E.val cid E.where_ $ courseApplication E.^. CourseApplicationUser E.==. E.val uid
E.&&. courseApplication E.^. CourseApplicationUser E.==. E.val uid
E.&&. courseApplication E.^. CourseApplicationAllocation E.==. E.val maId E.&&. courseApplication E.^. CourseApplicationAllocation E.==. E.val maId
E.&&. E.not_ (E.isNothing $ courseApplication E.^. CourseApplicationAllocationPriority) E.&&. E.not_ (E.isNothing $ courseApplication E.^. CourseApplicationAllocationPriority)
return . E.joinV . E.max_ $ courseApplication E.^. CourseApplicationAllocationPriority return . E.joinV . E.max_ $ courseApplication E.^. CourseApplicationAllocationPriority

View File

@ -25,6 +25,10 @@ import qualified Data.Map as Map
import qualified Data.Conduit.List as C import qualified Data.Conduit.List as C
import Handler.Course.ParticipantInvite
import Jobs.Queue
type CourseApplicationsTableExpr = ( E.SqlExpr (Entity CourseApplication) type CourseApplicationsTableExpr = ( E.SqlExpr (Entity CourseApplication)
`E.InnerJoin` E.SqlExpr (Entity User) `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 StudyTerms))
`E.InnerJoin` E.SqlExpr (Maybe (Entity StudyDegree)) `E.InnerJoin` E.SqlExpr (Maybe (Entity StudyDegree))
) )
`E.LeftOuterJoin` E.SqlExpr (Maybe (Entity CourseParticipant))
type CourseApplicationsTableData = DBRow ( Entity CourseApplication type CourseApplicationsTableData = DBRow ( Entity CourseApplication
, Entity User , Entity User
, E.Value Bool -- hasFiles , Bool -- hasFiles
, Maybe (Entity Allocation) , Maybe (Entity Allocation)
, Maybe (Entity StudyFeatures) , Maybe (Entity StudyFeatures)
, Maybe (Entity StudyTerms) , Maybe (Entity StudyTerms)
, Maybe (Entity StudyDegree) , Maybe (Entity StudyDegree)
, Bool -- isParticipant
) )
courseApplicationsIdent :: Text courseApplicationsIdent :: Text
courseApplicationsIdent = "applications" courseApplicationsIdent = "applications"
queryCourseApplication :: Getter CourseApplicationsTableExpr (E.SqlExpr (Entity CourseApplication)) 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 :: 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 :: 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 where
hasFiles appl = E.exists . E.from $ \courseApplicationFile -> hasFiles appl = E.exists . E.from $ \courseApplicationFile ->
E.where_ $ courseApplicationFile E.^. CourseApplicationFileApplication E.==. appl E.^. CourseApplicationId E.where_ $ courseApplicationFile E.^. CourseApplicationFileApplication E.==. appl E.^. CourseApplicationId
queryAllocation :: Getter CourseApplicationsTableExpr (E.SqlExpr (Maybe (Entity Allocation))) 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 :: 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 :: 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 :: 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 :: Lens' CourseApplicationsTableData (Entity CourseApplication)
resultCourseApplication = _dbrOutput . _1 resultCourseApplication = _dbrOutput . _1
@ -77,7 +89,7 @@ resultUser :: Lens' CourseApplicationsTableData (Entity User)
resultUser = _dbrOutput . _2 resultUser = _dbrOutput . _2
resultHasFiles :: Lens' CourseApplicationsTableData Bool resultHasFiles :: Lens' CourseApplicationsTableData Bool
resultHasFiles = _dbrOutput . _3 . _Value resultHasFiles = _dbrOutput . _3
resultAllocation :: Traversal' CourseApplicationsTableData (Entity Allocation) resultAllocation :: Traversal' CourseApplicationsTableData (Entity Allocation)
resultAllocation = _dbrOutput . _4 . _Just resultAllocation = _dbrOutput . _4 . _Just
@ -91,6 +103,9 @@ resultStudyTerms = _dbrOutput . _6 . _Just
resultStudyDegree :: Traversal' CourseApplicationsTableData (Entity StudyDegree) resultStudyDegree :: Traversal' CourseApplicationsTableData (Entity StudyDegree)
resultStudyDegree = _dbrOutput . _7 . _Just resultStudyDegree = _dbrOutput . _7 . _Just
resultIsParticipant :: Lens' CourseApplicationsTableData Bool
resultIsParticipant = _dbrOutput . _8
newtype CourseApplicationsTableVeto = CourseApplicationsTableVeto Bool newtype CourseApplicationsTableVeto = CourseApplicationsTableVeto Bool
deriving (Eq, Ord, Read, Show, Generic, Typeable) deriving (Eq, Ord, Read, Show, Generic, Typeable)
@ -207,10 +222,42 @@ instance Exception CourseApplicationsTableCsvException
embedRenderMessage ''UniWorX ''CourseApplicationsTableCsvException id 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 :: TermId -> SchoolId -> CourseShorthand -> Handler Html
getCApplicationsR = postCApplicationsR getCApplicationsR = postCApplicationsR
postCApplicationsR tid ssh csh = do postCApplicationsR tid ssh csh = do
(table, allocationsBounds) <- runDB $ do (table, allocationsBounds, mayAccept) <- runDB $ do
Entity cid Course{..} <- getBy404 $ TermSchoolCourseShort tid ssh csh Entity cid Course{..} <- getBy404 $ TermSchoolCourseShort tid ssh csh
csvName <- getMessageRender <*> pure (MsgCourseApplicationsTableCsvName tid ssh csh) csvName <- getMessageRender <*> pure (MsgCourseApplicationsTableCsvName tid ssh csh)
@ -237,31 +284,43 @@ postCApplicationsR tid ssh csh = do
studyFeatures <- view queryStudyFeatures studyFeatures <- view queryStudyFeatures
studyTerms <- view queryStudyTerms studyTerms <- view queryStudyTerms
studyDegree <- view queryStudyDegree studyDegree <- view queryStudyDegree
courseParticipant <- view queryCourseParticipant
lift $ do 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 $ studyDegree E.?. StudyDegreeId E.==. studyFeatures E.?. StudyFeaturesDegree
E.on $ studyTerms E.?. StudyTermsId E.==. studyFeatures E.?. StudyFeaturesField E.on $ studyTerms E.?. StudyTermsId E.==. studyFeatures E.?. StudyFeaturesField
E.on $ studyFeatures E.?. StudyFeaturesId E.==. courseApplication E.^. CourseApplicationField E.on $ studyFeatures E.?. StudyFeaturesId E.==. courseApplication E.^. CourseApplicationField
E.on $ courseApplication E.^. CourseApplicationAllocation E.==. allocation E.?. AllocationId E.on $ courseApplication E.^. CourseApplicationAllocation E.==. allocation E.?. AllocationId
E.on $ user E.^. UserId E.==. courseApplication E.^. CourseApplicationUser 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 :: DBRow _ -> MaybeT (YesodDB UniWorX) CourseApplicationsTableData
dbtProj = runReaderT $ do dbtProj = runReaderT $ do
appId <- view $ resultCourseApplication . _entityKey appId <- view $ _dbrOutput . _1 . _entityKey
cID <- encrypt appId cID <- encrypt appId
guardM . hasReadAccessTo $ CApplicationR tid ssh csh cID CAEditR 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) dbtRowKey = view $ queryCourseApplication . to (E.^. CourseApplicationId)
dbtColonnade :: Colonnade Sortable _ _ dbtColonnade :: Colonnade Sortable _ _
dbtColonnade = mconcat 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 (resultCourseApplication . _entityKey) applicationLink) $ colApplicationId (resultCourseApplication . _entityKey)
, anchorColonnadeM (views (resultUser . _entityKey) participantLink) $ colUserDisplayName (resultUser . _entityVal . $(multifocusL 2) _userDisplayName _userSurname) , anchorColonnadeM (views (resultUser . _entityKey) participantLink) $ colUserDisplayName (resultUser . _entityVal . $(multifocusL 2) _userDisplayName _userSurname)
, colUserMatriculation (resultUser . _entityVal . _userMatrikelnummer) , colUserMatriculation (resultUser . _entityVal . _userMatrikelnummer)
@ -276,7 +335,8 @@ postCApplicationsR tid ssh csh = do
] ]
dbtSorting = mconcat 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)) , sortUserName' $ $(multifocusG 2) (queryUser . to (E.^. UserDisplayName)) (queryUser . to (E.^. UserSurname))
, sortUserMatriculation $ queryUser . to (E.^. UserMatrikelnummer) , sortUserMatriculation $ queryUser . to (E.^. UserMatrikelnummer)
, sortStudyTerms queryStudyTerms , sortStudyTerms queryStudyTerms
@ -566,12 +626,67 @@ postCApplicationsR tid ssh csh = do
|| numFirstChoice' /= numFirstChoice || numFirstChoice' /= numFirstChoice
] ]
(, allocationsBounds) <$> dbTableWidget' psValidator DBTable{..} mayAccept <- hasWriteAccessTo $ CourseR tid ssh csh CAddUserR
(, allocationsBounds, mayAccept) <$> dbTableWidget' psValidator DBTable{..}
now <- liftIO getCurrentTime now <- liftIO getCurrentTime
let title = prependCourseTitle tid ssh csh MsgCourseApplicationsListTitle let title = prependCourseTitle tid ssh csh MsgCourseApplicationsListTitle
registrationOpen = maybe True (now <) 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 siteLayoutMsg title $ do
setTitleI title setTitleI title
$(widgetFile "course/applications-list") $(widgetFile "course/applications-list")

View File

@ -107,12 +107,13 @@ makeCourseForm miButtonAction template = identifyForm FIDcourse . validateFormDB
MsgRenderer mr <- getMsgRenderer MsgRenderer mr <- getMsgRenderer
uid <- liftHandler requireAuthId uid <- liftHandler requireAuthId
(lecturerSchools, adminSchools) <- liftHandler . runDB $ do (lecturerSchools, adminSchools, oldSchool) <- liftHandler . runDB $ do
lecturerSchools <- map (userFunctionSchool . entityVal) <$> selectList [UserFunctionUser ==. uid, UserFunctionFunction <-. [SchoolLecturer]] [] lecturerSchools <- map (userFunctionSchool . entityVal) <$> selectList [UserFunctionUser ==. uid, UserFunctionFunction <-. [SchoolLecturer]] []
protoAdminSchools <- map (userFunctionSchool . entityVal) <$> selectList [UserFunctionUser ==. uid, UserFunctionFunction <-. [SchoolAdmin]] [] protoAdminSchools <- map (userFunctionSchool . entityVal) <$> selectList [UserFunctionUser ==. uid, UserFunctionFunction <-. [SchoolAdmin]] []
adminSchools <- filterM (hasWriteAccessTo . flip SchoolR SchoolEditR) protoAdminSchools adminSchools <- filterM (hasWriteAccessTo . flip SchoolR SchoolEditR) protoAdminSchools
return (lecturerSchools, adminSchools) oldSchool <- forM (cfCourseId =<< template) $ fmap courseSchool . getJust
let userSchools = nub $ lecturerSchools ++ adminSchools return (lecturerSchools, adminSchools, oldSchool)
let userSchools = nub . maybe id (:) oldSchool $ lecturerSchools ++ adminSchools
termsField <- case template of termsField <- case template of
-- Change of term is only allowed if user may delete the course (i.e. no participants) or admin -- Change of term is only allowed if user may delete the course (i.e. no participants) or admin

View File

@ -4,6 +4,9 @@ module Handler.Course.ParticipantInvite
( InvitableJunction(..), InvitationDBData(..), InvitationTokenData(..) ( InvitableJunction(..), InvitationDBData(..), InvitationTokenData(..)
, getCInviteR, postCInviteR , getCInviteR, postCInviteR
, getCAddUserR, postCAddUserR , getCAddUserR, postCAddUserR
, AddParticipantsResult(..)
, addParticipantsResultMessages
, registerUsers, registerUser
) where ) where
import Import import Import
@ -96,16 +99,16 @@ participantInvitationConfig = InvitationConfig{..}
return . SomeMessage $ MsgCourseParticipantInvitationAccepted (CI.original courseName) return . SomeMessage $ MsgCourseParticipantInvitationAccepted (CI.original courseName)
invitationUltDest (Entity _ Course{..}) _ = return . SomeRoute $ CourseR courseTerm courseSchool courseShorthand CShowR invitationUltDest (Entity _ Course{..}) _ = return . SomeRoute $ CourseR courseTerm courseSchool courseShorthand CShowR
data AddRecipientsResult = AddRecipientsResult data AddParticipantsResult = AddParticipantsResult
{ aurAlreadyRegistered { aurAlreadyRegistered
, aurNoUniquePrimaryField , aurNoUniquePrimaryField
, aurSuccess :: [UserEmail] , aurSuccess :: Set UserId
} deriving (Read, Show, Generic, Typeable) } deriving (Read, Show, Generic, Typeable)
instance Semigroup AddRecipientsResult where instance Semigroup AddParticipantsResult where
(<>) = mappenddefault (<>) = mappenddefault
instance Monoid AddRecipientsResult where instance Monoid AddParticipantsResult where
mempty = memptydefault mempty = memptydefault
mappend = (<>) mappend = (<>)
@ -118,7 +121,9 @@ postCAddUserR tid ssh csh = do
wreq (multiUserField (maybe True not $ formResultToMaybe enlist) Nothing) wreq (multiUserField (maybe True not $ formResultToMaybe enlist) Nothing)
(fslI MsgCourseParticipantInviteField & setTooltip MsgMultiEmailFieldTip) 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 let heading = prependCourseTitle tid ssh csh MsgCourseParticipantsRegisterHeading
@ -128,57 +133,74 @@ postCAddUserR tid ssh csh = do
{ formEncoding { formEncoding
, formAction = Just . SomeRoute $ CourseR tid ssh csh CAddUserR , 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) $ registerUsers :: CourseId -> Set (Either UserEmail UserId) -> WriterT [Message] (YesodJobDB UniWorX) ()
tell . pure <=< messageI Success . MsgCourseParticipantsInvited $ length emails registerUsers cid users = do
let (emails,uids) = partitionEithers $ Set.toList users
unless (null aurAlreadyRegistered) $ do -- send Invitation eMails to unkown users
let modalTrigger = [whamlet|_{MsgCourseParticipantsAlreadyRegistered (length aurAlreadyRegistered)}|] lift $ sinkInvitationsF participantInvitationConfig [(mail,cid,(InvDBDataParticipant,InvTokenDataParticipant)) | mail <- emails]
modalContent = $(widgetFile "messages/courseInvitationAlreadyRegistered") -- register known users
tell . pure <=< messageWidget Info $ msgModal modalTrigger (Right modalContent) tell <=< lift . addParticipantsResultMessages <=< lift . execWriterT $ mapM_ (registerUser cid) uids
unless (null aurNoUniquePrimaryField) $ do unless (null emails) $
let modalTrigger = [whamlet|_{MsgCourseParticipantsRegisteredWithoutField (length aurNoUniquePrimaryField)}|] tell . pure <=< messageI Success . MsgCourseParticipantsInvited $ length emails
modalContent = $(widgetFile "messages/courseInvitationRegisteredWithoutField")
tell . pure <=< messageWidget Warning $ msgModal modalTrigger (Right modalContent)
unless (null aurSuccess) $
tell . pure <=< messageI Success . MsgCourseParticipantsRegistered $ length aurSuccess
registerUser :: CourseId -> UserId -> WriterT AddRecipientsResult (YesodJobDB UniWorX) () addParticipantsResultMessages :: (MonadHandler m, HandlerSite m ~ UniWorX)
registerUser cid uid = exceptT tell tell $ do => AddParticipantsResult
User{..} <- lift . lift $ getJust uid -> 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) $ unless (null aurAlreadyRegistered) $ do
throwError $ mempty { aurAlreadyRegistered = pure userEmail } 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 unless (null aurSuccess) $
| [f] <- features = Just f tell . pure <=< messageI Success . MsgCourseParticipantsRegistered $ length aurSuccess
| 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 registerUser :: (MonadHandler m, HandlerSite m ~ UniWorX, MonadCatch m)
Nothing -> mempty { aurNoUniquePrimaryField = pure userEmail } => CourseId
Just _ -> mempty { aurSuccess = pure userEmail } -> 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 getCInviteR, postCInviteR :: TermId -> SchoolId -> CourseShorthand -> Handler Html

View File

@ -72,12 +72,18 @@ getEShowR tid ssh csh examn = do
examClosedShown = lecturerInfoShown examClosedShown = lecturerInfoShown
sumMaxPoints = sum [ fromRational examPartWeight * mPoints | Entity _ ExamPart{..} <- examParts, let Just mPoints = examPartMaxPoints ] sumMaxPoints = sum [ fromRational examPartWeight * mPoints | Entity _ ExamPart{..} <- examParts, let Just mPoints = examPartMaxPoints ]
sumPoints = getSum <$> foldMap (fmap Sum . examPartResultResult . entityVal) results
noBonus = fromMaybe False $ do noBonus = fromMaybe False $ do
guardM $ bonusOnlyPassed <$> examBonusRule guardM $ bonusOnlyPassed <$> examBonusRule
return . fromMaybe True $ result ^? _Just . _entityVal . _examResultResult . _examResult . passingGrade . _Wrapped . to not 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 let examTimes = all (\(Entity _ ExamOccurrence{..}, _) -> Just examOccurrenceStart == examStart && examOccurrenceEnd == examEnd) occurrences
registerWidget registerWidget

View File

@ -160,7 +160,7 @@ resultCourseNote = _dbrOutput . _10 . _Just
resultAutomaticExamBonus :: Exam -> Map UserId SheetTypeSummary -> Fold ExamUserTableData Points 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 -> Map UserId SheetTypeSummary -> Fold ExamUserTableData ExamResultGrade
resultAutomaticExamResult exam examBonus' = folding . runReader $ do resultAutomaticExamResult exam examBonus' = folding . runReader $ do
@ -396,7 +396,7 @@ postEUsersR tid ssh csh examn = do
allBoni :: SheetGradeSummary allBoni :: SheetGradeSummary
allBoni = (mappend <$> normalSummary <*> bonusSummary) $ fold bonus allBoni = (mappend <$> normalSummary <*> bonusSummary) $ fold bonus
doBonus = is _Just examGradingRule || is _Just examBonusRule doBonus = is _Just examBonusRule
showPasses = doBonus && numSheetsPasses allBoni /= 0 showPasses = doBonus && numSheetsPasses allBoni /= 0
showPoints = doBonus && getSum (numSheetsPoints allBoni) /= 0 showPoints = doBonus && getSum (numSheetsPoints allBoni) /= 0
@ -494,14 +494,14 @@ postEUsersR tid ssh csh examn = do
, pure $ colDegreeShort resultStudyDegree , pure $ colDegreeShort resultStudyDegree
, pure $ colFeaturesSemester resultStudyFeatures , 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 , 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 , guardOn showPasses $ sortable Nothing (i18nCell MsgAchievedPasses) $ \(view $ resultUser . _entityKey -> uid) ->
SheetGradeSummary{achievedPasses} <- examBonusAchieved uid bonus let SheetGradeSummary{achievedPasses} = examBonusAchieved uid bonus
SheetGradeSummary{numSheetsPasses} <- examBonusPossible uid bonus SheetGradeSummary{numSheetsPasses} = examBonusPossible uid bonus
return $ propCell (getSum achievedPasses) (getSum numSheetsPasses) in propCell (getSum achievedPasses) (getSum numSheetsPasses)
, guardOn showPoints $ sortable Nothing (i18nCell MsgAchievedPoints) $ \(view $ resultUser . _entityKey -> uid) -> fromMaybe mempty $ do , guardOn showPoints $ sortable Nothing (i18nCell MsgAchievedPoints) $ \(view $ resultUser . _entityKey -> uid) ->
SheetGradeSummary{achievedPoints} <- examBonusAchieved uid bonus let SheetGradeSummary{achievedPoints} = examBonusAchieved uid bonus
SheetGradeSummary{sumSheetsPoints} <- examBonusPossible uid bonus SheetGradeSummary{sumSheetsPoints} = examBonusPossible uid bonus
return $ propCell (getSum achievedPoints) (getSum sumSheetsPoints) in propCell (getSum achievedPoints) (getSum sumSheetsPoints)
, guardOn doBonus $ sortable (Just "bonus") (i18nCell MsgExamBonusAchieved) . automaticCell $ resultExamBonus . _entityVal . _examBonusBonus . to Right <> resultAutomaticExamBonus' . to Left , guardOn doBonus $ sortable (Just "bonus") (i18nCell MsgExamBonusAchieved) . automaticCell $ resultExamBonus . _entityVal . _examBonusBonus . to Right <> resultAutomaticExamBonus' . to Left
, pure $ mconcat , pure $ mconcat
[ sortable (Just $ fromText [st|part-#{toPathPiece examPartNumber}|]) (i18nCell $ MsgExamPartNumbered examPartNumber) $ maybe mempty i18nCell . preview (resultExamPartResult epId . _Just . _entityVal . _examPartResultResult) [ 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 (resultStudyDegree . _entityVal . to (\StudyDegree{..} -> studyDegreeName <|> studyDegreeShorthand <|> Just (tshow studyDegreeKey)) . _Just)
<*> preview (resultStudyFeatures . _entityVal . _studyFeaturesSemester) <*> preview (resultStudyFeatures . _entityVal . _studyFeaturesSemester)
<*> preview (resultExamOccurrence . _entityVal . _examOccurrenceName) <*> preview (resultExamOccurrence . _entityVal . _examOccurrenceName)
<*> previews (resultUser . _entityKey . to (examBonusAchieved ?? bonus) . _Just . _achievedPoints . _Wrapped) (bool (const Nothing) Just showPoints) <*> previews (resultUser . _entityKey . to (examBonusAchieved ?? bonus) . _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 (examBonusAchieved ?? bonus) . _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) . _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 (examBonusPossible ?? bonus) . _numSheetsPasses . _Wrapped . integral) (bool (const Nothing) Just showPasses)
<*> previews (resultExamBonus . _entityVal . _examBonusBonus <> resultAutomaticExamBonus') (bool (const Nothing) Just doBonus) <*> previews (resultExamBonus . _entityVal . _examBonusBonus <> resultAutomaticExamBonus') (bool (const Nothing) Just doBonus)
<*> (Map.fromList . map (over _1 examPartNumber . over (_2 . _Just) (examPartResultResult . entityVal)) <$> asks (toListOf resultExamParts)) <*> (Map.fromList . map (over _1 examPartNumber . over (_2 . _Just) (examPartResultResult . entityVal)) <$> asks (toListOf resultExamParts))
<*> previews (resultExamResult . _entityVal . _examResultResult <> resultAutomaticExamResult') resultView <*> previews (resultExamResult . _entityVal . _examResultResult <> resultAutomaticExamResult') resultView
@ -645,7 +645,7 @@ postEUsersR tid ssh csh examn = do
when (epNumber `elem` examPartNumbers) $ when (epNumber `elem` examPartNumbers) $
yield $ ExamUserCsvSetPartResultData uid epNumber (Just epRes) 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 yield . ExamUserCsvSetBonusData False uid . join $ csvEUserBonus dbCsvNew
when (is _Just $ csvEUserExamResult dbCsvNew) $ when (is _Just $ csvEUserExamResult dbCsvNew) $
@ -684,15 +684,18 @@ postEUsersR tid ssh csh examn = do
newResult = fmap resultView <$> examGrade examVal (newBonus <|> oldBonus) =<< newResults newResult = fmap resultView <$> examGrade examVal (newBonus <|> oldBonus) =<< newResults
oldResult = dbCsvOld ^? (resultExamResult . _entityVal . _examResultResult <> resultAutomaticExamResult') . to resultView oldResult = dbCsvOld ^? (resultExamResult . _entityVal . _examResultResult <> resultAutomaticExamResult') . to resultView
case newBonus of when doBonus $
_ | newBonus == oldBonus case newBonus of
-> return () _ | newBonus == oldBonus
_ | is _Nothing newBonus -> return ()
-> return () _ | is _Nothing newBonus
Nothing -> return ()
-> yield $ ExamUserCsvSetBonusData False uid newBonus _ | Just ExamBonusManual{} <- examBonusRule
Just _ -> yield $ ExamUserCsvSetBonusData False uid newBonus
-> yield $ ExamUserCsvSetBonusData True uid newBonus Nothing
-> yield $ ExamUserCsvSetBonusData False uid newBonus
Just _
-> yield $ ExamUserCsvSetBonusData True uid newBonus
case newResult of case newResult of
_ | csvEUserExamResult dbCsvNew == oldResult _ | csvEUserExamResult dbCsvNew == oldResult
@ -928,22 +931,31 @@ postEUsersR tid ssh csh examn = do
guessUser :: ExamUserTableCsv -> DB (Bool, UserId) guessUser :: ExamUserTableCsv -> DB (Bool, UserId)
guessUser ExamUserTableCsv{..} = $cachedHereBinary (csvEUserMatriculation, csvEUserName, csvEUserSurname) $ do guessUser ExamUserTableCsv{..} = $cachedHereBinary (csvEUserMatriculation, csvEUserName, csvEUserSurname) $ do
users <- E.select . E.from $ \user -> 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.^. UserMatrikelnummer E.==.) . E.val . Just <$> csvEUserMatriculation
, (user E.^. UserDisplayName E.==.) . E.val <$> csvEUserName , (user E.^. UserDisplayName `E.hasInfix`) . E.val <$> csvEUserName
, (user E.^. UserSurname E.==.) . E.val <$> csvEUserSurname , (user E.^. UserSurname `E.hasInfix`) . E.val <$> csvEUserSurname
, (user E.^. UserFirstName E.==.) . E.val <$> csvEUserFirstName , (user E.^. UserFirstName `E.hasInfix`) . E.val <$> csvEUserFirstName
] ]
let isCourseParticipant = E.exists . E.from $ \courseParticipant -> let isCourseParticipant = E.exists . E.from $ \courseParticipant ->
E.where_ $ courseParticipant E.^. CourseParticipantCourse E.==. E.val examCourse E.where_ $ courseParticipant E.^. CourseParticipantCourse E.==. E.val examCourse
E.&&. courseParticipant E.^. CourseParticipantUser E.==. user E.^. UserId E.&&. courseParticipant E.^. CourseParticipantUser E.==. user E.^. UserId
E.limit 2 return (isCourseParticipant, user)
return (isCourseParticipant, user E.^. UserId) let users' = reverse $ sortBy closeness users
case users of closeness :: (E.Value Bool, Entity User) -> (E.Value Bool, Entity User) -> Ordering
(filter . view $ _1 . _Value -> [(E.Value isPart, E.Value uid)]) closeness = mconcat $ catMaybes
-> return (isPart, uid) [ pure $ comparing (preview $ _2 . _entityVal . _userMatrikelnummer . only csvEUserMatriculation)
[(E.Value isPart, E.Value uid)] , pure $ comparing (view _1)
-> return (isPart, uid) , 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 _other
-> throwM ExamUserCsvExceptionNoMatchingUser -> throwM ExamUserCsvExceptionNoMatchingUser

View File

@ -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 import Import
@ -796,3 +803,25 @@ postSetDisplayEmailR = do
siteLayoutMsg MsgTitleChangeUserDisplayEmail $ do siteLayoutMsg MsgTitleChangeUserDisplayEmail $ do
setTitleI MsgTitleChangeUserDisplayEmail setTitleI MsgTitleChangeUserDisplayEmail
$(i18nWidgetFile "set-display-email") $(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 ]
}

View File

@ -686,7 +686,7 @@ defaultLoads shid = do
return (sheetCorrector E.^. SheetCorrectorUser, sheetCorrector E.^. SheetCorrectorLoad, sheetCorrector E.^. SheetCorrectorState) return (sheetCorrector E.^. SheetCorrectorUser, sheetCorrector E.^. SheetCorrectorLoad, sheetCorrector E.^. SheetCorrectorState)
where where
toMap :: [(E.Value UserId, E.Value Load, E.Value CorrectorState)] -> Loads 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)) 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' :: (Either UserEmail UserId, (CorrectorState, Load)) -> Either (Invitation' SheetCorrector) SheetCorrector
postProcess' (Right sheetCorrectorUser, (sheetCorrectorState, sheetCorrectorLoad)) = Right 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 :: Maybe (Map ListPosition (Either UserEmail UserId, (CorrectorState, Load)))
filledData = Just . Map.fromList . zip [0..] $ Map.toList loads -- TODO orderBy Name?! filledData = Just . Map.fromList . zip [0..] $ Map.toList loads -- TODO orderBy Name?!
@ -906,7 +906,7 @@ correctorInvitationConfig = InvitationConfig{..}
itAuthority <- liftHandler requireAuthId itAuthority <- liftHandler requireAuthId
return $ InvitationTokenConfig itAuthority Nothing Nothing Nothing return $ InvitationTokenConfig itAuthority Nothing Nothing Nothing
invitationRestriction _ _ = return Authorized invitationRestriction _ _ = return Authorized
invitationForm _ (InvDBDataSheetCorrector load state, _) _ = pure $ (JunctionSheetCorrector load state, ()) invitationForm _ (InvDBDataSheetCorrector cLoad cState, _) _ = pure $ (JunctionSheetCorrector cLoad cState, ())
invitationInsertHook _ _ _ _ = id invitationInsertHook _ _ _ _ = id
invitationSuccessMsg (Entity _ Sheet{..}) _ = return . SomeMessage $ MsgCorrectorInvitationAccepted sheetName invitationSuccessMsg (Entity _ Sheet{..}) _ = return . SomeMessage $ MsgCorrectorInvitationAccepted sheetName
invitationUltDest (Entity _ Sheet{..}) _ = do invitationUltDest (Entity _ Sheet{..}) _ = do

View File

@ -1,480 +1,13 @@
{-# OPTIONS_GHC -fno-warn-orphans #-}
module Handler.Tutorial module Handler.Tutorial
( module Handler.Tutorial ( module Handler.Tutorial
) where ) where
import Import import Handler.Tutorial.Communication as Handler.Tutorial
import Handler.Utils import Handler.Tutorial.Delete as Handler.Tutorial
import Handler.Utils.Tutorial import Handler.Tutorial.Edit as Handler.Tutorial
import Handler.Utils.Delete import Handler.Tutorial.Form as Handler.Tutorial
import Handler.Utils.Communication import Handler.Tutorial.List as Handler.Tutorial
import Handler.Utils.Form.Occurrences import Handler.Tutorial.New as Handler.Tutorial
import Handler.Utils.Invitations import Handler.Tutorial.Register as Handler.Tutorial
import Jobs.Queue import Handler.Tutorial.TutorInvite as Handler.Tutorial
import Handler.Tutorial.Users as Handler.Tutorial
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
<ul .list--iconless .list--inline .list--comma-separated>
$forall tutor <- tutors
<li>
^{nameEmailWidget' tutor}
|]
, sortable (Just "participants") (i18nCell MsgTutorialParticipants) $ \DBRow{ dbrOutput = (Entity _ Tutorial{..}, n) } -> anchorCell (CTutorialR tid ssh csh tutorialName TUsersR) $ tshow n
, sortable (Just "capacity") (i18nCell MsgTutorialCapacity) $ \DBRow{ dbrOutput = (Entity _ Tutorial{..}, _) } -> maybe mempty (textCell . tshow) tutorialCapacity
, sortable (Just "room") (i18nCell MsgTutorialRoom) $ \DBRow{ dbrOutput = (Entity _ Tutorial{..}, _) } -> textCell tutorialRoom
, sortable Nothing (i18nCell MsgTutorialTime) $ \DBRow{ dbrOutput = (Entity _ Tutorial{..}, _) } -> occurrencesCell tutorialTime
, sortable (Just "register-group") (i18nCell MsgTutorialRegGroup) $ \DBRow{ dbrOutput = (Entity _ Tutorial{..}, _) } -> maybe mempty (textCell . CI.original) tutorialRegGroup
, sortable (Just "register-from") (i18nCell MsgTutorialRegisterFrom) $ \DBRow{ dbrOutput = (Entity _ Tutorial{..}, _) } -> maybeDateTimeCell tutorialRegisterFrom
, sortable (Just "register-to") (i18nCell MsgTutorialRegisterTo) $ \DBRow{ dbrOutput = (Entity _ Tutorial{..}, _) } -> maybeDateTimeCell tutorialRegisterTo
, sortable (Just "deregister-until") (i18nCell MsgTutorialDeregisterUntil) $ \DBRow{ dbrOutput = (Entity _ Tutorial{..}, _) } -> maybeDateTimeCell tutorialDeregisterUntil
, sortable Nothing mempty $ \DBRow{ dbrOutput = (Entity _ Tutorial{..}, _) } -> cell $ do
linkButton mempty [whamlet|_{MsgTutorialEdit}|] [BCIsButton] . SomeRoute $ CTutorialR tid ssh csh tutorialName TEditR
linkButton mempty [whamlet|_{MsgTutorialDelete}|] [BCIsButton, BCDanger] . SomeRoute $ CTutorialR tid ssh csh tutorialName TDeleteR
]
dbtSorting = Map.fromList
[ ("type", SortColumn $ \tutorial -> tutorial E.^. TutorialType )
, ("name", SortColumn $ \tutorial -> tutorial E.^. TutorialName )
, ("participants", SortColumn $ \tutorial -> 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))
)
, ("capacity", SortColumn $ \tutorial -> tutorial E.^. TutorialCapacity )
, ("room", SortColumn $ \tutorial -> tutorial E.^. TutorialRoom )
, ("register-group", SortColumn $ \tutorial -> tutorial E.^. TutorialRegGroup )
, ("register-from", SortColumn $ \tutorial -> tutorial E.^. TutorialRegisterFrom )
, ("register-to", SortColumn $ \tutorial -> tutorial E.^. TutorialRegisterTo )
, ("deregister-until", SortColumn $ \tutorial -> tutorial E.^. TutorialDeregisterUntil )
]
dbtFilter = Map.empty
dbtFilterUI = const mempty
dbtStyle = def
dbtParams = def
dbtIdent :: Text
dbtIdent = "tutorials"
dbtCsvEncode = noCsvEncode
dbtCsvDecode = Nothing
tutorialDBTableValidator = def
& defaultSorting [SortAscBy "type", SortAscBy "name"]
((), tutorialTable) <- runDB $ dbTable tutorialDBTableValidator tutorialDBTable
siteLayoutMsg (prependCourseTitle tid ssh csh MsgTutorialsHeading) $ do
setTitleI $ prependCourseTitle tid ssh csh MsgTutorialsHeading
$(widgetFile "tutorial-list")
postTRegisterR :: TermId -> SchoolId -> CourseShorthand -> TutorialName -> Handler ()
postTRegisterR tid ssh csh tutn = do
uid <- requireAuthId
Entity tutid Tutorial{..} <- runDB $ fetchTutorial tid ssh csh tutn
((btnResult, _), _) <- runFormPost buttonForm
formResult btnResult $ \case
BtnRegister -> do
runDB . void . insert $ TutorialParticipant tutid uid
addMessageI Success $ MsgTutorialRegisteredSuccess tutorialName
redirect $ CourseR tid ssh csh CShowR
BtnDeregister -> do
runDB . deleteBy $ UniqueTutorialParticipant tutid uid
addMessageI Success $ MsgTutorialDeregisteredSuccess tutorialName
redirect $ CourseR tid ssh csh CShowR
invalidArgs ["Register/Deregister button required"]
getTDeleteR, postTDeleteR :: TermId -> SchoolId -> CourseShorthand -> TutorialName -> Handler Html
getTDeleteR = postTDeleteR
postTDeleteR tid ssh csh tutn = do
tutid <- runDB $ fetchTutorialId tid ssh csh tutn
deleteR DeleteRoute
{ drRecords = Set.singleton tutid
, drUnjoin = \(_ `E.InnerJoin` tutorial) -> tutorial
, drGetInfo = \(course `E.InnerJoin` tutorial) -> do
E.on $ course E.^. CourseId E.==. tutorial E.^. TutorialCourse
let participants = E.sub_select . E.from $ \participant -> do
E.where_ $ participant E.^. TutorialParticipantTutorial E.==. tutorial E.^. TutorialId
return E.countRows
return (course, tutorial, participants :: E.SqlExpr (E.Value Int))
, drRenderRecord = \(Entity _ Course{..}, Entity _ Tutorial{..}, E.Value ps) ->
return [whamlet|_{prependCourseTitle courseTerm courseSchool courseShorthand (CI.original tutorialName)} (_{MsgParticipantsN ps})|]
, drRecordConfirmString = \(Entity _ Course{..}, Entity _ Tutorial{..}, E.Value ps) ->
return [st|#{termToText (unTermKey courseTerm)}/#{unSchoolKey courseSchool}/#{courseShorthand}/#{tutorialName}+#{tshow ps}|]
, drCaption = SomeMessage MsgTutorialDeleteQuestion
, drSuccessMessage = SomeMessage MsgTutorialDeleted
, drAbort = SomeRoute $ CTutorialR tid ssh csh tutn TUsersR
, drSuccess = SomeRoute $ CourseR tid ssh csh CTutorialListR
, drDelete = \_ -> id -- TODO: audit
}
getTCommR, postTCommR :: TermId -> SchoolId -> CourseShorthand -> TutorialName -> Handler Html
getTCommR = postTCommR
postTCommR tid ssh csh tutn = do
jSender <- requireAuthId
(cid, tutid) <- runDB $ fetchCourseIdTutorialId tid ssh csh tutn
commR CommunicationRoute
{ crHeading = SomeMessage . prependCourseTitle tid ssh csh $ SomeMessage MsgCommTutorialHeading
, crUltDest = SomeRoute $ CTutorialR tid ssh csh tutn TCommR
, crJobs = \Communication{..} -> do
let jSubject = cSubject
jMailContent = cBody
jCourse = cid
allRecipients = Set.toList $ Set.insert (Right jSender) cRecipients
jMailObjectUUID <- liftIO getRandom
jAllRecipientAddresses <- lift . fmap Set.fromList . forM allRecipients $ \case
Left email -> return . Address Nothing $ CI.original email
Right rid -> userAddress <$> getJust rid
forM_ allRecipients $ \jRecipientEmail ->
yield JobSendCourseCommunication{..}
, crRecipients = Map.fromList
[ ( RGTutorialParticipants
, E.from $ \(user `E.InnerJoin` participant) -> do
E.on $ user E.^. UserId E.==. participant E.^. TutorialParticipantUser
E.where_ $ participant E.^. TutorialParticipantTutorial E.==. E.val tutid
return user
)
, ( RGCourseLecturers
, E.from $ \(user `E.InnerJoin` lecturer) -> do
E.on $ user E.^. UserId E.==. lecturer E.^. LecturerUser
E.where_ $ lecturer E.^. LecturerCourse E.==. E.val cid
return user
)
, ( RGCourseCorrectors
, E.from $ \user -> do
E.where_ $ E.exists $ E.from $ \(sheet `E.InnerJoin` corrector) -> do
E.on $ sheet E.^. SheetId E.==. corrector E.^. SheetCorrectorSheet
E.where_ $ sheet E.^. SheetCourse E.==. E.val cid
E.&&. corrector E.^. SheetCorrectorUser E.==. user E.^. UserId
return user
)
, ( RGCourseTutors
, E.from $ \user -> do
E.where_ $ E.exists $ E.from $ \(tutorial `E.InnerJoin` tutor) -> do
E.on $ tutorial E.^. TutorialId E.==. tutor E.^. TutorTutorial
E.where_ $ tutorial E.^. TutorialCourse E.==. E.val cid
E.&&. tutor E.^. TutorUser E.==. user E.^. UserId
return user
)
]
, crRecipientAuth = Just $ \uid -> do
isTutorialUser <- E.selectExists . E.from $ \tutorialUser ->
E.where_ $ tutorialUser E.^. TutorialParticipantUser E.==. E.val uid
E.&&. tutorialUser E.^. TutorialParticipantTutorial E.==. E.val tutid
isAssociatedCorrector <- evalAccessForDB (Just uid) (CourseR tid ssh csh CNotesR) False
isAssociatedTutor <- evalAccessForDB (Just uid) (CourseR tid ssh csh CTutorialListR) False
mr <- getMsgRenderer
return $ if
| isTutorialUser -> Authorized
| otherwise -> orAR mr isAssociatedCorrector isAssociatedTutor
}
instance IsInvitableJunction Tutor where
type InvitationFor Tutor = Tutorial
data InvitableJunction Tutor = JunctionTutor
deriving (Eq, Ord, Read, Show, Generic, Typeable)
data InvitationDBData Tutor = InvDBDataTutor
deriving (Eq, Ord, Read, Show, Generic, Typeable)
data InvitationTokenData Tutor = InvTokenDataTutor
deriving (Eq, Ord, Read, Show, Generic, Typeable)
_InvitableJunction = iso
(\Tutor{..} -> (tutorUser, tutorTutorial, JunctionTutor))
(\(tutorUser, tutorTutorial, JunctionTutor) -> Tutor{..})
instance ToJSON (InvitableJunction Tutor) where
toJSON = genericToJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 1 }
toEncoding = genericToEncoding defaultOptions { fieldLabelModifier = camelToPathPiece' 1 }
instance FromJSON (InvitableJunction Tutor) where
parseJSON = genericParseJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 1 }
instance ToJSON (InvitationDBData Tutor) where
toJSON = genericToJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 4 }
toEncoding = genericToEncoding defaultOptions { fieldLabelModifier = camelToPathPiece' 4 }
instance FromJSON (InvitationDBData Tutor) where
parseJSON = genericParseJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 4 }
instance ToJSON (InvitationTokenData Tutor) where
toJSON = genericToJSON defaultOptions { constructorTagModifier = camelToPathPiece' 4 }
toEncoding = genericToEncoding defaultOptions { constructorTagModifier = camelToPathPiece' 4 }
instance FromJSON (InvitationTokenData Tutor) where
parseJSON = genericParseJSON defaultOptions { constructorTagModifier = camelToPathPiece' 4 }
tutorInvitationConfig :: InvitationConfig Tutor
tutorInvitationConfig = InvitationConfig{..}
where
invitationRoute (Entity _ Tutorial{..}) _ = do
Course{..} <- get404 tutorialCourse
return $ CTutorialR courseTerm courseSchool courseShorthand tutorialName TInviteR
invitationResolveFor _ = do
cRoute <- getCurrentRoute
case cRoute of
Just (CTutorialR tid csh ssh tutn TInviteR) ->
fetchTutorialId tid csh ssh tutn
_other ->
error "tutorInvitationConfig called from unsupported route"
invitationSubject (Entity _ Tutorial{..}) _ = do
Course{..} <- get404 tutorialCourse
return . SomeMessage $ MsgMailSubjectTutorInvitation courseTerm courseSchool courseShorthand tutorialName
invitationHeading (Entity _ Tutorial{..}) _ = return . SomeMessage $ MsgTutorInviteHeading tutorialName
invitationExplanation _ _ = return [ihamlet|_{SomeMessage MsgTutorInviteExplanation}|]
invitationTokenConfig _ _ = do
itAuthority <- liftHandler requireAuthId
return $ InvitationTokenConfig itAuthority Nothing Nothing Nothing
invitationRestriction _ _ = return Authorized
invitationForm _ _ _ = pure (JunctionTutor, ())
invitationInsertHook _ _ _ _ = id
invitationSuccessMsg (Entity _ Tutorial{..}) _ = return . SomeMessage $ MsgCorrectorInvitationAccepted tutorialName
invitationUltDest (Entity _ Tutorial{..}) _ = do
Course{..} <- get404 tutorialCourse
return . SomeRoute $ CourseR courseTerm courseSchool courseShorthand CTutorialListR
getTInviteR, postTInviteR :: TermId -> SchoolId -> CourseShorthand -> TutorialName -> Handler Html
getTInviteR = postTInviteR
postTInviteR = invitationR tutorInvitationConfig
data TutorialForm = TutorialForm
{ tfName :: TutorialName
, tfType :: CI Text
, tfCapacity :: Maybe Int
, tfRoom :: Text
, tfTime :: Occurrences
, tfRegGroup :: Maybe (CI Text)
, tfRegisterFrom :: Maybe UTCTime
, tfRegisterTo :: Maybe UTCTime
, tfDeregisterUntil :: Maybe UTCTime
, tfTutors :: Set (Either UserEmail UserId)
}
tutorialForm :: CourseId -> Maybe TutorialForm -> Form TutorialForm
tutorialForm cid template html = do
MsgRenderer mr <- getMsgRenderer
cRoute <- fromMaybe (error "tutorialForm called from 404-Handler") <$> getCurrentRoute
uid <- liftHandler requireAuthId
let
tutorForm = Set.fromList <$> massInputAccumA miAdd' miCell' (\p -> Just . SomeRoute $ cRoute :#: p) miLayout' ("tutors" :: Text) (fslI MsgTutorialTutors & setTooltip MsgMassInputTip) True (Set.toList . tfTutors <$> template)
where
miAdd' :: (Text -> Text) -> FieldView UniWorX -> Form ([Either UserEmail UserId] -> FormResult [Either UserEmail UserId])
miAdd' nudge submitView csrf = do
(addRes, addView) <- mpreq (multiUserField False . Just $ tutUserSuggestions uid) ("" & addName (nudge "email")) Nothing
let
addRes'
| otherwise
= addRes <&> \newDat oldDat -> if
| existing <- newDat `Set.intersection` Set.fromList oldDat
, not $ Set.null existing
-> FormFailure [mr MsgTutorialTutorAlreadyAdded]
| otherwise
-> FormSuccess $ Set.toList newDat
return (addRes', $(widgetFile "tutorial/tutorMassInput/add"))
miCell' :: Either UserEmail UserId -> Widget
miCell' (Left email) =
$(widgetFile "tutorial/tutorMassInput/cellInvitation")
miCell' (Right userId) = do
User{..} <- liftHandler . runDB $ get404 userId
$(widgetFile "tutorial/tutorMassInput/cellKnown")
miLayout' :: MassInputLayout ListLength (Either UserEmail UserId) ()
miLayout' lLength _ cellWdgts delButtons addWdgts = $(widgetFile "tutorial/tutorMassInput/layout")
flip (renderAForm FormStandard) html $ TutorialForm
<$> areq (textField & cfStrip & cfCI) (fslpI MsgTutorialName (mr MsgTutorialName) & setTooltip MsgTutorialNameTip) (tfName <$> template)
<*> areq (textField & cfStrip & cfCI & addDatalist tutTypeDatalist) (fslpI MsgTutorialType $ mr MsgTutorialType) (tfType <$> template)
<*> aopt (natFieldI MsgTutorialCapacityNonPositive) (fslpI MsgTutorialCapacity (mr MsgTutorialCapacity) & setTooltip MsgTutorialCapacityTip) (tfCapacity <$> template)
<*> areq textField (fslpI MsgTutorialRoom $ mr MsgTutorialRoomPlaceholder) (tfRoom <$> template)
<*> occurrencesAForm ("occurrences" :: Text) (tfTime <$> template)
<*> aopt (textField & cfStrip & cfCI) (fslI MsgTutorialRegGroup & setTooltip MsgTutorialRegGroupTip) ((tfRegGroup <$> template) <|> Just (Just "tutorial"))
<*> aopt utcTimeField (fslpI MsgRegisterFrom (mr MsgDate)
& setTooltip MsgCourseRegisterFromTip
) (tfRegisterFrom <$> template)
<*> aopt utcTimeField (fslpI MsgRegisterTo (mr MsgDate)
& setTooltip MsgCourseRegisterToTip
) (tfRegisterTo <$> template)
<*> aopt utcTimeField (fslpI MsgDeRegUntil (mr MsgDate)
& setTooltip MsgCourseDeregisterUntilTip
) (tfDeregisterUntil <$> template)
<*> tutorForm
where
tutTypeDatalist :: HandlerFor UniWorX (OptionList (CI Text))
tutTypeDatalist = fmap (mkOptionList . map (\t -> Option (CI.original t) t (CI.original t)) . Set.toAscList) . runDB $
fmap (setOf $ folded . _Value) . E.select . E.from $ \tutorial -> do
E.where_ $ tutorial E.^. TutorialCourse E.==. E.val cid
return $ tutorial E.^. TutorialType
tutUserSuggestions :: UserId -> E.SqlQuery (E.SqlExpr (Entity User))
tutUserSuggestions uid = E.from $ \(lecturer `E.InnerJoin` course `E.InnerJoin` tutorial `E.InnerJoin` tutor `E.InnerJoin` tutorUser) -> do
E.on $ tutorUser E.^. UserId E.==. tutor E.^. TutorUser
E.on $ tutor E.^. TutorTutorial E.==. tutorial E.^. TutorialId
E.on $ tutorial E.^. TutorialCourse E.==. course E.^. CourseId
E.on $ course E.^. CourseId E.==. lecturer E.^. LecturerCourse
E.where_ $ lecturer E.^. LecturerUser E.==. E.val uid
return tutorUser
getCTutorialNewR, postCTutorialNewR :: TermId -> SchoolId -> CourseShorthand -> Handler Html
getCTutorialNewR = postCTutorialNewR
postCTutorialNewR tid ssh csh = do
Entity cid Course{..} <- runDB . getBy404 $ TermSchoolCourseShort tid ssh csh
((newTutResult, newTutWidget), newTutEnctype) <- runFormPost $ tutorialForm cid Nothing
formResult newTutResult $ \TutorialForm{..} -> do
insertRes <- runDBJobs $ do
now <- liftIO getCurrentTime
insertRes <- insertUnique Tutorial
{ tutorialName = tfName
, tutorialCourse = cid
, tutorialType = tfType
, tutorialCapacity = tfCapacity
, tutorialRoom = tfRoom
, tutorialTime = tfTime
, tutorialRegGroup = tfRegGroup
, tutorialRegisterFrom = tfRegisterFrom
, tutorialRegisterTo = tfRegisterTo
, tutorialDeregisterUntil = tfDeregisterUntil
, tutorialLastChanged = now
}
whenIsJust insertRes $ \tutid -> do
let (invites, adds) = partitionEithers $ Set.toList tfTutors
insertMany_ $ map (Tutor tutid) adds
sinkInvitationsF tutorInvitationConfig $ map (, tutid, (InvDBDataTutor, InvTokenDataTutor)) invites
return insertRes
case insertRes of
Nothing -> addMessageI Error $ MsgTutorialNameTaken tfName
Just _ -> do
addMessageI Success $ MsgTutorialCreated tfName
redirect $ CourseR tid ssh csh CTutorialListR
let heading = prependCourseTitle tid ssh csh MsgTutorialNew
siteLayoutMsg heading $ do
setTitleI heading
let
newTutForm = wrapForm newTutWidget def
{ formMethod = POST
, formAction = Just . SomeRoute $ CourseR tid ssh csh CTutorialNewR
, formEncoding = newTutEnctype
}
$(widgetFile "tutorial-new")
getTEditR, postTEditR :: TermId -> SchoolId -> CourseShorthand -> TutorialName -> Handler Html
getTEditR = postTEditR
postTEditR tid ssh csh tutn = do
(cid, tutid, template) <- runDB $ do
(cid, Entity tutid Tutorial{..}) <- fetchCourseIdTutorial tid ssh csh tutn
tutorIds <- fmap (map E.unValue) . E.select . E.from $ \tutor -> do
E.where_ $ tutor E.^. TutorTutorial E.==. E.val tutid
return $ tutor E.^. TutorUser
tutorInvites <- sourceInvitationsF @Tutor tutid
let
template = TutorialForm
{ tfName = tutorialName
, tfType = tutorialType
, tfCapacity = tutorialCapacity
, tfRoom = tutorialRoom
, tfTime = tutorialTime
, tfRegGroup = tutorialRegGroup
, tfRegisterFrom = tutorialRegisterFrom
, tfRegisterTo = tutorialRegisterTo
, tfDeregisterUntil = tutorialDeregisterUntil
, tfTutors = Set.fromList (map Right tutorIds)
<> Set.mapMonotonic Left (Map.keysSet tutorInvites)
}
return (cid, tutid, template)
((newTutResult, newTutWidget), newTutEnctype) <- runFormPost . tutorialForm cid $ Just template
formResult newTutResult $ \TutorialForm{..} -> do
insertRes <- runDBJobs $ do
now <- liftIO getCurrentTime
insertRes <- myReplaceUnique tutid Tutorial
{ tutorialName = tfName
, tutorialCourse = cid
, tutorialType = tfType
, tutorialCapacity = tfCapacity
, tutorialRoom = tfRoom
, tutorialTime = tfTime
, tutorialRegGroup = tfRegGroup
, tutorialRegisterFrom = tfRegisterFrom
, tutorialRegisterTo = tfRegisterTo
, tutorialDeregisterUntil = tfDeregisterUntil
, tutorialLastChanged = now
}
when (is _Nothing insertRes) $ do
let (invites, adds) = partitionEithers $ Set.toList tfTutors
deleteWhere [ TutorTutorial ==. tutid ]
insertMany_ $ map (Tutor tutid) adds
deleteWhere [ InvitationFor ==. invRef @Tutor tutid, InvitationEmail /<-. invites ]
sinkInvitationsF tutorInvitationConfig $ map (, tutid, (InvDBDataTutor, InvTokenDataTutor)) invites
return insertRes
case insertRes of
Just _ -> addMessageI Error $ MsgTutorialNameTaken tfName
Nothing -> do
addMessageI Success $ MsgTutorialEdited tfName
redirect $ CourseR tid ssh csh CTutorialListR
let heading = prependCourseTitle tid ssh csh . MsgTutorialEditHeading $ tfName template
siteLayoutMsg heading $ do
setTitleI heading
let
newTutForm = wrapForm newTutWidget def
{ formMethod = POST
, formAction = Just . SomeRoute $ CTutorialR tid ssh csh tutn TEditR
, formEncoding = newTutEnctype
}
$(widgetFile "tutorial-edit")

View File

@ -0,0 +1,81 @@
module Handler.Tutorial.Communication
( getTCommR, postTCommR
) where
import Import
import Handler.Utils
import Handler.Utils.Tutorial
import Handler.Utils.Communication
import qualified Database.Esqueleto as E
import qualified Database.Esqueleto.Utils as E
import qualified Data.Map as Map
import qualified Data.Set as Set
import qualified Data.CaseInsensitive as CI
getTCommR, postTCommR :: TermId -> SchoolId -> CourseShorthand -> TutorialName -> Handler Html
getTCommR = postTCommR
postTCommR tid ssh csh tutn = do
jSender <- requireAuthId
(cid, tutid) <- runDB $ fetchCourseIdTutorialId tid ssh csh tutn
commR CommunicationRoute
{ crHeading = SomeMessage . prependCourseTitle tid ssh csh $ SomeMessage MsgCommTutorialHeading
, crUltDest = SomeRoute $ CTutorialR tid ssh csh tutn TCommR
, crJobs = \Communication{..} -> do
let jSubject = cSubject
jMailContent = cBody
jCourse = cid
allRecipients = Set.toList $ Set.insert (Right jSender) cRecipients
jMailObjectUUID <- liftIO getRandom
jAllRecipientAddresses <- lift . fmap Set.fromList . forM allRecipients $ \case
Left email -> return . Address Nothing $ CI.original email
Right rid -> userAddress <$> getJust rid
forM_ allRecipients $ \jRecipientEmail ->
yield JobSendCourseCommunication{..}
, crRecipients = Map.fromList
[ ( RGTutorialParticipants
, E.from $ \(user `E.InnerJoin` participant) -> do
E.on $ user E.^. UserId E.==. participant E.^. TutorialParticipantUser
E.where_ $ participant E.^. TutorialParticipantTutorial E.==. E.val tutid
return user
)
, ( RGCourseLecturers
, E.from $ \(user `E.InnerJoin` lecturer) -> do
E.on $ user E.^. UserId E.==. lecturer E.^. LecturerUser
E.where_ $ lecturer E.^. LecturerCourse E.==. E.val cid
return user
)
, ( RGCourseCorrectors
, E.from $ \user -> do
E.where_ $ E.exists $ E.from $ \(sheet `E.InnerJoin` corrector) -> do
E.on $ sheet E.^. SheetId E.==. corrector E.^. SheetCorrectorSheet
E.where_ $ sheet E.^. SheetCourse E.==. E.val cid
E.&&. corrector E.^. SheetCorrectorUser E.==. user E.^. UserId
return user
)
, ( RGCourseTutors
, E.from $ \user -> do
E.where_ $ E.exists $ E.from $ \(tutorial `E.InnerJoin` tutor) -> do
E.on $ tutorial E.^. TutorialId E.==. tutor E.^. TutorTutorial
E.where_ $ tutorial E.^. TutorialCourse E.==. E.val cid
E.&&. tutor E.^. TutorUser E.==. user E.^. UserId
return user
)
]
, crRecipientAuth = Just $ \uid -> do
isTutorialUser <- E.selectExists . E.from $ \tutorialUser ->
E.where_ $ tutorialUser E.^. TutorialParticipantUser E.==. E.val uid
E.&&. tutorialUser E.^. TutorialParticipantTutorial E.==. E.val tutid
isAssociatedCorrector <- evalAccessForDB (Just uid) (CourseR tid ssh csh CNotesR) False
isAssociatedTutor <- evalAccessForDB (Just uid) (CourseR tid ssh csh CTutorialListR) False
mr <- getMsgRenderer
return $ if
| isTutorialUser -> Authorized
| otherwise -> orAR mr isAssociatedCorrector isAssociatedTutor
}

View File

@ -0,0 +1,39 @@
module Handler.Tutorial.Delete
( getTDeleteR, postTDeleteR
) where
import Import
import Handler.Utils
import Handler.Utils.Tutorial
import Handler.Utils.Delete
import qualified Database.Esqueleto as E
import qualified Data.Set as Set
import qualified Data.CaseInsensitive as CI
getTDeleteR, postTDeleteR :: TermId -> SchoolId -> CourseShorthand -> TutorialName -> Handler Html
getTDeleteR = postTDeleteR
postTDeleteR tid ssh csh tutn = do
tutid <- runDB $ fetchTutorialId tid ssh csh tutn
deleteR DeleteRoute
{ drRecords = Set.singleton tutid
, drUnjoin = \(_ `E.InnerJoin` tutorial) -> tutorial
, drGetInfo = \(course `E.InnerJoin` tutorial) -> do
E.on $ course E.^. CourseId E.==. tutorial E.^. TutorialCourse
let participants = E.sub_select . E.from $ \participant -> do
E.where_ $ participant E.^. TutorialParticipantTutorial E.==. tutorial E.^. TutorialId
return E.countRows
return (course, tutorial, participants :: E.SqlExpr (E.Value Int))
, drRenderRecord = \(Entity _ Course{..}, Entity _ Tutorial{..}, E.Value ps) ->
return [whamlet|_{prependCourseTitle courseTerm courseSchool courseShorthand (CI.original tutorialName)} (_{MsgParticipantsN ps})|]
, drRecordConfirmString = \(Entity _ Course{..}, Entity _ Tutorial{..}, E.Value ps) ->
return [st|#{termToText (unTermKey courseTerm)}/#{unSchoolKey courseSchool}/#{courseShorthand}/#{tutorialName}+#{tshow ps}|]
, drCaption = SomeMessage MsgTutorialDeleteQuestion
, drSuccessMessage = SomeMessage MsgTutorialDeleted
, drAbort = SomeRoute $ CTutorialR tid ssh csh tutn TUsersR
, drSuccess = SomeRoute $ CourseR tid ssh csh CTutorialListR
, drDelete = const id -- TODO: audit
}

View File

@ -0,0 +1,92 @@
module Handler.Tutorial.Edit
( getTEditR, postTEditR
) where
import Import
import Handler.Utils
import Handler.Utils.Tutorial
import Handler.Utils.Invitations
import Jobs.Queue
import qualified Database.Esqueleto as E
import qualified Data.Map as Map
import qualified Data.Set as Set
import Handler.Tutorial.Form
import Handler.Tutorial.TutorInvite
getTEditR, postTEditR :: TermId -> SchoolId -> CourseShorthand -> TutorialName -> Handler Html
getTEditR = postTEditR
postTEditR tid ssh csh tutn = do
(cid, tutid, template) <- runDB $ do
(cid, Entity tutid Tutorial{..}) <- fetchCourseIdTutorial tid ssh csh tutn
tutorIds <- fmap (map E.unValue) . E.select . E.from $ \tutor -> do
E.where_ $ tutor E.^. TutorTutorial E.==. E.val tutid
return $ tutor E.^. TutorUser
tutorInvites <- sourceInvitationsF @Tutor tutid
let
template = TutorialForm
{ tfName = tutorialName
, tfType = tutorialType
, tfCapacity = tutorialCapacity
, tfRoom = tutorialRoom
, tfTime = tutorialTime
, tfRegGroup = tutorialRegGroup
, tfRegisterFrom = tutorialRegisterFrom
, tfRegisterTo = tutorialRegisterTo
, tfDeregisterUntil = tutorialDeregisterUntil
, tfTutors = Set.fromList (map Right tutorIds)
<> Set.mapMonotonic Left (Map.keysSet tutorInvites)
}
return (cid, tutid, template)
((newTutResult, newTutWidget), newTutEnctype) <- runFormPost . tutorialForm cid $ Just template
formResult newTutResult $ \TutorialForm{..} -> do
insertRes <- runDBJobs $ do
now <- liftIO getCurrentTime
insertRes <- myReplaceUnique tutid Tutorial
{ tutorialName = tfName
, tutorialCourse = cid
, tutorialType = tfType
, tutorialCapacity = tfCapacity
, tutorialRoom = tfRoom
, tutorialTime = tfTime
, tutorialRegGroup = tfRegGroup
, tutorialRegisterFrom = tfRegisterFrom
, tutorialRegisterTo = tfRegisterTo
, tutorialDeregisterUntil = tfDeregisterUntil
, tutorialLastChanged = now
}
when (is _Nothing insertRes) $ do
let (invites, adds) = partitionEithers $ Set.toList tfTutors
deleteWhere [ TutorTutorial ==. tutid ]
insertMany_ $ map (Tutor tutid) adds
deleteWhere [ InvitationFor ==. invRef @Tutor tutid, InvitationEmail /<-. invites ]
sinkInvitationsF tutorInvitationConfig $ map (, tutid, (InvDBDataTutor, InvTokenDataTutor)) invites
return insertRes
case insertRes of
Just _ -> addMessageI Error $ MsgTutorialNameTaken tfName
Nothing -> do
addMessageI Success $ MsgTutorialEdited tfName
redirect $ CourseR tid ssh csh CTutorialListR
let heading = prependCourseTitle tid ssh csh . MsgTutorialEditHeading $ tfName template
siteLayoutMsg heading $ do
setTitleI heading
let
newTutForm = wrapForm newTutWidget def
{ formMethod = POST
, formAction = Just . SomeRoute $ CTutorialR tid ssh csh tutn TEditR
, formEncoding = newTutEnctype
}
$(widgetFile "tutorial-edit")

View File

@ -0,0 +1,96 @@
module Handler.Tutorial.Form
( TutorialForm(..)
, tutorialForm
) where
import Import
import Handler.Utils
import Handler.Utils.Form.Occurrences
import qualified Database.Esqueleto as E
import Data.Map ((!))
import qualified Data.Set as Set
import qualified Data.CaseInsensitive as CI
data TutorialForm = TutorialForm
{ tfName :: TutorialName
, tfType :: CI Text
, tfCapacity :: Maybe Int
, tfRoom :: Text
, tfTime :: Occurrences
, tfRegGroup :: Maybe (CI Text)
, tfRegisterFrom :: Maybe UTCTime
, tfRegisterTo :: Maybe UTCTime
, tfDeregisterUntil :: Maybe UTCTime
, tfTutors :: Set (Either UserEmail UserId)
}
tutorialForm :: CourseId -> Maybe TutorialForm -> Form TutorialForm
tutorialForm cid template html = do
MsgRenderer mr <- getMsgRenderer
cRoute <- fromMaybe (error "tutorialForm called from 404-Handler") <$> getCurrentRoute
uid <- liftHandler requireAuthId
let
tutorForm = Set.fromList <$> massInputAccumA miAdd' miCell' (\p -> Just . SomeRoute $ cRoute :#: p) miLayout' ("tutors" :: Text) (fslI MsgTutorialTutors & setTooltip MsgMassInputTip) True (Set.toList . tfTutors <$> template)
where
miAdd' :: (Text -> Text) -> FieldView UniWorX -> Form ([Either UserEmail UserId] -> FormResult [Either UserEmail UserId])
miAdd' nudge submitView csrf = do
(addRes, addView) <- mpreq (multiUserField False . Just $ tutUserSuggestions uid) ("" & addName (nudge "email")) Nothing
let
addRes'
| otherwise
= addRes <&> \newDat oldDat -> if
| existing <- newDat `Set.intersection` Set.fromList oldDat
, not $ Set.null existing
-> FormFailure [mr MsgTutorialTutorAlreadyAdded]
| otherwise
-> FormSuccess $ Set.toList newDat
return (addRes', $(widgetFile "tutorial/tutorMassInput/add"))
miCell' :: Either UserEmail UserId -> Widget
miCell' (Left email) =
$(widgetFile "tutorial/tutorMassInput/cellInvitation")
miCell' (Right userId) = do
User{..} <- liftHandler . runDB $ get404 userId
$(widgetFile "tutorial/tutorMassInput/cellKnown")
miLayout' :: MassInputLayout ListLength (Either UserEmail UserId) ()
miLayout' lLength _ cellWdgts delButtons addWdgts = $(widgetFile "tutorial/tutorMassInput/layout")
flip (renderAForm FormStandard) html $ TutorialForm
<$> areq (textField & cfStrip & cfCI) (fslpI MsgTutorialName (mr MsgTutorialName) & setTooltip MsgTutorialNameTip) (tfName <$> template)
<*> areq (textField & cfStrip & cfCI & addDatalist tutTypeDatalist) (fslpI MsgTutorialType $ mr MsgTutorialType) (tfType <$> template)
<*> aopt (natFieldI MsgTutorialCapacityNonPositive) (fslpI MsgTutorialCapacity (mr MsgTutorialCapacity) & setTooltip MsgTutorialCapacityTip) (tfCapacity <$> template)
<*> areq textField (fslpI MsgTutorialRoom $ mr MsgTutorialRoomPlaceholder) (tfRoom <$> template)
<*> occurrencesAForm ("occurrences" :: Text) (tfTime <$> template)
<*> aopt (textField & cfStrip & cfCI) (fslI MsgTutorialRegGroup & setTooltip MsgTutorialRegGroupTip) ((tfRegGroup <$> template) <|> Just (Just "tutorial"))
<*> aopt utcTimeField (fslpI MsgRegisterFrom (mr MsgDate)
& setTooltip MsgCourseRegisterFromTip
) (tfRegisterFrom <$> template)
<*> aopt utcTimeField (fslpI MsgRegisterTo (mr MsgDate)
& setTooltip MsgCourseRegisterToTip
) (tfRegisterTo <$> template)
<*> aopt utcTimeField (fslpI MsgDeRegUntil (mr MsgDate)
& setTooltip MsgCourseDeregisterUntilTip
) (tfDeregisterUntil <$> template)
<*> tutorForm
where
tutTypeDatalist :: HandlerFor UniWorX (OptionList (CI Text))
tutTypeDatalist = fmap (mkOptionList . map (\t -> Option (CI.original t) t (CI.original t)) . Set.toAscList) . runDB $
fmap (setOf $ folded . _Value) . E.select . E.from $ \tutorial -> do
E.where_ $ tutorial E.^. TutorialCourse E.==. E.val cid
return $ tutorial E.^. TutorialType
tutUserSuggestions :: UserId -> E.SqlQuery (E.SqlExpr (Entity User))
tutUserSuggestions uid = E.from $ \(lecturer `E.InnerJoin` course `E.InnerJoin` tutorial `E.InnerJoin` tutor `E.InnerJoin` tutorUser) -> do
E.on $ tutorUser E.^. UserId E.==. tutor E.^. TutorUser
E.on $ tutor E.^. TutorTutorial E.==. tutorial E.^. TutorialId
E.on $ tutorial E.^. TutorialCourse E.==. course E.^. CourseId
E.on $ course E.^. CourseId E.==. lecturer E.^. LecturerCourse
E.where_ $ lecturer E.^. LecturerUser E.==. E.val uid
return tutorUser

View File

@ -0,0 +1,87 @@
module Handler.Tutorial.List
( getCTutorialListR
) where
import Import
import Handler.Utils
import qualified Database.Esqueleto as E
import Database.Esqueleto.Utils.TH
import qualified Data.Map as Map
import qualified Data.CaseInsensitive as CI
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
<ul .list--iconless .list--inline .list--comma-separated>
$forall tutor <- tutors
<li>
^{nameEmailWidget' tutor}
|]
, sortable (Just "participants") (i18nCell MsgTutorialParticipants) $ \DBRow{ dbrOutput = (Entity _ Tutorial{..}, n) } -> anchorCell (CTutorialR tid ssh csh tutorialName TUsersR) $ tshow n
, sortable (Just "capacity") (i18nCell MsgTutorialCapacity) $ \DBRow{ dbrOutput = (Entity _ Tutorial{..}, _) } -> maybe mempty (textCell . tshow) tutorialCapacity
, sortable (Just "room") (i18nCell MsgTutorialRoom) $ \DBRow{ dbrOutput = (Entity _ Tutorial{..}, _) } -> textCell tutorialRoom
, sortable Nothing (i18nCell MsgTutorialTime) $ \DBRow{ dbrOutput = (Entity _ Tutorial{..}, _) } -> occurrencesCell tutorialTime
, sortable (Just "register-group") (i18nCell MsgTutorialRegGroup) $ \DBRow{ dbrOutput = (Entity _ Tutorial{..}, _) } -> maybe mempty (textCell . CI.original) tutorialRegGroup
, sortable (Just "register-from") (i18nCell MsgTutorialRegisterFrom) $ \DBRow{ dbrOutput = (Entity _ Tutorial{..}, _) } -> maybeDateTimeCell tutorialRegisterFrom
, sortable (Just "register-to") (i18nCell MsgTutorialRegisterTo) $ \DBRow{ dbrOutput = (Entity _ Tutorial{..}, _) } -> maybeDateTimeCell tutorialRegisterTo
, sortable (Just "deregister-until") (i18nCell MsgTutorialDeregisterUntil) $ \DBRow{ dbrOutput = (Entity _ Tutorial{..}, _) } -> maybeDateTimeCell tutorialDeregisterUntil
, sortable Nothing mempty $ \DBRow{ dbrOutput = (Entity _ Tutorial{..}, _) } -> cell $ do
linkButton mempty [whamlet|_{MsgTutorialEdit}|] [BCIsButton] . SomeRoute $ CTutorialR tid ssh csh tutorialName TEditR
linkButton mempty [whamlet|_{MsgTutorialDelete}|] [BCIsButton, BCDanger] . SomeRoute $ CTutorialR tid ssh csh tutorialName TDeleteR
]
dbtSorting = Map.fromList
[ ("type", SortColumn $ \tutorial -> tutorial E.^. TutorialType )
, ("name", SortColumn $ \tutorial -> tutorial E.^. TutorialName )
, ("participants", SortColumn $ \tutorial -> 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))
)
, ("capacity", SortColumn $ \tutorial -> tutorial E.^. TutorialCapacity )
, ("room", SortColumn $ \tutorial -> tutorial E.^. TutorialRoom )
, ("register-group", SortColumn $ \tutorial -> tutorial E.^. TutorialRegGroup )
, ("register-from", SortColumn $ \tutorial -> tutorial E.^. TutorialRegisterFrom )
, ("register-to", SortColumn $ \tutorial -> tutorial E.^. TutorialRegisterTo )
, ("deregister-until", SortColumn $ \tutorial -> tutorial E.^. TutorialDeregisterUntil )
]
dbtFilter = Map.empty
dbtFilterUI = const mempty
dbtStyle = def
dbtParams = def
dbtIdent :: Text
dbtIdent = "tutorials"
dbtCsvEncode = noCsvEncode
dbtCsvDecode = Nothing
tutorialDBTableValidator = def
& defaultSorting [SortAscBy "type", SortAscBy "name"]
((), tutorialTable) <- runDB $ dbTable tutorialDBTableValidator tutorialDBTable
siteLayoutMsg (prependCourseTitle tid ssh csh MsgTutorialsHeading) $ do
setTitleI $ prependCourseTitle tid ssh csh MsgTutorialsHeading
$(widgetFile "tutorial-list")

View File

@ -0,0 +1,60 @@
module Handler.Tutorial.New
( getCTutorialNewR, postCTutorialNewR
) where
import Import
import Handler.Utils
import Handler.Utils.Invitations
import Jobs.Queue
import qualified Data.Set as Set
import Handler.Tutorial.Form
import Handler.Tutorial.TutorInvite
getCTutorialNewR, postCTutorialNewR :: TermId -> SchoolId -> CourseShorthand -> Handler Html
getCTutorialNewR = postCTutorialNewR
postCTutorialNewR tid ssh csh = do
Entity cid Course{..} <- runDB . getBy404 $ TermSchoolCourseShort tid ssh csh
((newTutResult, newTutWidget), newTutEnctype) <- runFormPost $ tutorialForm cid Nothing
formResult newTutResult $ \TutorialForm{..} -> do
insertRes <- runDBJobs $ do
now <- liftIO getCurrentTime
insertRes <- insertUnique Tutorial
{ tutorialName = tfName
, tutorialCourse = cid
, tutorialType = tfType
, tutorialCapacity = tfCapacity
, tutorialRoom = tfRoom
, tutorialTime = tfTime
, tutorialRegGroup = tfRegGroup
, tutorialRegisterFrom = tfRegisterFrom
, tutorialRegisterTo = tfRegisterTo
, tutorialDeregisterUntil = tfDeregisterUntil
, tutorialLastChanged = now
}
whenIsJust insertRes $ \tutid -> do
let (invites, adds) = partitionEithers $ Set.toList tfTutors
insertMany_ $ map (Tutor tutid) adds
sinkInvitationsF tutorInvitationConfig $ map (, tutid, (InvDBDataTutor, InvTokenDataTutor)) invites
return insertRes
case insertRes of
Nothing -> addMessageI Error $ MsgTutorialNameTaken tfName
Just _ -> do
addMessageI Success $ MsgTutorialCreated tfName
redirect $ CourseR tid ssh csh CTutorialListR
let heading = prependCourseTitle tid ssh csh MsgTutorialNew
siteLayoutMsg heading $ do
setTitleI heading
let
newTutForm = wrapForm newTutWidget def
{ formMethod = POST
, formAction = Just . SomeRoute $ CourseR tid ssh csh CTutorialNewR
, formEncoding = newTutEnctype
}
$(widgetFile "tutorial-new")

View File

@ -0,0 +1,28 @@
module Handler.Tutorial.Register
( postTRegisterR
) where
import Import
import Handler.Utils
import Handler.Utils.Tutorial
postTRegisterR :: TermId -> SchoolId -> CourseShorthand -> TutorialName -> Handler ()
postTRegisterR tid ssh csh tutn = do
uid <- requireAuthId
Entity tutid Tutorial{..} <- runDB $ fetchTutorial tid ssh csh tutn
((btnResult, _), _) <- runFormPost buttonForm
formResult btnResult $ \case
BtnRegister -> do
runDB . void . insert $ TutorialParticipant tutid uid
addMessageI Success $ MsgTutorialRegisteredSuccess tutorialName
redirect $ CourseR tid ssh csh CShowR
BtnDeregister -> do
runDB . deleteBy $ UniqueTutorialParticipant tutid uid
addMessageI Success $ MsgTutorialDeregisteredSuccess tutorialName
redirect $ CourseR tid ssh csh CShowR
invalidArgs ["Register/Deregister button required"]

View File

@ -0,0 +1,79 @@
{-# OPTIONS_GHC -fno-warn-orphans #-}
module Handler.Tutorial.TutorInvite
( getTInviteR, postTInviteR
, tutorInvitationConfig
, InvitableJunction(..), InvitationDBData(..), InvitationTokenData(..)
) where
import Import
import Handler.Utils.Tutorial
import Handler.Utils.Invitations
import Data.Aeson hiding (Result(..))
import Text.Hamlet (ihamlet)
instance IsInvitableJunction Tutor where
type InvitationFor Tutor = Tutorial
data InvitableJunction Tutor = JunctionTutor
deriving (Eq, Ord, Read, Show, Generic, Typeable)
data InvitationDBData Tutor = InvDBDataTutor
deriving (Eq, Ord, Read, Show, Generic, Typeable)
data InvitationTokenData Tutor = InvTokenDataTutor
deriving (Eq, Ord, Read, Show, Generic, Typeable)
_InvitableJunction = iso
(\Tutor{..} -> (tutorUser, tutorTutorial, JunctionTutor))
(\(tutorUser, tutorTutorial, JunctionTutor) -> Tutor{..})
instance ToJSON (InvitableJunction Tutor) where
toJSON = genericToJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 1 }
toEncoding = genericToEncoding defaultOptions { fieldLabelModifier = camelToPathPiece' 1 }
instance FromJSON (InvitableJunction Tutor) where
parseJSON = genericParseJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 1 }
instance ToJSON (InvitationDBData Tutor) where
toJSON = genericToJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 4 }
toEncoding = genericToEncoding defaultOptions { fieldLabelModifier = camelToPathPiece' 4 }
instance FromJSON (InvitationDBData Tutor) where
parseJSON = genericParseJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 4 }
instance ToJSON (InvitationTokenData Tutor) where
toJSON = genericToJSON defaultOptions { constructorTagModifier = camelToPathPiece' 4 }
toEncoding = genericToEncoding defaultOptions { constructorTagModifier = camelToPathPiece' 4 }
instance FromJSON (InvitationTokenData Tutor) where
parseJSON = genericParseJSON defaultOptions { constructorTagModifier = camelToPathPiece' 4 }
tutorInvitationConfig :: InvitationConfig Tutor
tutorInvitationConfig = InvitationConfig{..}
where
invitationRoute (Entity _ Tutorial{..}) _ = do
Course{..} <- get404 tutorialCourse
return $ CTutorialR courseTerm courseSchool courseShorthand tutorialName TInviteR
invitationResolveFor _ = do
cRoute <- getCurrentRoute
case cRoute of
Just (CTutorialR tid csh ssh tutn TInviteR) ->
fetchTutorialId tid csh ssh tutn
_other ->
error "tutorInvitationConfig called from unsupported route"
invitationSubject (Entity _ Tutorial{..}) _ = do
Course{..} <- get404 tutorialCourse
return . SomeMessage $ MsgMailSubjectTutorInvitation courseTerm courseSchool courseShorthand tutorialName
invitationHeading (Entity _ Tutorial{..}) _ = return . SomeMessage $ MsgTutorInviteHeading tutorialName
invitationExplanation _ _ = return [ihamlet|_{SomeMessage MsgTutorInviteExplanation}|]
invitationTokenConfig _ _ = do
itAuthority <- liftHandler requireAuthId
return $ InvitationTokenConfig itAuthority Nothing Nothing Nothing
invitationRestriction _ _ = return Authorized
invitationForm _ _ _ = pure (JunctionTutor, ())
invitationInsertHook _ _ _ _ = id
invitationSuccessMsg (Entity _ Tutorial{..}) _ = return . SomeMessage $ MsgCorrectorInvitationAccepted tutorialName
invitationUltDest (Entity _ Tutorial{..}) _ = do
Course{..} <- get404 tutorialCourse
return . SomeRoute $ CourseR courseTerm courseSchool courseShorthand CTutorialListR
getTInviteR, postTInviteR :: TermId -> SchoolId -> CourseShorthand -> TutorialName -> Handler Html
getTInviteR = postTInviteR
postTInviteR = invitationR tutorInvitationConfig

View File

@ -73,6 +73,7 @@ postAdminUserAddR = do
, userWarningDays = userDefaultWarningDays , userWarningDays = userDefaultWarningDays
, userNotificationSettings = def , userNotificationSettings = def
, userMailLanguages = def , userMailLanguages = def
, userCsvOptions = def
, userTokensIssuedAfter = Nothing , userTokensIssuedAfter = Nothing
, userCreated = now , userCreated = now
, userLastLdapSynchronisation = Nothing , userLastLdapSynchronisation = Nothing

View File

@ -150,9 +150,9 @@ commR CommunicationRoute{..} = do
-> Map (EnumPosition RecipientCategory, ListPosition) (FieldView UniWorX) -> Map (EnumPosition RecipientCategory, ListPosition) (FieldView UniWorX)
-> Map (Natural, (EnumPosition RecipientCategory, ListPosition)) Widget -> Map (Natural, (EnumPosition RecipientCategory, ListPosition)) Widget
-> Widget -> Widget
miLayout liveliness state cellWdgts _delButtons addWdgts = do miLayout liveliness cState cellWdgts _delButtons addWdgts = do
checkedIdentBase <- newIdent checkedIdentBase <- newIdent
let checkedCategories = Set.mapMonotonic (unEnumPosition . fst) . Set.filter (\k' -> Map.foldrWithKey (\k (_, checkState) -> (||) $ k == k' && checkState /= FormSuccess False && (checkState /= FormMissing || maybe True snd (chosenRecipients' !? k))) False state) $ Map.keysSet state let checkedCategories = Set.mapMonotonic (unEnumPosition . fst) . Set.filter (\k' -> Map.foldrWithKey (\k (_, checkState) -> (||) $ k == k' && checkState /= FormSuccess False && (checkState /= FormMissing || maybe True snd (chosenRecipients' !? k))) False cState) $ Map.keysSet cState
checkedIdent c = checkedIdentBase <> "-" <> toPathPiece c checkedIdent c = checkedIdentBase <> "-" <> toPathPiece c
hasContent c = not (null $ categoryIndices c) || Map.member (1, (EnumPosition c, 0)) addWdgts hasContent c = not (null $ categoryIndices c) || Map.member (1, (EnumPosition c, 0)) addWdgts
categoryIndices c = Set.filter ((== c) . unEnumPosition . fst) $ review liveCoords liveliness categoryIndices c = Set.filter ((== c) . unEnumPosition . fst) $ review liveCoords liveliness
@ -165,10 +165,13 @@ commR CommunicationRoute{..} = do
postProcess :: Map (EnumPosition RecipientCategory, ListPosition) (Either UserEmail UserId, Bool) -> Set (Either UserEmail UserId) postProcess :: Map (EnumPosition RecipientCategory, ListPosition) (Either UserEmail UserId, Bool) -> Set (Either UserEmail UserId)
postProcess = Set.fromList . map fst . filter snd . Map.elems postProcess = Set.fromList . map fst . filter snd . Map.elems
recipientsListMsg <- messageI Info MsgCommRecipientsList
((commRes,commWdgt),commEncoding) <- runFormPost . identifyForm FIDCommunication . renderAForm FormStandard $ Communication ((commRes,commWdgt),commEncoding) <- runFormPost . identifyForm FIDCommunication . renderAForm FormStandard $ Communication
<$> recipientAForm <$> recipientAForm
<* aformMessage recipientsListMsg
<*> aopt textField (fslI MsgCommSubject) Nothing <*> aopt textField (fslI MsgCommSubject) Nothing
<*> areq htmlField (fslpI MsgCommBody "Html") Nothing <*> areq htmlField (fslpI MsgCommBody "Html" & setTooltip MsgCommBodyTip) Nothing
formResult commRes $ \comm -> do formResult commRes $ \comm -> do
runDBJobs . runConduit $ transPipe (mapReaderT lift) (crJobs comm) .| sinkDBJobs runDBJobs . runConduit $ transPipe (mapReaderT lift) (crJobs comm) .| sinkDBJobs
addMessageI Success . MsgCommSuccess . Set.size $ cRecipients comm addMessageI Success . MsgCommSuccess . Set.size $ cRecipients comm
@ -183,4 +186,3 @@ commR CommunicationRoute{..} = do
siteLayoutMsg crHeading $ do siteLayoutMsg crHeading $ do
setTitleI crHeading setTitleI crHeading
formWdgt formWdgt
$(i18nWidgetFile "html-input")

View File

@ -1,8 +1,7 @@
{-# OPTIONS_GHC -fno-warn-orphans #-} {-# OPTIONS_GHC -fno-warn-orphans #-}
module Handler.Utils.Csv module Handler.Utils.Csv
( typeCsv, extensionCsv ( decodeCsv
, decodeCsv
, encodeCsv , encodeCsv
, encodeDefaultOrderedCsv , encodeDefaultOrderedCsv
, respondCsv, respondCsvDB , respondCsv, respondCsvDB
@ -12,9 +11,6 @@ module Handler.Utils.Csv
, ToNamedRecord(..), FromNamedRecord(..) , ToNamedRecord(..), FromNamedRecord(..)
, DefaultOrdered(..) , DefaultOrdered(..)
, ToField(..), FromField(..) , ToField(..), FromField(..)
, CsvRendered(..)
, toCsvRendered
, toDefaultOrderedCsvRendered
) where ) where
import Import hiding (Header, mapM_) import Import hiding (Header, mapM_)
@ -40,18 +36,6 @@ import qualified Data.ByteString.Lazy as LBS
import qualified Data.Attoparsec.ByteString.Lazy as A import qualified Data.Attoparsec.ByteString.Lazy as A
deriving instance Typeable CsvParseError
instance Exception CsvParseError
typeCsv, typeCsv' :: ContentType
typeCsv = simpleContentType typeCsv'
typeCsv' = "text/csv; charset=UTF-8; header=present"
extensionCsv :: Extension
extensionCsv = fromMaybe "csv" $ listToMaybe [ ext | (ext, mime) <- Map.toList mimeMap, mime == typeCsv ]
decodeCsv :: (MonadThrow m, FromNamedRecord csv, MonadLogger m) => ConduitT ByteString csv m () decodeCsv :: (MonadThrow m, FromNamedRecord csv, MonadLogger m) => ConduitT ByteString csv m ()
decodeCsv = transPipe throwExceptT $ do decodeCsv = transPipe throwExceptT $ do
testBuffer <- accumTestBuffer LBS.empty testBuffer <- accumTestBuffer LBS.empty
@ -114,19 +98,23 @@ decodeCsv = transPipe throwExceptT $ do
encodeCsv :: ( ToNamedRecord csv encodeCsv :: ( ToNamedRecord csv
, Monad m , MonadHandler m
, HandlerSite m ~ UniWorX
) )
=> Header => Header
-> ConduitT csv ByteString m () -> ConduitT csv ByteString m ()
-- ^ Encode a stream of records -- ^ Encode a stream of records
-- --
-- Currently not streaming -- Currently not streaming
encodeCsv hdr = fmap (encodeByName hdr) (C.foldMap pure) >>= C.sourceLazy encodeCsv hdr = do
csvOpts <- fmap (maybe def (userCsvOptions . entityVal)) . lift $ liftHandler maybeAuth
fmap (encodeByNameWith (csvOpts ^. _CsvEncodeOptions) hdr) (C.foldMap pure) >>= C.sourceLazy
encodeDefaultOrderedCsv :: forall csv m. encodeDefaultOrderedCsv :: forall csv m.
( ToNamedRecord csv ( ToNamedRecord csv
, DefaultOrdered csv , DefaultOrdered csv
, Monad m , MonadHandler m
, HandlerSite m ~ UniWorX
) )
=> ConduitT csv ByteString m () => ConduitT csv ByteString m ()
encodeDefaultOrderedCsv = encodeCsv $ headerOrder (error "headerOrder" :: csv) encodeDefaultOrderedCsv = encodeCsv $ headerOrder (error "headerOrder" :: csv)
@ -134,33 +122,30 @@ encodeDefaultOrderedCsv = encodeCsv $ headerOrder (error "headerOrder" :: csv)
respondCsv :: ToNamedRecord csv respondCsv :: ToNamedRecord csv
=> Header => Header
-> ConduitT () csv (HandlerFor site) () -> ConduitT () csv Handler ()
-> HandlerFor site TypedContent -> Handler TypedContent
respondCsv hdr src = respondSource typeCsv' $ src .| encodeCsv hdr .| awaitForever sendChunk respondCsv hdr src = respondSource typeCsv' $ src .| encodeCsv hdr .| awaitForever sendChunk
respondDefaultOrderedCsv :: forall csv site. respondDefaultOrderedCsv :: forall csv.
( ToNamedRecord csv ( ToNamedRecord csv
, DefaultOrdered csv , DefaultOrdered csv
) )
=> ConduitT () csv (HandlerFor site) () => ConduitT () csv Handler ()
-> HandlerFor site TypedContent -> Handler TypedContent
respondDefaultOrderedCsv = respondCsv $ headerOrder (error "headerOrder" :: csv) respondDefaultOrderedCsv = respondCsv $ headerOrder (error "headerOrder" :: csv)
respondCsvDB :: ( ToNamedRecord csv respondCsvDB :: ToNamedRecord csv
, YesodPersistRunner site
)
=> Header => Header
-> ConduitT () csv (YesodDB site) () -> ConduitT () csv DB ()
-> HandlerFor site TypedContent -> Handler TypedContent
respondCsvDB hdr src = respondSourceDB typeCsv' $ src .| encodeCsv hdr .| awaitForever sendChunk respondCsvDB hdr src = respondSourceDB typeCsv' $ src .| encodeCsv hdr .| awaitForever sendChunk
respondDefaultOrderedCsvDB :: forall csv site. respondDefaultOrderedCsvDB :: forall csv.
( ToNamedRecord csv ( ToNamedRecord csv
, DefaultOrdered csv , DefaultOrdered csv
, YesodPersistRunner site
) )
=> ConduitT () csv (YesodDB site) () => ConduitT () csv DB ()
-> HandlerFor site TypedContent -> Handler TypedContent
respondDefaultOrderedCsvDB = respondCsvDB $ headerOrder (error "headerOrder" :: csv) respondDefaultOrderedCsvDB = respondCsvDB $ headerOrder (error "headerOrder" :: csv)
fileSourceCsv :: ( FromNamedRecord csv fileSourceCsv :: ( FromNamedRecord csv
@ -173,11 +158,6 @@ fileSourceCsv :: ( FromNamedRecord csv
fileSourceCsv = (.| decodeCsv) . fileSource fileSourceCsv = (.| decodeCsv) . fileSource
data CsvRendered = CsvRendered
{ csvRenderedHeader :: Header
, csvRenderedData :: [NamedRecord]
} deriving (Eq, Read, Show, Generic, Typeable)
instance ToWidget UniWorX CsvRendered where instance ToWidget UniWorX CsvRendered where
toWidget CsvRendered{..} = liftWidget $(widgetFile "widgets/csvRendered") toWidget CsvRendered{..} = liftWidget $(widgetFile "widgets/csvRendered")
where where
@ -188,21 +168,3 @@ instance ToWidget UniWorX CsvRendered where
] ]
headers = decodeUtf8 <$> Vector.toList csvRenderedHeader headers = decodeUtf8 <$> Vector.toList csvRenderedHeader
toCsvRendered :: forall mono.
( ToNamedRecord (Element mono)
, MonoFoldable mono
)
=> Header
-> mono -> CsvRendered
toCsvRendered csvRenderedHeader (otoList -> csvs) = CsvRendered{..}
where
csvRenderedData = map toNamedRecord csvs
toDefaultOrderedCsvRendered :: forall mono.
( ToNamedRecord (Element mono)
, DefaultOrdered (Element mono)
, MonoFoldable mono
)
=> mono -> CsvRendered
toDefaultOrderedCsvRendered = toCsvRendered $ headerOrder (error "headerOrder" :: Element mono)

View File

@ -78,12 +78,12 @@ examBonus (Entity eId Exam{..}) = runConduit $
) )
return (examRegistration E.^. ExamRegistrationUser, sheet E.^. SheetType, submission) return (examRegistration E.^. ExamRegistrationUser, sheet E.^. SheetType, submission)
accum = C.fold ?? Map.empty $ \acc (E.Value uid, E.Value sheetType, fmap entityVal -> sub) -> accum = C.fold ?? Map.empty $ \acc (E.Value uid, E.Value sheetType, fmap entityVal -> sub) ->
Map.unionWith mappend acc . Map.singleton uid . sheetTypeSum sheetType . (>>= submissionRatingPoints) $ assertM submissionRatingDone sub flip (Map.insertWith mappend uid) acc . sheetTypeSum sheetType $ assertM submissionRatingDone sub >>= submissionRatingPoints
in rawData .| accum in rawData .| accum
examBonusPossible, examBonusAchieved :: UserId -> Map UserId SheetTypeSummary -> Maybe SheetGradeSummary examBonusPossible, examBonusAchieved :: UserId -> Map UserId SheetTypeSummary -> SheetGradeSummary
examBonusPossible uid bonusMap = normalSummary <$> Map.lookup uid bonusMap examBonusPossible uid bonusMap = normalSummary $ Map.findWithDefault mempty uid bonusMap
examBonusAchieved uid bonusMap = (mappend <$> normalSummary <*> bonusSummary) <$> Map.lookup uid bonusMap examBonusAchieved uid bonusMap = mappend <$> normalSummary <*> bonusSummary $ Map.findWithDefault mempty uid bonusMap
examResultBonus :: ExamBonusRule examResultBonus :: ExamBonusRule
@ -91,6 +91,8 @@ examResultBonus :: ExamBonusRule
-> SheetGradeSummary -- ^ `examBonusAchieved` -> SheetGradeSummary -- ^ `examBonusAchieved`
-> Points -> Points
examResultBonus bonusRule bonusPossible bonusAchieved = case bonusRule of examResultBonus bonusRule bonusPossible bonusAchieved = case bonusRule of
ExamBonusManual{}
-> 0
ExamBonusPoints{..} ExamBonusPoints{..}
-> roundToPoints bonusRound $ toRational bonusMaxPoints * bonusProp -> roundToPoints bonusRound $ toRational bonusMaxPoints * bonusProp
where where

View File

@ -12,6 +12,7 @@ import Handler.Utils.Form.Types
import Handler.Utils.DateTime import Handler.Utils.DateTime
import Import import Import
import Data.Char (chr, ord)
import qualified Data.Char as Char import qualified Data.Char as Char
import qualified Data.Text as Text import qualified Data.Text as Text
import qualified Data.CaseInsensitive as CI import qualified Data.CaseInsensitive as CI
@ -220,11 +221,7 @@ multiAction :: forall action a.
-> Maybe action -> Maybe action
-> (Html -> MForm Handler (FormResult a, [FieldView UniWorX])) -> (Html -> MForm Handler (FormResult a, [FieldView UniWorX]))
multiAction acts fs@FieldSettings{..} defAction csrf = do multiAction acts fs@FieldSettings{..} defAction csrf = do
mr <- getMessageRender (actionRes, actionView) <- mreq (selectField . optionsF $ Map.keysSet acts) fs defAction
let
options = OptionList [ Option (mr a) a (toPathPiece a) | a <- Map.keys acts ] fromPathPiece
(actionRes, actionView) <- mreq (selectField $ return options) fs defAction
results <- mapM (fmap (over _2 ($ [])) . aFormToForm) acts results <- mapM (fmap (over _2 ($ [])) . aFormToForm) acts
let actionResults = view _1 <$> results let actionResults = view _1 <$> results
@ -520,7 +517,8 @@ submissionModeForm prev = multiActionA actions (fslI MsgSheetSubmissionMode) $ c
) )
] ]
data ExamBonusRule' = ExamBonusPoints' data ExamBonusRule' = ExamBonusManual'
| ExamBonusPoints'
deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic, Typeable) deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic, Typeable)
instance Universe ExamBonusRule' instance Universe ExamBonusRule'
instance Finite ExamBonusRule' instance Finite ExamBonusRule'
@ -530,6 +528,7 @@ embedRenderMessage ''UniWorX ''ExamBonusRule' id
classifyBonusRule :: ExamBonusRule -> ExamBonusRule' classifyBonusRule :: ExamBonusRule -> ExamBonusRule'
classifyBonusRule = \case classifyBonusRule = \case
ExamBonusManual{} -> ExamBonusManual'
ExamBonusPoints{} -> ExamBonusPoints' ExamBonusPoints{} -> ExamBonusPoints'
examBonusRuleForm :: Maybe ExamBonusRule -> AForm Handler ExamBonusRule examBonusRuleForm :: Maybe ExamBonusRule -> AForm Handler ExamBonusRule
@ -537,7 +536,11 @@ examBonusRuleForm prev = multiActionA actions (fslI MsgExamBonusRule) $ classify
where where
actions :: Map ExamBonusRule' (AForm Handler ExamBonusRule) actions :: Map ExamBonusRule' (AForm Handler ExamBonusRule)
actions = Map.fromList actions = Map.fromList
[ ( ExamBonusPoints' [ ( ExamBonusManual'
, ExamBonusManual
<$> (fromMaybe False <$> aopt checkBoxField (fslI MsgExamBonusOnlyPassed) (Just <$> preview _bonusOnlyPassed =<< prev))
)
, ( ExamBonusPoints'
, ExamBonusPoints , ExamBonusPoints
<$> apreq (checkBool (> 0) MsgExamBonusMaxPointsNonPositive pointsField) (fslI MsgExamBonusMaxPoints & setTooltip MsgExamBonusMaxPointsTip) (preview _bonusMaxPoints =<< prev) <$> apreq (checkBool (> 0) MsgExamBonusMaxPointsNonPositive pointsField) (fslI MsgExamBonusMaxPoints & setTooltip MsgExamBonusMaxPointsTip) (preview _bonusMaxPoints =<< prev)
<*> (fromMaybe False <$> aopt checkBoxField (fslI MsgExamBonusOnlyPassed) (Just <$> preview _bonusOnlyPassed =<< prev)) <*> (fromMaybe False <$> aopt checkBoxField (fslI MsgExamBonusOnlyPassed) (Just <$> preview _bonusOnlyPassed =<< prev))
@ -1193,3 +1196,84 @@ examPassedField :: forall m.
) )
=> Field m ExamPassed => Field m ExamPassed
examPassedField = hoistField liftHandler $ selectField optionsFinite examPassedField = hoistField liftHandler $ selectField optionsFinite
data CsvOptions' = CsvOptionsPreset' CsvPreset
| CsvOptionsCustom'
deriving (Eq, Ord, Read, Show, Generic, Typeable)
deriveFinite ''CsvOptions'
instance PathPiece CsvOptions' where
toPathPiece = \case
CsvOptionsPreset' p -> toPathPiece p
CsvOptionsCustom' -> "custom"
fromPathPiece t = fromPathPiece t
<|> guardOn (t == "custom") CsvOptionsCustom'
instance RenderMessage UniWorX CsvOptions' where
renderMessage m ls = \case
CsvOptionsPreset' p -> mr p
CsvOptionsCustom' -> mr MsgCsvCustom
where
mr :: forall msg. RenderMessage UniWorX msg => msg -> Text
mr = renderMessage m ls
csvOptionsForm :: forall m.
( MonadHandler m
, HandlerSite m ~ UniWorX
)
=> FieldSettings UniWorX
-> Maybe CsvOptions
-> AForm m CsvOptions
csvOptionsForm fs mPrev = hoistAForm liftHandler . multiActionA csvActs fs $ classifyCsvOptions <$> mPrev
where
csvActs :: Map CsvOptions' (AForm Handler CsvOptions)
csvActs = mapF $ \case
CsvOptionsPreset' preset
-> pure $ csvPreset # preset
CsvOptionsCustom'
-> CsvOptions
<$> areq (selectField delimiterOpts) (fslI MsgCsvDelimiter) (csvDelimiter <$> mPrev)
<*> areq (selectField lineEndOpts) (fslI MsgCsvUseCrLf) (csvUseCrLf <$> mPrev)
<*> areq (selectField quoteOpts) (fslI MsgCsvQuoting & setTooltip MsgCsvQuotingTip) (csvQuoting <$> mPrev)
delimiterOpts :: Handler (OptionList Char)
delimiterOpts = do
MsgRenderer mr <- getMsgRenderer
let
opts =
[ (MsgCsvDelimiterNull, '\0')
, (MsgCsvDelimiterTab, '\t')
, (MsgCsvDelimiterComma, ',')
, (MsgCsvDelimiterColon, chr 58)
, (MsgCsvDelimiterBar, '|')
, (MsgCsvDelimiterSpace, ' ')
, (MsgCsvDelimiterUnitSep, chr 31)
]
olReadExternal t = do
i <- readMay t
guard $ i >= 0 && i <= 255
let c = chr i
guard $ any ((== c) . view _2) opts
return c
olOptions = [ Option (mr msg) c (tshow $ ord c)
| (msg, c) <- opts
]
return OptionList{..}
lineEndOpts :: Handler (OptionList Bool)
lineEndOpts = optionsPathPiece
[ (MsgCsvCrLf, True )
, (MsgCsvLf, False)
]
quoteOpts :: Handler (OptionList Quoting)
quoteOpts = optionsF
[ QuoteMinimal
, QuoteAll
]
classifyCsvOptions :: CsvOptions -> CsvOptions'
classifyCsvOptions opts
| Just preset <- opts ^? csvPreset
= CsvOptionsPreset' preset
| otherwise
= CsvOptionsCustom'

View File

@ -1,6 +1,6 @@
module Handler.Utils.Mail module Handler.Utils.Mail
( addRecipientsDB ( addRecipientsDB
, userAddress , userAddress, userAddressFrom
, userMailT , userMailT
, addFileDB , addFileDB
) where ) where
@ -28,7 +28,16 @@ addRecipientsDB uFilter = runConduit $ transPipe (liftHandler . runDB) (selectSo
let addr = Address (Just userDisplayName) $ CI.original userEmail let addr = Address (Just userDisplayName) $ CI.original userEmail
_mailTo %= flip snoc addr _mailTo %= flip snoc addr
userAddressFrom :: User -> Address
-- ^ Format an e-mail address suitable for usage in a @From@-header
--
-- Uses `userDisplayEmail`
userAddressFrom User{userDisplayEmail, userDisplayName} = Address (Just userDisplayName) $ CI.original userDisplayEmail
userAddress :: User -> Address userAddress :: User -> Address
-- ^ Format an e-mail address suitable for usage as a recipient
--
-- Uses `userEmail`
userAddress User{userEmail, userDisplayName} = Address (Just userDisplayName) $ CI.original userEmail userAddress User{userEmail, userDisplayName} = Address (Just userDisplayName) $ CI.original userEmail
userMailT :: ( MonadHandler m userMailT :: ( MonadHandler m

View File

@ -20,7 +20,6 @@ import Control.Monad.State.Class as State
import Control.Monad.Writer (MonadWriter(..), execWriterT, execWriter) import Control.Monad.Writer (MonadWriter(..), execWriterT, execWriter)
import Control.Monad.RWS.Lazy (MonadRWS, RWST, execRWST) import Control.Monad.RWS.Lazy (MonadRWS, RWST, execRWST)
import qualified Control.Monad.Random as Rand import qualified Control.Monad.Random as Rand
import qualified System.Random.Shuffle as Rand (shuffleM)
import Data.Maybe () import Data.Maybe ()
@ -248,9 +247,6 @@ planSubmissions sid restriction = do
maximumsBy :: (Ord a, Ord b) => (a -> b) -> Set a -> Set a maximumsBy :: (Ord a, Ord b) => (a -> b) -> Set a -> Set a
maximumsBy f xs = flip Set.filter xs $ \x -> maybe True (((==) `on` f) x . maximumBy (comparing f)) $ fromNullable xs maximumsBy f xs = flip Set.filter xs $ \x -> maybe True (((==) `on` f) x . maximumBy (comparing f)) $ fromNullable xs
unstableSortBy :: MonadRandom m => (a -> a -> Ordering) -> [a] -> m [a]
unstableSortBy cmp = fmap concat . mapM Rand.shuffleM . groupBy (\a b -> cmp a b == EQ) . sortBy cmp
submissionFileSource :: SubmissionId -> ConduitT () (Entity File) (YesodDB UniWorX) () submissionFileSource :: SubmissionId -> ConduitT () (Entity File) (YesodDB UniWorX) ()
submissionFileSource = E.selectSource . fmap snd . E.from . submissionFileQuery submissionFileSource = E.selectSource . fmap snd . E.from . submissionFileQuery

View File

@ -49,6 +49,7 @@ import Handler.Utils.Form
import Handler.Utils.Csv import Handler.Utils.Csv
import Handler.Utils.ContentDisposition import Handler.Utils.ContentDisposition
import Handler.Utils.I18n import Handler.Utils.I18n
import Handler.Utils.Widgets
import Utils import Utils
import Utils.Lens import Utils.Lens

View File

@ -96,3 +96,9 @@ editedByW fmt tm usr = do
heat :: Integral a => a -> a -> Double heat :: Integral a => a -> a -> Double
heat (toInteger -> full) (toInteger -> achieved) heat (toInteger -> full) (toInteger -> achieved)
= roundToDigits 3 $ cutOffPercent 0.3 (fromIntegral full^2) (fromIntegral achieved^2) = roundToDigits 3 $ cutOffPercent 0.3 (fromIntegral full^2) (fromIntegral achieved^2)
i18n :: forall m msg.
( MonadWidget m
, RenderMessage (HandlerSite m) msg
) => msg -> m ()
i18n = toWidget . (SomeMessage :: msg -> SomeMessage (HandlerSite m))

View File

@ -84,6 +84,14 @@ import Control.Monad.Trans.Reader as Import
( reader, Reader, runReader, mapReader, withReader ( reader, Reader, runReader, mapReader, withReader
, ReaderT(..), mapReaderT, withReaderT , ReaderT(..), mapReaderT, withReaderT
) )
import Control.Monad.Trans.State as Import
( state, State, runState, mapState, withState
, StateT(..), mapStateT, withStateT
)
import Control.Monad.Trans.Writer.Lazy as Import
( writer, Writer, runWriter, mapWriter, execWriter
, WriterT(..), mapWriterT, execWriterT
)
import Control.Monad.Base as Import import Control.Monad.Base as Import
import Control.Monad.Catch as Import hiding (Handler(..)) import Control.Monad.Catch as Import hiding (Handler(..))
import Control.Monad.Trans.Control as Import hiding (embed) import Control.Monad.Trans.Control as Import hiding (embed)

View File

@ -86,6 +86,7 @@ instance Exception JobQueueException
handleJobs :: ( MonadResource m handleJobs :: ( MonadResource m
, MonadLogger m , MonadLogger m
, MonadUnliftIO m , MonadUnliftIO m
, MonadMask m
) )
=> UniWorX -> m () => UniWorX -> m ()
-- | Spawn a set of workers that read control commands from `appJobCtl` and address them as they come in -- | Spawn a set of workers that read control commands from `appJobCtl` and address them as they come in
@ -97,7 +98,7 @@ handleJobs foundation@UniWorX{..}
| otherwise = do | otherwise = do
UnliftIO{..} <- askUnliftIO UnliftIO{..} <- askUnliftIO
jobPoolManager <- allocateLinkedAsync . unliftIO $ manageJobPool foundation jobPoolManager <- allocateLinkedAsyncWithUnmask $ \unmask -> unliftIO $ manageJobPool foundation unmask
jobCron <- allocateLinkedAsync . unliftIO $ manageCrontab foundation jobCron <- allocateLinkedAsync . unliftIO $ manageCrontab foundation
@ -129,20 +130,40 @@ manageJobPool :: forall m.
( MonadResource m ( MonadResource m
, MonadLogger m , MonadLogger m
, MonadUnliftIO m , MonadUnliftIO m
, MonadMask m
) )
=> UniWorX -> m () => UniWorX -> (forall a. IO a -> IO a) -> m ()
manageJobPool foundation@UniWorX{..} manageJobPool foundation@UniWorX{..} unmask = shutdownOnException $
= flip runContT return . forever . join . atomically $ asum flip runContT return . forever . join . atomically $ asum
[ spawnMissingWorkers [ spawnMissingWorkers
, reapDeadWorkers , reapDeadWorkers
, terminateGracefully , terminateGracefully
] ]
where where
shutdownOnException :: m a -> m a
shutdownOnException act = do
UnliftIO{..} <- askUnliftIO
actAsync <- allocateLinkedAsyncMasked $ unliftIO act
let handleExc e = do
atomically $ do
jState <- tryReadTMVar appJobState
for_ jState $ \JobState{jobShutdown} -> tryPutTMVar jobShutdown ()
void $ wait actAsync
throwM e
liftIO (unmask $ wait actAsync) `catchAll` handleExc
num :: Int num :: Int
num = fromIntegral $ foundation ^. _appJobWorkers num = fromIntegral $ foundation ^. _appJobWorkers
spawnMissingWorkers, reapDeadWorkers, terminateGracefully :: STM (ContT () m ()) spawnMissingWorkers, reapDeadWorkers, terminateGracefully :: STM (ContT () m ())
spawnMissingWorkers = do spawnMissingWorkers = do
shouldTerminate' <- readTMVar appJobState >>= fmap not . isEmptyTMVar . jobShutdown
guard $ not shouldTerminate'
oldState <- takeTMVar appJobState oldState <- takeTMVar appJobState
let missing = num - Map.size (jobWorkers oldState) let missing = num - Map.size (jobWorkers oldState)
guard $ missing > 0 guard $ missing > 0
@ -204,6 +225,10 @@ manageJobPool foundation@UniWorX{..}
terminateGracefully = do terminateGracefully = do
shouldTerminate <- readTMVar appJobState >>= fmap not . isEmptyTMVar . jobShutdown shouldTerminate <- readTMVar appJobState >>= fmap not . isEmptyTMVar . jobShutdown
guard shouldTerminate guard shouldTerminate
oldState <- takeTMVar appJobState
guard $ 0 == Map.size (jobWorkers oldState)
return . callCC $ \terminate -> do return . callCC $ \terminate -> do
$logInfoS "JobPoolManager" "Shutting down" $logInfoS "JobPoolManager" "Shutting down"
terminate () terminate ()

View File

@ -20,7 +20,7 @@ dispatchJobInvitation jInviter jInvitee jInvitationUrl jInvitationSubject jInvit
whenIsJust mInviter $ \jInviter' -> mailT def $ do whenIsJust mInviter $ \jInviter' -> mailT def $ do
_mailTo .= [Address Nothing $ CI.original jInvitee] _mailTo .= [Address Nothing $ CI.original jInvitee]
replaceMailHeader "Reply-To" . Just . renderAddress $ userAddress jInviter' replaceMailHeader "Reply-To" . Just . renderAddress $ userAddressFrom jInviter'
replaceMailHeader "Auto-Submitted" $ Just "auto-generated" replaceMailHeader "Auto-Submitted" $ Just "auto-generated"
replaceMailHeader "Subject" $ Just jInvitationSubject replaceMailHeader "Subject" $ Just jInvitationSubject
addPart ($(ihamletFile "templates/mail/invitation.hamlet") :: HtmlUrlI18n UniWorXMessage (Route UniWorX)) addPart ($(ihamletFile "templates/mail/invitation.hamlet") :: HtmlUrlI18n UniWorXMessage (Route UniWorX))

View File

@ -6,8 +6,6 @@ import Import
import Handler.Utils import Handler.Utils
import qualified Data.Set as Set
import qualified Data.CaseInsensitive as CI import qualified Data.CaseInsensitive as CI
@ -24,13 +22,15 @@ dispatchJobSendCourseCommunication jRecipientEmail jAllRecipientAddresses jCours
<$> getJust jSender <$> getJust jSender
<*> getJust jCourse <*> getJust jCourse
either (\email -> mailT def . (assign _mailTo (pure . Address Nothing $ CI.original email) *>)) userMailT jRecipientEmail $ do either (\email -> mailT def . (assign _mailTo (pure . Address Nothing $ CI.original email) *>)) userMailT jRecipientEmail $ do
MsgRenderer mr <- getMailMsgRenderer
void $ setMailObjectUUID jMailObjectUUID void $ setMailObjectUUID jMailObjectUUID
_mailFrom .= userAddress sender _mailFrom .= userAddressFrom sender
if -- Use `addMailHeader` instead of `_mailCc` to make `mailT` ignore the additional recipients addMailHeader "Cc" [st|#{mr MsgCommUndisclosedRecipients}:;|]
| jRecipientEmail == Right jSender
-> addMailHeader "Cc" . intercalate ", " . map renderAddress $ Set.toAscList (Set.delete (userAddress sender) jAllRecipientAddresses)
| otherwise
-> addMailHeader "Cc" "Undisclosed Recipients:;"
addMailHeader "Auto-Submitted" "no" addMailHeader "Auto-Submitted" "no"
setSubjectI . prependCourseTitle courseTerm courseSchool courseShorthand $ maybe (SomeMessage MsgCommCourseSubject) SomeMessage jSubject setSubjectI . prependCourseTitle courseTerm courseSchool courseShorthand $ maybe (SomeMessage MsgCommCourseSubject) SomeMessage jSubject
void $ addPart jMailContent void $ addPart jMailContent
when (jRecipientEmail == Right jSender) $
addPart' $ do
partIsAttachment $ unpack (mr MsgCommAllRecipients) `addExtension` unpack extensionCsv
toMailPart (toDefaultOrderedCsvRendered jAllRecipientAddresses, userCsvOptions sender)

View File

@ -23,7 +23,7 @@ dispatchNotificationSubmissionRated nSubmission jRecipient = userMailT jRecipien
return (course, sheet, submission, corrector) return (course, sheet, submission, corrector)
whenIsJust corrector $ \corrector' -> whenIsJust corrector $ \corrector' ->
addMailHeader "Reply-To" . renderAddress $ userAddress corrector' addMailHeader "Reply-To" . renderAddress $ userAddressFrom corrector'
replaceMailHeader "Auto-Submitted" $ Just "auto-generated" replaceMailHeader "Auto-Submitted" $ Just "auto-generated"
setSubjectI $ MsgMailSubjectSubmissionRated courseShorthand setSubjectI $ MsgMailSubjectSubmissionRated courseShorthand

View File

@ -21,7 +21,7 @@ module Mail
, PrioritisedAlternatives , PrioritisedAlternatives
, ToMailPart(..) , ToMailPart(..)
, addAlternatives, provideAlternative, providePreferredAlternative , addAlternatives, provideAlternative, providePreferredAlternative
, addPart , addPart, addPart', modifyPart, partIsAttachment
, MonadHeader(..) , MonadHeader(..)
, MailHeader , MailHeader
, MailObjectId , MailObjectId
@ -43,6 +43,8 @@ import Model.Types.TH.JSON
import Network.Mail.Mime hiding (addPart, addAttachment) import Network.Mail.Mime hiding (addPart, addAttachment)
import qualified Network.Mail.Mime as Mime (addPart) import qualified Network.Mail.Mime as Mime (addPart)
import Settings.Mime
import Data.Monoid (Last(..)) import Data.Monoid (Last(..))
import Control.Monad.Trans.RWS (RWST(..)) import Control.Monad.Trans.RWS (RWST(..))
import Control.Monad.Trans.State (StateT(..), execStateT, mapStateT) import Control.Monad.Trans.State (StateT(..), execStateT, mapStateT)
@ -71,6 +73,7 @@ import qualified Data.ByteString.Lazy as LBS
import Utils (MsgRendererS(..), MonadSecretBox(..), maybeT) import Utils (MsgRendererS(..), MonadSecretBox(..), maybeT)
import Utils.Lens.TH import Utils.Lens.TH
import Control.Lens hiding (from) import Control.Lens hiding (from)
import Control.Lens.Extras (is) import Control.Lens.Extras (is)
@ -336,7 +339,7 @@ instance YesodMail site => ToMailPart site (StateT Part (HandlerFor site) a) whe
instance YesodMail site => ToMailPart site LT.Text where instance YesodMail site => ToMailPart site LT.Text where
toMailPart text = do toMailPart text = do
_partType .= "text/plain; charset=utf-8" _partType .= decodeUtf8 typePlain
_partEncoding .= QuotedPrintableText _partEncoding .= QuotedPrintableText
_partContent .= encodeUtf8 text _partContent .= encodeUtf8 text
@ -348,7 +351,7 @@ instance YesodMail site => ToMailPart site LTB.Builder where
instance YesodMail site => ToMailPart site Html where instance YesodMail site => ToMailPart site Html where
toMailPart html = do toMailPart html = do
_partType .= "text/html; charset=utf-8" _partType .= decodeUtf8 typeHtml
_partEncoding .= QuotedPrintableText _partEncoding .= QuotedPrintableText
_partContent .= renderMarkup html _partContent .= renderMarkup html
@ -372,7 +375,7 @@ instance ToMailPart site a => ToMailPart site (Shakespeare.RenderUrl (Route site
instance YesodMail site => ToMailPart site Aeson.Value where instance YesodMail site => ToMailPart site Aeson.Value where
toMailPart val = do toMailPart val = do
_partType .= "application/json; charset=utf-8" _partType .= decodeUtf8 typeJson
_partEncoding .= QuotedPrintableText _partEncoding .= QuotedPrintableText
_partContent .= Aeson.encodePretty val _partContent .= Aeson.encodePretty val
@ -396,20 +399,35 @@ addPart :: ( MonadMail m
, HandlerSite m ~ site , HandlerSite m ~ site
, ToMailPart site a , ToMailPart site a
) => a -> m (MailPartReturn site a) ) => a -> m (MailPartReturn site a)
addPart part = do addPart = addPart' . toMailPart
(ret, part') <- runStateT (toMailPart part) initialPart
addPart' :: MonadMail m
=> StateT Part m a
-> m a
addPart' part = do
(ret, part') <- runStateT part initialPart
modify . Mime.addPart $ pure part' modify . Mime.addPart $ pure part'
return ret return ret
initialPart :: Part initialPart :: Part
initialPart = Part initialPart = Part
{ partType = "text/plain" { partType = decodeUtf8 defaultMimeType
, partEncoding = None , partEncoding = Base64
, partFilename = Nothing , partFilename = Nothing
, partHeaders = [] , partHeaders = []
, partContent = mempty , partContent = mempty
} }
modifyPart :: (MonadMail m, HandlerSite m ~ site, YesodMail site)
=> StateT Part (HandlerFor site) a
-> StateT Part m a
modifyPart = toMailPart
partIsAttachment :: (Textual t, MonadMail m, HandlerSite m ~ site, YesodMail site)
=> t
-> StateT Part m ()
partIsAttachment (repack -> fName) = modifyPart $ _partFilename .= Just fName
class MonadHandler m => MonadHeader m where class MonadHandler m => MonadHeader m where
modifyHeaders :: (Headers -> Headers) -> m () modifyHeaders :: (Headers -> Headers) -> m ()

View File

@ -13,6 +13,7 @@ module Model.Types.Exam
, ExamOccurrenceRule(..) , ExamOccurrenceRule(..)
, ExamGrade(..) , ExamGrade(..)
, numberGrade , numberGrade
, ExamGradeDefCenter(..)
, ExamGradingRule(..) , ExamGradingRule(..)
, ExamPassed(..) , ExamPassed(..)
, passingGrade , passingGrade
@ -116,7 +117,10 @@ instance Universe res => Universe (ExamResult' res) where
instance Finite res => Finite (ExamResult' res) instance Finite res => Finite (ExamResult' res)
data ExamBonusRule = ExamBonusPoints data ExamBonusRule = ExamBonusManual
{ bonusOnlyPassed :: Bool
}
| ExamBonusPoints
{ bonusMaxPoints :: Points { bonusMaxPoints :: Points
, bonusOnlyPassed :: Bool , bonusOnlyPassed :: Bool
, bonusRound :: Points , bonusRound :: Points
@ -215,6 +219,15 @@ instance PersistFieldSql ExamGrade where
sqlType _ = SqlNumeric 2 1 sqlType _ = SqlNumeric 2 1
newtype ExamGradeDefCenter = ExamGradeDefCenter { examGradeDefCenter :: Maybe ExamGrade }
deriving (Eq, Read, Show, Generic, Typeable)
instance Ord ExamGradeDefCenter where
ExamGradeDefCenter Nothing <= ExamGradeDefCenter (Just g) = Grade23 <= g
ExamGradeDefCenter (Just g) <= ExamGradeDefCenter Nothing = g <= Grade27
ExamGradeDefCenter g <= ExamGradeDefCenter g' = g <= g'
data ExamGradingRule data ExamGradingRule
= ExamGradingKey = ExamGradingKey
{ examGradingKey :: [Points] -- ^ @[n1, n2, n3, ..., n11]@ means @0 <= p < n1 -> p ~= 5@, @n1 <= p < n2 -> p ~ 4@, @n2 <= p < n3 -> p ~ 3.7@, ..., @n10 <= p -> p ~ 1.0@ { examGradingKey :: [Points] -- ^ @[n1, n2, n3, ..., n11]@ means @0 <= p < n1 -> p ~= 5@, @n1 <= p < n2 -> p ~ 4@, @n2 <= p < n3 -> p ~ 3.7@, ..., @n10 <= p -> p ~ 1.0@

View File

@ -1,3 +1,5 @@
{-# OPTIONS_GHC -fno-warn-orphans #-}
{-| {-|
Module: Model.Types.Misc Module: Model.Types.Misc
Description: Additional uncategorized types Description: Additional uncategorized types
@ -5,6 +7,7 @@ Description: Additional uncategorized types
module Model.Types.Misc module Model.Types.Misc
( module Model.Types.Misc ( module Model.Types.Misc
, Quoting(..)
) where ) where
import Import.NoModel import Import.NoModel
@ -14,6 +17,11 @@ import Data.Maybe (fromJust)
import qualified Data.Text as Text import qualified Data.Text as Text
import qualified Data.Text.Lens as Text import qualified Data.Text.Lens as Text
import Data.Csv (Quoting(..))
import qualified Data.Csv as Csv
import qualified Data.Aeson as JSON
data StudyFieldType = FieldPrimary | FieldSecondary data StudyFieldType = FieldPrimary | FieldSecondary
deriving (Eq, Ord, Enum, Show, Read, Bounded, Generic) deriving (Eq, Ord, Enum, Show, Read, Bounded, Generic)
@ -43,3 +51,83 @@ nullaryPathPiece ''Theme $ camelToPathPiece' 1
$(deriveSimpleWith ''ToMessage 'toMessage (over Text.packed $ Text.intercalate " " . unsafeTail . splitCamel) ''Theme) -- describe theme to user $(deriveSimpleWith ''ToMessage 'toMessage (over Text.packed $ Text.intercalate " " . unsafeTail . splitCamel) ''Theme) -- describe theme to user
derivePersistField "Theme" derivePersistField "Theme"
deriving instance Generic Quoting
deriving instance Ord Quoting
deriving instance Read Quoting
deriveJSON defaultOptions
{ constructorTagModifier = camelToPathPiece' 1
} ''Quoting
deriveFinite ''Quoting
nullaryPathPiece ''Quoting $ \q -> if
| q == "QuoteNone" -> "never"
| otherwise -> camelToPathPiece' 1 q
data CsvOptions
= CsvOptions
{ csvDelimiter :: Char
, csvUseCrLf :: Bool
, csvQuoting :: Csv.Quoting
}
deriving (Eq, Ord, Read, Show, Generic, Typeable)
instance Default CsvOptions where
def = csvPreset # CsvPresetRFC
data CsvPreset = CsvPresetRFC
| CsvPresetExcel
deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic, Typeable)
instance Universe CsvPreset
instance Finite CsvPreset
csvPreset :: Prism' CsvOptions CsvPreset
csvPreset = prism' fromPreset toPreset
where
fromPreset :: CsvPreset -> CsvOptions
fromPreset CsvPresetRFC = CsvOptions { csvDelimiter = ',', csvUseCrLf = True, csvQuoting = QuoteMinimal }
fromPreset CsvPresetExcel = CsvOptions { csvDelimiter = ';', csvUseCrLf = True, csvQuoting = QuoteAll }
toPreset :: CsvOptions -> Maybe CsvPreset
toPreset opts = case filter (\p -> fromPreset p == opts) universeF of
[p] -> Just p
_other -> Nothing
_CsvEncodeOptions :: Iso' CsvOptions Csv.EncodeOptions
_CsvEncodeOptions = iso toEncode fromEncode
where
toEncode CsvOptions{..} = Csv.defaultEncodeOptions
{ Csv.encDelimiter = fromIntegral $ fromEnum csvDelimiter
, Csv.encUseCrLf = csvUseCrLf
, Csv.encQuoting = csvQuoting
, Csv.encIncludeHeader = True
}
fromEncode encOpts = CsvOptions
{ csvDelimiter = toEnum . fromIntegral $ Csv.encDelimiter encOpts
, csvUseCrLf = Csv.encUseCrLf encOpts
, csvQuoting = Csv.encQuoting encOpts
}
instance ToJSON CsvOptions where
toJSON CsvOptions{..} = JSON.object
[ "delimiter" JSON..= fromEnum csvDelimiter
, "use-cr-lf" JSON..= csvUseCrLf
, "quoting" JSON..= csvQuoting
]
instance FromJSON CsvOptions where
parseJSON = JSON.withObject "CsvOptions" $ \o -> do
csvDelimiter <- fmap (fmap toEnum) (o JSON..:? "delimiter") JSON..!= csvDelimiter def
csvUseCrLf <- o JSON..:? "use-cr-lf" JSON..!= csvUseCrLf def
csvQuoting <- o JSON..:? "quoting" JSON..!= csvQuoting def
return CsvOptions{..}
derivePersistFieldJSON ''CsvOptions
nullaryPathPiece ''CsvPreset $ camelToPathPiece' 2
instance YesodMail site => ToMailPart site (CsvRendered, CsvOptions) where
toMailPart (CsvRendered{..}, encOpts) = do
_partType .= decodeUtf8 typeCsv'
_partEncoding .= QuotedPrintableText
_partContent .= Csv.encodeByNameWith (encOpts ^. _CsvEncodeOptions) csvRenderedHeader csvRenderedData
instance YesodMail site => ToMailPart site CsvRendered where
toMailPart = toMailPart . (, def :: CsvOptions)

View File

@ -15,6 +15,8 @@ import Utils.PathPiece
import Utils (assertM) import Utils (assertM)
import qualified Data.Csv as Csv
deriving instance Read Address deriving instance Read Address
deriving instance Ord Address deriving instance Ord Address
@ -32,3 +34,13 @@ instance FromJSON Address where
addressName <- assertM (not . null) <$> (obj .:? "name") addressName <- assertM (not . null) <$> (obj .:? "name")
addressEmail <- obj .: "email" addressEmail <- obj .: "email"
return Address{..} return Address{..}
instance Csv.ToNamedRecord Address where
toNamedRecord Address{..} = Csv.namedRecord
[ "name" Csv..= addressName
, "email" Csv..= addressEmail
]
instance Csv.DefaultOrdered Address where
headerOrder _ = Csv.header [ "name", "email" ]

View File

@ -9,6 +9,7 @@
module Settings module Settings
( module Settings ( module Settings
, module Settings.Cluster , module Settings.Cluster
, module Settings.Mime
) where ) where
import Import.NoModel import Import.NoModel
@ -58,6 +59,7 @@ import qualified Database.Memcached.Binary.Types as Memcached
import Model import Model
import Settings.Cluster import Settings.Cluster
import Settings.Mime
import Control.Monad.Trans.Maybe (MaybeT(..)) import Control.Monad.Trans.Maybe (MaybeT(..))
@ -67,10 +69,6 @@ import Jose.Jwt (JwtEncoding(..))
import System.FilePath.Glob import System.FilePath.Glob
import Handler.Utils.Submission.TH import Handler.Utils.Submission.TH
import Network.Mime.TH
import qualified Data.Map as Map
import qualified Data.Set as Set
-- | Runtime settings to configure this application. These settings can be -- | Runtime settings to configure this application. These settings can be
@ -458,18 +456,6 @@ widgetFileSettings = def
submissionBlacklist :: [Pattern] submissionBlacklist :: [Pattern]
submissionBlacklist = $(patternFile compDefault "config/submission-blacklist") submissionBlacklist = $(patternFile compDefault "config/submission-blacklist")
mimeMap :: MimeMap
mimeMap = $(mimeMapFile "config/mimetypes")
mimeLookup :: FileName -> MimeType
mimeLookup = mimeByExt mimeMap defaultMimeType
mimeExtensions :: MimeType -> Set Extension
mimeExtensions needle = Set.fromList [ ext | (ext, typ) <- Map.toList mimeMap, typ == needle ]
archiveTypes :: Set MimeType
archiveTypes = $(mimeSetFile "config/archive-types")
-- The rest of this file contains settings which rarely need changing by a -- The rest of this file contains settings which rarely need changing by a
-- user. -- user.

31
src/Settings/Mime.hs Normal file
View File

@ -0,0 +1,31 @@
module Settings.Mime
( mimeMap
, mimeLookup
, mimeExtensions
, archiveTypes
, module Network.Mime
) where
import ClassyPrelude
import qualified Data.Map as Map
import qualified Data.Set as Set
import Network.Mime
( FileName, MimeType, MimeMap, Extension
, mimeByExt, defaultMimeType
)
import Network.Mime.TH
mimeMap :: MimeMap
mimeMap = $(mimeMapFile "config/mimetypes")
mimeLookup :: FileName -> MimeType
mimeLookup = mimeByExt mimeMap defaultMimeType
mimeExtensions :: MimeType -> Set Extension
mimeExtensions needle = Set.fromList [ ext | (ext, typ) <- Map.toList mimeMap, typ == needle ]
archiveTypes :: Set MimeType
archiveTypes = $(mimeSetFile "config/archive-types")

View File

@ -1,5 +1,7 @@
module UnliftIO.Async.Utils module UnliftIO.Async.Utils
( allocateAsync, allocateLinkedAsync ( allocateAsync, allocateLinkedAsync
, allocateAsyncWithUnmask, allocateLinkedAsyncWithUnmask
, allocateAsyncMasked, allocateLinkedAsyncMasked
) where ) where
import ClassyPrelude hiding (cancel, async, link) import ClassyPrelude hiding (cancel, async, link)
@ -17,3 +19,21 @@ allocateAsync = fmap (view _2) . flip allocate cancel . liftIO . async
allocateLinkedAsync :: forall m a. (MonadUnliftIO m, MonadResource m) => IO a -> m (Async a) allocateLinkedAsync :: forall m a. (MonadUnliftIO m, MonadResource m) => IO a -> m (Async a)
allocateLinkedAsync = uncurry (<$) . (id &&& link) <=< allocateAsync allocateLinkedAsync = uncurry (<$) . (id &&& link) <=< allocateAsync
allocateAsyncWithUnmask :: forall m a.
MonadResource m
=> ((forall b. IO b -> IO b) -> IO a) -> m (Async a)
allocateAsyncWithUnmask act = fmap (view _2) . flip allocate cancel . liftIO $ asyncWithUnmask act
allocateLinkedAsyncWithUnmask :: forall m a. (MonadUnliftIO m, MonadResource m) => ((forall b. IO b -> IO b) -> IO a) -> m (Async a)
allocateLinkedAsyncWithUnmask act = uncurry (<$) . (id &&& link) =<< allocateAsyncWithUnmask act
allocateAsyncMasked :: forall m a.
MonadResource m
=> IO a -> m (Async a)
allocateAsyncMasked act = fmap (view _2) . flip allocate cancel . liftIO $ asyncWithUnmask (const act)
allocateLinkedAsyncMasked :: forall m a. (MonadUnliftIO m, MonadResource m) => IO a -> m (Async a)
allocateLinkedAsyncMasked = uncurry (<$) . (id &&& link) <=< allocateAsyncMasked

View File

@ -85,6 +85,9 @@ import Algebra.Lattice (top, bottom, (/\), (\/), BoundedJoinSemiLattice, Bounded
import Data.Constraint (Dict(..)) import Data.Constraint (Dict(..))
import Control.Monad.Random.Class (MonadRandom)
import qualified System.Random.Shuffle as Rand (shuffleM)
{-# ANN module ("HLint: ignore Use asum" :: String) #-} {-# ANN module ("HLint: ignore Use asum" :: String) #-}
@ -409,7 +412,8 @@ mapSymmDiff a b = Map.fromListWith Set.union . map (over _2 Set.singleton) . Set
assocsSet :: Ord (k, v) => Map k v -> Set (k, v) assocsSet :: Ord (k, v) => Map k v -> Set (k, v)
assocsSet = setOf folded . imap (,) assocsSet = setOf folded . imap (,)
mapF :: (Ord k, Finite k) => (k -> v) -> Map k v
mapF = flip Map.fromSet $ Set.fromList universeF
--------------- ---------------
-- Functions -- -- Functions --
@ -953,3 +957,16 @@ clampMin, clampMax :: Ord a
-> a -- ^ Clamped Value -> a -- ^ Clamped Value
clampMin = max clampMin = max
clampMax = min clampMax = min
------------
-- Random --
------------
unstableSortBy :: MonadRandom m => (a -> a -> Ordering) -> [a] -> m [a]
unstableSortBy cmp = fmap concat . mapM Rand.shuffleM . groupBy (\a b -> cmp a b == EQ) . sortBy cmp
unstableSortOn :: (MonadRandom m, Ord b) => (a -> b) -> [a] -> m [a]
unstableSortOn = unstableSortBy . comparing
unstableSort :: (MonadRandom m, Ord a) => [a] -> m [a]
unstableSort = unstableSortBy compare

View File

@ -1,14 +1,39 @@
{-# OPTIONS -fno-warn-orphans #-}
module Utils.Csv module Utils.Csv
( pathPieceCsv ( typeCsv, typeCsv', extensionCsv
, pathPieceCsv
, (.:??) , (.:??)
, CsvRendered(..)
, toCsvRendered
, toDefaultOrderedCsvRendered
) where ) where
import ClassyPrelude hiding (lookup) import ClassyPrelude hiding (lookup)
import Settings.Mime
import Data.Csv hiding (Name) import Data.Csv hiding (Name)
import Data.Csv.Conduit (CsvParseError)
import Language.Haskell.TH (Name) import Language.Haskell.TH (Name)
import Language.Haskell.TH.Lib import Language.Haskell.TH.Lib
import Yesod.Core.Content (ContentType, simpleContentType)
import qualified Data.Map as Map
deriving instance Typeable CsvParseError
instance Exception CsvParseError
typeCsv, typeCsv' :: ContentType
typeCsv = simpleContentType typeCsv'
typeCsv' = "text/csv; charset=UTF-8; header=present"
extensionCsv :: Extension
extensionCsv = fromMaybe "csv" $ listToMaybe [ ext | (ext, mime) <- Map.toList mimeMap, mime == typeCsv ]
pathPieceCsv :: Name -> DecsQ pathPieceCsv :: Name -> DecsQ
pathPieceCsv (conT -> t) = pathPieceCsv (conT -> t) =
@ -22,3 +47,27 @@ pathPieceCsv (conT -> t) =
(.:??) :: FromField (Maybe a) => NamedRecord -> ByteString -> Parser (Maybe a) (.:??) :: FromField (Maybe a) => NamedRecord -> ByteString -> Parser (Maybe a)
m .:?? name = lookup m name <|> return Nothing m .:?? name = lookup m name <|> return Nothing
data CsvRendered = CsvRendered
{ csvRenderedHeader :: Header
, csvRenderedData :: [NamedRecord]
} deriving (Eq, Read, Show, Generic, Typeable)
toCsvRendered :: forall mono.
( ToNamedRecord (Element mono)
, MonoFoldable mono
)
=> Header
-> mono -> CsvRendered
toCsvRendered csvRenderedHeader (otoList -> csvs) = CsvRendered{..}
where
csvRenderedData = map toNamedRecord csvs
toDefaultOrderedCsvRendered :: forall mono.
( ToNamedRecord (Element mono)
, DefaultOrdered (Element mono)
, MonoFoldable mono
)
=> mono -> CsvRendered
toDefaultOrderedCsvRendered = toCsvRendered $ headerOrder (error "headerOrder" :: Element mono)

View File

@ -328,7 +328,7 @@ combinedButtonField :: forall a m.
) => [a] -> FieldSettings (HandlerSite m) -> AForm m [Maybe a] ) => [a] -> FieldSettings (HandlerSite m) -> AForm m [Maybe a]
combinedButtonField bs FieldSettings{..} = formToAForm $ do combinedButtonField bs FieldSettings{..} = formToAForm $ do
mr <- getMessageRender mr <- getMessageRender
fvId <- maybe newFormIdent return fsId fvId <- maybe newIdent return fsId
name <- maybe newFormIdent return fsName name <- maybe newFormIdent return fsName
(ress, fvs) <- fmap unzip . for bs $ \b -> mopt (buttonField b) ("" { fsId = Just $ fvId <> "__" <> toPathPiece b (ress, fvs) <- fmap unzip . for bs $ \b -> mopt (buttonField b) ("" { fsId = Just $ fvId <> "__" <> toPathPiece b
, fsName = Just $ name <> "__" <> toPathPiece b , fsName = Just $ name <> "__" <> toPathPiece b
@ -491,6 +491,24 @@ reorderField optList = Field{..}
withNum t n = tshow n <> "." <> t withNum t n = tshow n <> "." <> t
$(widgetFile "widgets/permutation/permutation") $(widgetFile "widgets/permutation/permutation")
optionsPathPiece :: ( MonadHandler m
, HandlerSite m ~ site
, MonoFoldable mono
, Element mono ~ (msg, val)
, RenderMessage site msg
, PathPiece val
)
=> mono -> m (OptionList val)
optionsPathPiece (otoList -> opts) = do
mr <- getMessageRender
let
mkOption (m, a) = Option
{ optionDisplay = mr m
, optionInternalValue = a
, optionExternalValue = toPathPiece a
}
return . mkOptionList $ mkOption <$> opts
optionsF :: ( MonadHandler m optionsF :: ( MonadHandler m
, RenderMessage site (Element mono) , RenderMessage site (Element mono)
, HandlerSite m ~ site , HandlerSite m ~ site
@ -498,16 +516,7 @@ optionsF :: ( MonadHandler m
, MonoFoldable mono , MonoFoldable mono
) )
=> mono -> m (OptionList (Element mono)) => mono -> m (OptionList (Element mono))
optionsF (otoList -> opts) = do optionsF = optionsPathPiece . map (id &&& id) . otoList
mr <- getMessageRender
let
mkOption a = Option
{ optionDisplay = mr a
, optionInternalValue = a
, optionExternalValue = toPathPiece a
}
return . mkOptionList $ mkOption <$> opts
optionsFinite :: ( MonadHandler m optionsFinite :: ( MonadHandler m
, Finite a , Finite a

View File

@ -7,6 +7,7 @@ import ClassyPrelude.Yesod
import Database.PostgreSQL.Simple (SqlError(SqlError), sqlErrorHint) import Database.PostgreSQL.Simple (SqlError(SqlError), sqlErrorHint)
import Control.Monad.Catch (MonadMask) import Control.Monad.Catch (MonadMask)
import Database.Persist.Sql
import Database.Persist.Sql.Raw.QQ import Database.Persist.Sql.Raw.QQ
import Control.Retry import Control.Retry
@ -14,20 +15,22 @@ import Control.Retry
import Control.Lens ((&)) import Control.Lens ((&))
retryTransaction :: forall m a. (MonadLogger m, MonadMask m, MonadIO m) => m a -> m a setSerializable :: forall m a. (MonadLogger m, MonadMask m, MonadIO m) => ReaderT SqlBackend m a -> ReaderT SqlBackend m a
retryTransaction = recovering policy [logRetries suggestRetry logRetry] . const setSerializable act = recovering policy [logRetries suggestRetry logRetry] act'
where where
policy :: RetryPolicyM m policy :: RetryPolicyM (ReaderT SqlBackend m)
policy = fullJitterBackoff 1e3 & limitRetriesByCumulativeDelay 10e6 policy = fullJitterBackoff 1e3 & limitRetriesByCumulativeDelay 10e6
suggestRetry :: SqlError -> m Bool suggestRetry :: SqlError -> ReaderT SqlBackend m Bool
suggestRetry SqlError{sqlErrorHint} = return $ "The transaction might succeed if retried." `isInfixOf` sqlErrorHint suggestRetry SqlError{sqlErrorHint} = return $ "The transaction might succeed if retried." `isInfixOf` sqlErrorHint
logRetry :: Bool -- ^ Will retry logRetry :: Bool -- ^ Will retry
-> SqlError -> SqlError
-> RetryStatus -> RetryStatus
-> m () -> ReaderT SqlBackend m ()
logRetry shouldRetry err status = $logDebugS "Sql" . pack $ defaultLogMsg shouldRetry err status logRetry shouldRetry err status = $logDebugS "Sql" . pack $ defaultLogMsg shouldRetry err status
setSerializable :: (MonadLogger m, MonadMask m, MonadIO m) => ReaderT SqlBackend m a -> ReaderT SqlBackend m a act' :: RetryStatus -> ReaderT SqlBackend m a
setSerializable act = retryTransaction $ [executeQQ|SET TRANSACTION ISOLATION LEVEL SERIALIZABLE|] *> act act' RetryStatus{..}
| rsIterNumber == 0 = [executeQQ|SET TRANSACTION ISOLATION LEVEL SERIALIZABLE|] *> act
| otherwise = transactionUndoWithIsolation Serializable *> act

View File

@ -26,6 +26,8 @@ import Control.Monad.Trans.Reader (ReaderT, mapReaderT, runReaderT)
import Control.Monad.Base (MonadBase) import Control.Monad.Base (MonadBase)
import Control.Monad.Trans.Control (MonadBaseControl) import Control.Monad.Trans.Control (MonadBaseControl)
import Control.Monad.Catch (MonadMask, MonadCatch) import Control.Monad.Catch (MonadMask, MonadCatch)
import Control.Monad.Random.Class (MonadRandom)
import Control.Monad.Morph (MFunctor, MMonad)
deriving via (ReaderT (HandlerData site site) IO) instance MonadFix (HandlerFor site) deriving via (ReaderT (HandlerData site site) IO) instance MonadFix (HandlerFor site)
@ -40,6 +42,9 @@ deriving via (ReaderT (WidgetData site) IO) instance MonadMask (WidgetFor site)
deriving via (ReaderT (HandlerData site site) IO) instance MonadBase IO (HandlerFor site) deriving via (ReaderT (HandlerData site site) IO) instance MonadBase IO (HandlerFor site)
deriving via (ReaderT (WidgetData site) IO) instance MonadBase IO (WidgetFor site) deriving via (ReaderT (WidgetData site) IO) instance MonadBase IO (WidgetFor site)
deriving via (ReaderT (HandlerData site site) IO) instance MonadRandom (HandlerFor site)
deriving via (ReaderT (WidgetData site) IO) instance MonadRandom (WidgetFor site)
-- | Type-level tags for compatability of Yesod `cached`-System with `MonadMemo` -- | Type-level tags for compatability of Yesod `cached`-System with `MonadMemo`
newtype CachedMemoT k v m a = CachedMemoT { runCachedMemoT' :: ReaderT Loc m a } newtype CachedMemoT k v m a = CachedMemoT { runCachedMemoT' :: ReaderT Loc m a }
@ -48,6 +53,7 @@ newtype CachedMemoT k v m a = CachedMemoT { runCachedMemoT' :: ReaderT Loc m a }
, MonadThrow, MonadCatch, MonadMask, MonadLogger, MonadLoggerIO , MonadThrow, MonadCatch, MonadMask, MonadLogger, MonadLoggerIO
, MonadResource, MonadHandler, MonadWidget , MonadResource, MonadHandler, MonadWidget
) )
deriving newtype ( MFunctor, MMonad, MonadTrans )
deriving newtype instance MonadBase b m => MonadBase b (CachedMemoT k v m) deriving newtype instance MonadBase b m => MonadBase b (CachedMemoT k v m)
deriving newtype instance MonadBaseControl b m => MonadBaseControl b (CachedMemoT k v m) deriving newtype instance MonadBaseControl b m => MonadBaseControl b (CachedMemoT k v m)
@ -56,7 +62,8 @@ instance MonadReader r m => MonadReader r (CachedMemoT k v m) where
reader = CachedMemoT . lift . reader reader = CachedMemoT . lift . reader
local f (CachedMemoT act) = CachedMemoT $ mapReaderT (local f) act local f (CachedMemoT act) = CachedMemoT $ mapReaderT (local f) act
deriving via (ReaderT Loc) instance MonadTrans (CachedMemoT k v) instance MonadUnliftIO m => MonadUnliftIO (CachedMemoT k v m) where
askUnliftIO = (\UnliftIO{..} -> UnliftIO $ \(CachedMemoT f) -> unliftIO f) <$> CachedMemoT askUnliftIO
-- | Uses `cachedBy` with a `Binary`-encoded @k@ -- | Uses `cachedBy` with a `Binary`-encoded @k@
@ -69,3 +76,7 @@ runCachedMemoT :: Q Exp
runCachedMemoT = do runCachedMemoT = do
loc <- location loc <- location
[e| flip runReaderT loc . runCachedMemoT' |] [e| flip runReaderT loc . runCachedMemoT' |]
instance site ~ site' => ToWidget site (SomeMessage site') where
toWidget msg = toWidget =<< (getMessageRender <*> pure msg)

View File

@ -56,5 +56,7 @@ extra-deps:
- persistent-qq-2.9.1 - persistent-qq-2.9.1
- process-1.6.5.1
resolver: lts-13.21 resolver: lts-13.21
allow-newer: true allow-newer: true

View File

@ -1,22 +1,28 @@
$newline never $newline never
$if not (null allocationsBounds) $if not (null allocationsBounds)
<h2>_{MsgCourseAllocationsBounds (length allocationsBounds)} <section>
<dl .deflist> <h2>_{MsgCourseAllocationsBounds (length allocationsBounds)}
$forall (Allocation{allocationName, allocationRegisterTo}, numApps, numFirstChoice, capped) <- allocationsBounds <dl .deflist>
<dt .deflist__dt> $forall (Allocation{allocationName, allocationRegisterTo}, numApps, numFirstChoice, capped) <- allocationsBounds
#{allocationName} <dt .deflist__dt>
<dd .deflist__dd> #{allocationName}
<p> <dd .deflist__dd>
$if numApps == numFirstChoice <p>
_{MsgCourseAllocationsBoundCoincide numFirstChoice} $if numApps == numFirstChoice
$else _{MsgCourseAllocationsBoundCoincide numFirstChoice}
_{MsgCourseAllocationsBound numApps numFirstChoice} $else
$if capped _{MsgCourseAllocationsBound numApps numFirstChoice}
<p .bound_explanation> $if capped
_{MsgCourseAllocationsBoundCapped} <p .bound_explanation>
$if registrationOpen allocationRegisterTo _{MsgCourseAllocationsBoundCapped}
<p .bound_explanation> $if registrationOpen allocationRegisterTo
_{MsgCourseAllocationsBoundWarningOpen} <p .bound_explanation>
_{MsgCourseAllocationsBoundWarningOpen}
$if mayAccept
<section>
<p>_{MsgBtnAcceptApplicationsTip}
^{acceptWgt}
<h2>_{MsgMenuCourseApplications} <section>
^{table} <h2>_{MsgMenuCourseApplications}
^{table}

View File

@ -114,7 +114,7 @@ $if not (null occurrences)
<th .table__th>_{MsgExamRoomDescription} <th .table__th>_{MsgExamRoomDescription}
<tbody> <tbody>
$forall (Entity _occId ExamOccurrence{examOccurrenceName, examOccurrenceRoom, examOccurrenceStart, examOccurrenceEnd, examOccurrenceDescription}, registered) <- occurrences $forall (Entity _occId ExamOccurrence{examOccurrenceName, examOccurrenceRoom, examOccurrenceStart, examOccurrenceEnd, examOccurrenceDescription}, registered) <- occurrences
<tr .table__row :occurrenceAssignmentsShown && not registered:.occurrence--not-registered> <tr .table__row :occurrenceAssignmentsShown && (not registered && hasRegistration):.occurrence--not-registered>
$if occurrenceNamesShown $if occurrenceNamesShown
<td .table__td #exam-occurrence__#{examOccurrenceName}>#{examOccurrenceName} <td .table__td #exam-occurrence__#{examOccurrenceName}>#{examOccurrenceName}
$if occurrenceAssignmentsShown $if occurrenceAssignmentsShown

View File

@ -1,5 +1,20 @@
$newline never $newline never
<dl .deflist> <dl .deflist>
<dt .deflist__dt>
^{formatGregorianW 2019 09 27}
<dd .deflist__dd>
<ul>
<li>Automatische Anmeldung von Bewerbern in Kursen, die nicht an einer Zentralanmeldung teilnehmen (nach Bewertung der Bewerbung)
<dt .deflist__dt>
^{formatGregorianW 2019 09 25}
<dd .deflist__dd>
<ul>
<li>Automatische Berechnung von Prufüngsboni
<li>Automatische Berechnung von Prüfungsleistungen
<li><i>Bugfix</i>: Uhrzeiten werden beim Laden eines Formulars nichtmehr zurückgesetzt
<li><i>Bugfix</i>: Studierende tauchen in der Prüfungsleistungen-Tabelle nicht mehr mehrfach auf
<dt .deflist__dt> <dt .deflist__dt>
^{formatGregorianW 2019 09 16} ^{formatGregorianW 2019 09 16}
<dd .deflist__dd> <dd .deflist__dd>

View File

@ -1,12 +1,23 @@
$newline never
<h3>Hinweise zum Import von CSV-Dateien <h3>Hinweise zum Import von CSV-Dateien
<dl .deflist> <dl .deflist>
<dt .deflist__dt>Datenformat
<dd .deflist__dd>
Beim Import wird, pro Spalte, das selbe Datenformat erwartet, wie es beim #
Export produziert wird (siehe <i>Spalten- & Zellenformat</i> #
unter <i>CSV-Export</i>).<br />
Spalten können beliebig permutiert werden und dürfen auch fehlen (in #
diesem Fall wird die fehlende Spalte so behandelt als enthielte sie in #
jeder Zeile eine leere Zelle).<br />
Spalten werden an ihrer Überschrift identifiziert. #
Die Überschrift darf daher nicht verändert oder entfernt werden.
<dt .deflist__dt>Änderungen <dt .deflist__dt>Änderungen
<dd .deflist__dd> <dd .deflist__dd>
Einige Zellen können durch den Import verändert werden. Einige Zellen können durch den Import verändert werden.<br />
Nicht-änderbare Zellen werden ignoriert, falls diese verändert wurden. Nicht-änderbare Zellen werden ignoriert, falls diese verändert wurden.
<dt .deflist__dt>Vorschau <dt .deflist__dt>Vorschau
<dd .deflist__dd> <dd .deflist__dd>
Es wird eine Vorschau angezeigt, bevor irgendetwas tatsächlich geändert wird. Es wird eine Vorschau angezeigt, bevor irgendetwas tatsächlich geändert wird.<br />
In der Vorschau können dann auch nur teilweise Änderungen ausgewählt werden. In der Vorschau können dann auch nur teilweise Änderungen ausgewählt werden.
<dt .deflist__dt>Leere Zellen <dt .deflist__dt>Leere Zellen
<dd .deflist__dd> <dd .deflist__dd>
@ -16,22 +27,22 @@
<p> <p>
Es werden nur konsistente Änderungen akzeptiert! Es werden nur konsistente Änderungen akzeptiert!
<p> <p>
Daraus folgt, dass es sinnvoll sein kann, gewisse Zellen frei zu lassen; Daraus folgt, dass es sinnvoll sein kann, gewisse Zellen frei zu lassen; #
z.B. ändert man ein Studienfachzuordnung eines Teilnehmers ab, ändert man z.B. die Studienfachzuordnung eines Teilnehmers ab, #
dann müsste man auch Abschluss und Semesterzahl passend ändern. so müsste man auch Abschluss und Fachsemester passend ändern.<br />
Da diese jedoch eindeutig sind, kann man diese Zellen einfach frei lassen. Da diese jedoch eindeutig sind, kann man diese Zellen einfach frei lassen.
<dt .deflist__dt>Zeilen Identifikation <dt .deflist__dt>Zeilen Identifikation
<dd .deflist__dd> <dd .deflist__dd>
Mehrere Spalten werden zur Identifikation der Zeile verwendet. Mehrere Spalten werden zur Identifikation der Zeile verwendet.<br />
Es muss nicht in jeder Spalte der Zeile ein Wert vorhanden sein, Es muss nicht in jeder Spalte der Zeile ein Wert vorhanden sein, #
so lange die Identifikation noch eindeutig ist. so lange die Identifikation noch eindeutig ist.<br />
Sind mehrere Werte vorhanden, so müssen diese natürlich zueinander passen. Sind mehrere Werte vorhanden, so müssen diese natürlich zueinander passen.
<dt .deflist__dt>Zeilen hinzufügen <dt .deflist__dt>Zeilen hinzufügen
<dd .deflist__dd> <dd .deflist__dd>
Es können auch neue Zeilen hinzugefügt werden, so fern ausreichend Es können auch neue Zeilen hinzugefügt werden, sofern ausreichend #
eindeutige Informationen vorhanden sind; eindeutige Informationen vorhanden sind; #
z.B. können so Prüfungsteilnehmer nachgemeldet werden. z.B. können so Prüfungsteilnehmer nachgemeldet werden.
<dt .deflist__dt>Zeilen löschen <dt .deflist__dt>Zeilen löschen
<dd .deflist__dd> <dd .deflist__dd>
Fehlende Zeilen werden in der Vorschau zur Löschung angeboten Fehlende Zeilen werden in der Vorschau zur Löschung angeboten #
und dann ggf. gelöscht. und dann ggf. gelöscht.

View File

@ -1,5 +1,5 @@
<h2> <h2>
_{MsgCourseParticipantsAlreadyRegistered (length aurAlreadyRegistered)} _{MsgCourseParticipantsAlreadyRegistered (length aurAlreadyRegistered)}
<ul> <ul>
$forall email <- aurAlreadyRegistered $forall email <- aurAlreadyRegistered'
<li style="font-family: monospace">#{email} <li style="font-family: monospace">#{email}

View File

@ -1,5 +1,5 @@
<h2> <h2>
_{MsgCourseParticipantsRegisteredWithoutField (length aurNoUniquePrimaryField)} _{MsgCourseParticipantsRegisteredWithoutField (length aurNoUniquePrimaryField)}
<ul> <ul>
$forall email <- aurNoUniquePrimaryField $forall email <- aurNoUniquePrimaryField'
<li style="font-family: monospace">#{email} <li style="font-family: monospace">#{email}

View File

@ -14,5 +14,7 @@ $if is _Just dbtCsvEncode
<div .csv-export__content> <div .csv-export__content>
<p> <p>
^{csvColExplanations'} ^{csvColExplanations'}
<p>
^{modal (i18n MsgCsvChangeOptionsLabel) (Left (SomeRoute CsvOptionsR))}
^{csvExportWdgt'} ^{csvExportWdgt'}

View File

@ -1,5 +1,7 @@
$newline never $newline never
$case bonusRule $case bonusRule
$of ExamBonusManual _
_{MsgExamBonusManualParticipants}
$of ExamBonusPoints ps False _ $of ExamBonusPoints ps False _
_{MsgExamBonusPoints ps} _{MsgExamBonusPoints ps}
$of ExamBonusPoints ps True _ $of ExamBonusPoints ps True _

View File

@ -1,5 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
[[ -n "${FORCE_RELEASE}" ]] && exit 0
set -e set -e
[ "${FLOCKER}" != "$0" ] && exec env FLOCKER="$0" flock -en .stack-work.lock "$0" "$@" || : [ "${FLOCKER}" != "$0" ] && exec env FLOCKER="$0" flock -en .stack-work.lock "$0" "$@" || :

View File

@ -111,6 +111,7 @@ fillDb = do
, userNotificationSettings = def , userNotificationSettings = def
, userCreated = now , userCreated = now
, userLastLdapSynchronisation = Nothing , userLastLdapSynchronisation = Nothing
, userCsvOptions = csvPreset # CsvPresetRFC
} }
fhamann <- insert User fhamann <- insert User
{ userIdent = "felix.hamann@campus.lmu.de" { userIdent = "felix.hamann@campus.lmu.de"
@ -135,6 +136,7 @@ fillDb = do
, userNotificationSettings = def , userNotificationSettings = def
, userCreated = now , userCreated = now
, userLastLdapSynchronisation = Nothing , userLastLdapSynchronisation = Nothing
, userCsvOptions = csvPreset # CsvPresetExcel
} }
jost <- insert User jost <- insert User
{ userIdent = "jost@tcs.ifi.lmu.de" { userIdent = "jost@tcs.ifi.lmu.de"
@ -159,6 +161,7 @@ fillDb = do
, userNotificationSettings = def , userNotificationSettings = def
, userCreated = now , userCreated = now
, userLastLdapSynchronisation = Nothing , userLastLdapSynchronisation = Nothing
, userCsvOptions = def
} }
maxMuster <- insert User maxMuster <- insert User
{ userIdent = "max@campus.lmu.de" { userIdent = "max@campus.lmu.de"
@ -183,6 +186,7 @@ fillDb = do
, userNotificationSettings = def , userNotificationSettings = def
, userCreated = now , userCreated = now
, userLastLdapSynchronisation = Nothing , userLastLdapSynchronisation = Nothing
, userCsvOptions = def
} }
tinaTester <- insert $ User tinaTester <- insert $ User
{ userIdent = "tester@campus.lmu.de" { userIdent = "tester@campus.lmu.de"
@ -207,6 +211,7 @@ fillDb = do
, userNotificationSettings = def , userNotificationSettings = def
, userCreated = now , userCreated = now
, userLastLdapSynchronisation = Nothing , userLastLdapSynchronisation = Nothing
, userCsvOptions = def
} }
svaupel <- insert User svaupel <- insert User
{ userIdent = "vaupel.sarah@campus.lmu.de" { userIdent = "vaupel.sarah@campus.lmu.de"
@ -231,6 +236,7 @@ fillDb = do
, userNotificationSettings = def , userNotificationSettings = def
, userCreated = now , userCreated = now
, userLastLdapSynchronisation = Nothing , userLastLdapSynchronisation = Nothing
, userCsvOptions = def
} }
void . repsert (TermKey summer2017) $ Term void . repsert (TermKey summer2017) $ Term
{ termName = summer2017 { termName = summer2017

View File

@ -18,10 +18,11 @@ instance Arbitrary (Route Auth) where
instance Arbitrary (Route EmbeddedStatic) where instance Arbitrary (Route EmbeddedStatic) where
arbitrary = do arbitrary = do
let printableText = pack . filter (/= '/') . getPrintableString <$> arbitrary let printableText = pack . filter (/= '/') . getPrintableString <$> arbitrary
printableText' = printableText `suchThat` (not . null)
pathLength <- getPositive <$> arbitrary pathLength <- getPositive <$> arbitrary
path <- replicateM pathLength printableText path <- replicateM pathLength printableText'
paramNum <- getNonNegative <$> arbitrary paramNum <- getNonNegative <$> arbitrary
params <- replicateM paramNum $ (,) <$> printableText <*> printableText params <- replicateM paramNum $ (,) <$> printableText' <*> printableText
return $ embeddedResourceR path params return $ embeddedResourceR path params
instance Arbitrary SchoolR where instance Arbitrary SchoolR where

View File

@ -0,0 +1,12 @@
module Model.MigrationSpec where
import TestImport
import Model.Migration
spec :: Spec
spec = withApp $ -- `withApp` does migration, if needed
describe "Migration" $
it "is idempotent" $
(`shouldBe` False) <$> runDB requiresMigration -- Migration shouldn't be needed after `withApp` above

View File

@ -8,6 +8,7 @@ import Settings
import Control.Lens (review, preview) import Control.Lens (review, preview)
import Data.Aeson (Value) import Data.Aeson (Value)
import qualified Data.Aeson as Aeson import qualified Data.Aeson as Aeson
import qualified Data.Aeson.Types as Aeson
import MailSpec () import MailSpec ()
@ -32,6 +33,8 @@ import Data.Scientific
import Utils.Lens import Utils.Lens
import qualified Data.Char as Char
instance (Arbitrary a, MonoFoldable a) => Arbitrary (NonNull a) where instance (Arbitrary a, MonoFoldable a) => Arbitrary (NonNull a) where
arbitrary = arbitrary `suchThatMap` fromNullable arbitrary = arbitrary `suchThatMap` fromNullable
@ -250,6 +253,28 @@ instance Arbitrary ExamPassed where
arbitrary = genericArbitrary arbitrary = genericArbitrary
shrink = genericShrink shrink = genericShrink
instance Arbitrary Quoting where
arbitrary = genericArbitrary
shrink = genericShrink
instance Arbitrary CsvOptions where
arbitrary = CsvOptions
<$> suchThat arbitrary validDelimiter
<*> arbitrary
<*> arbitrary
where
validDelimiter c = and
[ Char.isLatin1 c
, c /= '"'
, c /= '\r'
, c /= '\n'
]
shrink = genericShrink
instance Arbitrary CsvPreset where
arbitrary = genericArbitrary
shrink = genericShrink
spec :: Spec spec :: Spec
spec = do spec = do
@ -334,6 +359,12 @@ spec = do
[ eqLaws, ordLaws, showReadLaws, jsonLaws, persistFieldLaws ] [ eqLaws, ordLaws, showReadLaws, jsonLaws, persistFieldLaws ]
lawsCheckHspec (Proxy @ExamPassed) lawsCheckHspec (Proxy @ExamPassed)
[ eqLaws, ordLaws, showReadLaws, finiteLaws, jsonLaws, pathPieceLaws, persistFieldLaws, csvFieldLaws ] [ eqLaws, ordLaws, showReadLaws, finiteLaws, jsonLaws, pathPieceLaws, persistFieldLaws, csvFieldLaws ]
lawsCheckHspec (Proxy @Quoting)
[ eqLaws, ordLaws, jsonLaws, showReadLaws, finiteLaws, pathPieceLaws ]
lawsCheckHspec (Proxy @CsvOptions)
[ eqLaws, ordLaws, showReadLaws, jsonLaws, persistFieldLaws ]
lawsCheckHspec (Proxy @CsvPreset)
[ eqLaws, ordLaws, showReadLaws, boundedEnumLaws, finiteLaws, pathPieceLaws ]
describe "TermIdentifier" $ do describe "TermIdentifier" $ do
it "has compatible encoding/decoding to/from Text" . property $ it "has compatible encoding/decoding to/from Text" . property $
@ -365,6 +396,9 @@ spec = do
parse "1.8" `shouldSatisfy` is _Left parse "1.8" `shouldSatisfy` is _Left
parse "voided" `shouldBe` Right ExamVoided parse "voided" `shouldBe` Right ExamVoided
parse "no-show" `shouldBe` Right ExamNoShow parse "no-show" `shouldBe` Right ExamNoShow
describe "CsvOptions" $
it "json-decodes from empty object" . example $
Aeson.parseMaybe Aeson.parseJSON (Aeson.object []) `shouldBe` Just (def :: CsvOptions)
termExample :: (TermIdentifier, Text) -> Expectation termExample :: (TermIdentifier, Text) -> Expectation
termExample (term, encoded) = example $ do termExample (term, encoded) = example $ do

View File

@ -22,10 +22,13 @@ import Utils
import System.FilePath import System.FilePath
import Data.Time import Data.Time
import Mail (MailLanguages(..))
instance Arbitrary EmailAddress where instance Arbitrary EmailAddress where
arbitrary = do arbitrary = do
local <- suchThat arbitrary (\l -> isEmail l (CBS.pack "example.com")) local <- suchThat (CBS.pack . getPrintableString <$> arbitrary) (\l -> isEmail l (CBS.pack "example.com"))
domain <- suchThat arbitrary (\d -> isEmail (CBS.pack "example") d) domain <- suchThat (CBS.pack . getPrintableString <$> arbitrary) (\d -> isEmail (CBS.pack "example") d)
let (Just result) = emailAddress (makeEmailLike local domain) let (Just result) = emailAddress (makeEmailLike local domain)
pure result pure result
@ -100,8 +103,9 @@ instance Arbitrary User where
userDownloadFiles <- arbitrary userDownloadFiles <- arbitrary
userWarningDays <- arbitrary userWarningDays <- arbitrary
userMailLanguages <- arbitrary userMailLanguages <- fmap MailLanguages $ sublistOf =<< shuffle (toList appLanguages)
userNotificationSettings <- arbitrary userNotificationSettings <- arbitrary
userCsvOptions <- arbitrary
userCreated <- arbitrary userCreated <- arbitrary
userLastLdapSynchronisation <- arbitrary userLastLdapSynchronisation <- arbitrary

View File

@ -140,6 +140,7 @@ createUser adjUser = do
userNotificationSettings = def userNotificationSettings = def
userCreated = now userCreated = now
userLastLdapSynchronisation = Nothing userLastLdapSynchronisation = Nothing
userCsvOptions = def
runDB . insertEntity $ adjUser User{..} runDB . insertEntity $ adjUser User{..}
lawsCheckHspec :: Typeable a => Proxy a -> [Proxy a -> Laws] -> Spec lawsCheckHspec :: Typeable a => Proxy a -> [Proxy a -> Laws] -> Spec