Merge branch 'master' into info-lecturer
This commit is contained in:
commit
2205180350
143
CHANGELOG.md
143
CHANGELOG.md
@ -2,6 +2,149 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
||||
|
||||
### [7.3.2](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/compare/v7.3.1...v7.3.2) (2019-10-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **exam-users:** make csv import much more lenient ([2ddb566](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/2ddb566))
|
||||
* **mail:** honor userCsvOptions and userDisplayEmail ([89adf7f](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/89adf7f))
|
||||
|
||||
|
||||
|
||||
### [7.3.1](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/compare/v7.3.0...v7.3.1) (2019-09-30)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **course-edit:** edit courses without being school-wide lecturer ([d7d1f27](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/d7d1f27)), closes [#464](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/issues/464)
|
||||
|
||||
|
||||
|
||||
## [7.3.0](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/compare/v7.2.2...v7.3.0) (2019-09-30)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **course-application:** better display of priorities ([64f7715](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/64f7715))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **csv:** allow customisation of csv-export-options ([95ceedd](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/95ceedd))
|
||||
|
||||
|
||||
|
||||
### [7.2.2](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/compare/v7.2.1...v7.2.2) (2019-09-30)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **authorisation:** keep showing allocations (ro) to lecturers ([c8e1d51](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/c8e1d51))
|
||||
|
||||
|
||||
|
||||
### [7.2.1](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/compare/v7.2.0...v7.2.1) (2019-09-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* fix build ([69f4a80](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/69f4a80))
|
||||
* fix tutorial registration group applying globally ([d2ba173](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/d2ba173))
|
||||
|
||||
|
||||
|
||||
## [7.2.0](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/compare/v7.1.2...v7.2.0) (2019-09-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* bump changelog ([60a7bb2](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/60a7bb2))
|
||||
* don't treat ExamBonusManual as override ([16abcd2](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/16abcd2))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **course-applications:** automatic acceptance of direct applicants ([620950d](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/620950d))
|
||||
|
||||
|
||||
|
||||
### [7.1.2](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/compare/v7.1.1...v7.1.2) (2019-09-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **exams:** include bonus points in sum for exam participants ([2bc6894](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/2bc6894))
|
||||
|
||||
|
||||
|
||||
### [7.1.1](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/compare/v7.1.0...v7.1.1) (2019-09-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* fix build ([d13ace4](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/d13ace4))
|
||||
|
||||
|
||||
|
||||
## [7.1.0](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/compare/v7.0.0...v7.1.0) (2019-09-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **datepicker:** select time from preselected date on edit ([d3375bb](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/d3375bb))
|
||||
* **jobs:** cleaner shutdown of job-pool-manager ([adc8d46](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/adc8d46))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **exams:** re-introduce ExamBonusManual ([54e94a6](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/54e94a6))
|
||||
|
||||
|
||||
|
||||
## [7.0.0](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/compare/v6.11.1...v7.0.0) (2019-09-25)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* fix startup on unix-socket ([39f1295](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/39f1295))
|
||||
* improve async behaviour ([cc7a528](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/cc7a528))
|
||||
* make migration idempotent again ([9778404](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/9778404))
|
||||
* restore behaviour of waiting asynchronously for job-management ([5ebcd89](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/5ebcd89))
|
||||
* **communication:** make communication form more intuitive ([7a2b972](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/7a2b972)), closes [#387](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/issues/387)
|
||||
* fix migration ([d2478a3](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/d2478a3))
|
||||
* fix migration & tests ([e05ea8e](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/e05ea8e))
|
||||
* migration ([4383eb1](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/4383eb1))
|
||||
* syntax ([7afd569](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/7afd569))
|
||||
* **migration:** drop more tables in w.a. for inconsistent 21→22 ([d79dca6](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/d79dca6))
|
||||
* typo ([fb1e42d](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/fb1e42d))
|
||||
|
||||
|
||||
### chore
|
||||
|
||||
* bump versions ([67e3b38](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/67e3b38))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **course:** additional crosslinking ([5eaba78](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/5eaba78))
|
||||
* **exam-users:** document part-* family of columns ([fe07a22](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/fe07a22))
|
||||
* **exams:** accept/reset computed results ([72342f1](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/72342f1))
|
||||
* **exams:** automatically compute examResults ([ea5a398](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/ea5a398))
|
||||
* **exams:** better display exam-result-information ([0ebda4d](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/0ebda4d))
|
||||
* **exams:** csv-import of ExamPartResults ([29f4e28](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/29f4e28))
|
||||
* **exams:** implement rounding of exambonus ([e97cd56](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/e97cd56))
|
||||
* **exams:** refine exam form ([014a17a](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/014a17a))
|
||||
|
||||
|
||||
### BREAKING CHANGES
|
||||
|
||||
* yesod >=1.6
|
||||
* **exams:** examPartName no longer required
|
||||
* **exams:** Introduces ExamPartNumbers
|
||||
|
||||
|
||||
|
||||
### [6.11.1](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/compare/v6.11.0...v6.11.1) (2019-09-17)
|
||||
|
||||
|
||||
|
||||
@ -67,7 +67,7 @@ update = do
|
||||
restartAppInNewThread tidStore = modifyStoredIORef tidStore $ \tid -> do
|
||||
killThread tid
|
||||
withStore doneStore takeMVar
|
||||
readStore doneStore >>= start
|
||||
withStore doneStore start
|
||||
|
||||
|
||||
-- | Start the server in a separate thread.
|
||||
@ -77,10 +77,7 @@ update = do
|
||||
(port, site, app) <- getApplicationRepl
|
||||
resourceForkIO $ do
|
||||
finally (liftIO $ runSettings (setPort port defaultSettings) app)
|
||||
-- Note that this implies concurrency
|
||||
-- between shutdownApp and the next app that is starting.
|
||||
-- Normally this should be fine
|
||||
(liftIO $ putMVar done () >> shutdownApp site)
|
||||
(liftIO $ shutdownApp site `finally` putMVar done ())
|
||||
|
||||
-- | kill the server
|
||||
shutdown :: IO ()
|
||||
|
||||
@ -8,3 +8,5 @@ log-settings:
|
||||
destination: "test.log"
|
||||
|
||||
auth-dummy-login: true
|
||||
|
||||
job-workers: 1
|
||||
|
||||
@ -24,17 +24,6 @@ const FORM_DATE_FORMAT_MOMENT = {
|
||||
'datetime-local': `${FORM_DATE_FORMAT_DATE_MOMENT} ${FORM_DATE_FORMAT_TIME_MOMENT}`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Takes a string representation of a date and a format string and parses the given date to a Date object.
|
||||
* If the date string is not valid (i.e. cannot be parsed with the given format string), returns undefined.
|
||||
* @param {*} dateStr string representation of a date
|
||||
* @param {*} dateFormat format string of the date
|
||||
*/
|
||||
function parseDateWithFormat(dateStr, dateFormat) {
|
||||
const parsedMomentDate = moment(dateStr, dateFormat);
|
||||
if (parsedMomentDate.isValid()) return parsedMomentDate.toDate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a string representation of a date, an input ('previous') format and a desired output format and returns a reformatted date string.
|
||||
* If the date string is not valid (i.e. cannot be parsed with the given input format string), returns the original date string;
|
||||
@ -136,6 +125,9 @@ export class Datepicker {
|
||||
throw new Error('Datepicker utility called on unsupported element!');
|
||||
}
|
||||
|
||||
// format any existing dates to fancy display format on pageload
|
||||
this.formatElementValue(true);
|
||||
|
||||
// initialize tail.datetime (datepicker) instance
|
||||
this.datepickerInstance = datetime(this._element, { ...datepickerGlobalConfig, ...datepickerConfig });
|
||||
|
||||
@ -187,9 +179,6 @@ export class Datepicker {
|
||||
|
||||
// format the date value of the form input element of this datepicker before form submission
|
||||
this._element.form.addEventListener('submit', () => this.formatElementValue());
|
||||
|
||||
// format any existing dates to fancy display format on pageload
|
||||
this.formatElementValue(true);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
@ -201,22 +190,21 @@ export class Datepicker {
|
||||
* @param {*} toFancy optional target format switch (boolean value; default is false). If set to a truthy value, formats the element value to fancy instead of internal date format.
|
||||
*/
|
||||
formatElementValue(toFancy) {
|
||||
const dp = this.datepickerInstance;
|
||||
if (this._element.value) {
|
||||
if (toFancy) {
|
||||
const parsedDate = parseDateWithFormat(this._element.value, FORM_DATE_FORMAT[this.elementType]);
|
||||
if (parsedDate) dp.selectDate();
|
||||
} else {
|
||||
this._element.value = this.unformat();
|
||||
}
|
||||
this._element.value = this.unformat(toFancy);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Returns a datestring in internal format from the current state of the input element value.
|
||||
* @param {*} toFancy Format date from internal to fancy or vice versa. When omitted, toFancy is falsy and results in fancy -> internal
|
||||
*/
|
||||
unformat() {
|
||||
return reformatDateString(this._element.value, FORM_DATE_FORMAT_MOMENT[this.elementType], FORM_DATE_FORMAT[this.elementType]);
|
||||
unformat(toFancy) {
|
||||
const formatIn = toFancy ? FORM_DATE_FORMAT[this.elementType] : FORM_DATE_FORMAT_MOMENT[this.elementType];
|
||||
const formatOut = toFancy ? FORM_DATE_FORMAT_MOMENT[this.elementType] : FORM_DATE_FORMAT[this.elementType];
|
||||
return reformatDateString(this._element.value, formatIn, formatOut);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -15,4 +15,4 @@ if [[ -d .stack-work-doc ]]; then
|
||||
trap move-back EXIT
|
||||
fi
|
||||
|
||||
stack build --fast --flag uniworx:library-only --flag uniworx:dev --haddock --haddock-hyperlink-source --haddock-deps --haddock-internal
|
||||
stack build --fast --flag uniworx:library-only --flag uniworx:dev --haddock --haddock-hyperlink-source --haddock-deps --haddock-internal ${@}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
[[ -n "${FORCE_RELEASE}" ]] && exit 0
|
||||
|
||||
set -e
|
||||
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
|
||||
@ -173,6 +173,7 @@ CourseApplicationTemplateApplication: Bewerbungsvorlage(n)
|
||||
CourseApplicationTemplateRegistration: Anmeldungsvorlage(n)
|
||||
CourseApplicationTemplateArchiveName tid@TermId ssh@SchoolId csh@CourseShorthand: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-bewerbungsvorlagen
|
||||
CourseApplication: Bewerbung
|
||||
CourseApplicationIsParticipant: Kursteilnehmer
|
||||
|
||||
CourseApplicationExists: Sie haben sich bereits für diesen Kurs beworben
|
||||
CourseApplicationInvalidAction: Angegeben Aktion kann nicht durchgeführt werden
|
||||
@ -1135,10 +1136,14 @@ NavigationFavourites: Favoriten
|
||||
|
||||
CommSubject: Betreff
|
||||
CommBody: Nachricht
|
||||
CommBodyTip: Das Eingabefeld akzeptiert derzeit ausschließlich Html. U.A. Zeilumbrüche werden dementsprechend ignoriert und müssen manuell mit <br> eingefügt werden.
|
||||
CommRecipients: Empfänger
|
||||
CommRecipientsTip: Sie selbst erhalten immer eine Kopie der Nachricht
|
||||
CommRecipientsList: Die an Sie selbst verschickte Kopie der Nachricht wird, zu Archivierungszwecken, eine vollständige Liste aller Empfänger enthalten. Die Empfängerliste wird im CSV-Format and die E-Mail angehängt. Andere Empfänger erhalten die Liste nicht. Bitte entfernen Sie dementsprechend den Anhang bevor Sie die E-Mail weiterleiten oder anderweitig mit Dritten teilen.
|
||||
CommDuplicateRecipients n@Int: #{n} #{pluralDE n "doppelter" "doppelte"} Empfänger ignoriert
|
||||
CommSuccess n@Int: Nachricht wurde an #{n} Empfänger versandt
|
||||
CommUndisclosedRecipients: Verborgene Empfänger
|
||||
CommAllRecipients: alle-empfaenger
|
||||
|
||||
CommCourseHeading: Kursmitteilung
|
||||
CommTutorialHeading: Tutorium-Mitteilung
|
||||
@ -1346,6 +1351,7 @@ ExamBonus: Bonuspunkte-System
|
||||
ExamBonusRule: Prüfungsbonus aus Übungsbetrieb
|
||||
ExamNoBonus': Kein automatischer Bonus
|
||||
ExamBonusPoints': Umrechnung von Übungspunkten
|
||||
ExamBonusManual': Manuelle Berechnung
|
||||
|
||||
ExamBonusAchieved: Bonuspunkte
|
||||
|
||||
@ -1415,6 +1421,7 @@ ExamEdited exam@ExamName: #{exam} erfolgreich bearbeitet
|
||||
ExamNoShow: Nicht erschienen
|
||||
ExamVoided: Entwertet
|
||||
|
||||
ExamBonusManualParticipants: Von den Kursverwaltern manuell berechnet
|
||||
ExamBonusPoints possible@Points: Maximal #{showFixed True possible} Prüfungspunkte
|
||||
ExamBonusPointsPassed possible@Points: Maximal #{showFixed True possible} Prüfungspunkte, falls die Prüfung auch ohne Bonus bereits bestanden ist
|
||||
|
||||
@ -1494,8 +1501,8 @@ Proportion c@Text of@Text prop@Rational: #{c}/#{of} (#{rationalToFixed2 (100 * p
|
||||
ExamUserCsvName tid@TermId ssh@SchoolId csh@CourseShorthand examn@ExamName: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-#{foldedCase examn}-teilnehmer
|
||||
CourseApplicationsTableCsvName tid@TermId ssh@SchoolId csh@CourseShorthand: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-bewerbungen
|
||||
|
||||
CsvColumnsExplanationsLabel: Spalten
|
||||
CsvColumnsExplanationsTip: Bedeutung der in der CSV-Datei enthaltenen Spalten
|
||||
CsvColumnsExplanationsLabel: Spalten- & Zellenformat
|
||||
CsvColumnsExplanationsTip: Bedeutung und Format der in der CSV-Datei enthaltenen Spalten
|
||||
CsvColumnExamUserSurname: Nachname(n) des Teilnehmers
|
||||
CsvColumnExamUserFirstName: Vorname(n) des Teilnehmers
|
||||
CsvColumnExamUserName: Voller Name des Teilnehmers (gewöhnlicherweise inkl. Vor- und Nachname(n))
|
||||
@ -1525,7 +1532,7 @@ CsvColumnApplicationsSemester: Fachsemester des Bewerbes im assoziierten Studien
|
||||
CsvColumnApplicationsText: Text-Bewerbung
|
||||
CsvColumnApplicationsHasFiles: Hat der Bewerber Dateien zu seiner Bewerbung eingereicht (siehe ZIP-Archiv aller Bewerbungsdateien)?
|
||||
CsvColumnApplicationsVeto: Bewerber mit Veto werden garantiert nicht dem Kurs zugeteilt; "veto" oder leer
|
||||
CsvColumnApplicationsRating: Bewertung der Bewerbung; "1.0", "1.3", "1.7", ..., "4.0", "5.0"
|
||||
CsvColumnApplicationsRating: Bewertung der Bewerbung; "1.0", "1.3", "1.7", ..., "4.0", "5.0" (Leer wird behandelt wie eine Note zwischen 2.3 und 2.7)
|
||||
CsvColumnApplicationsComment: Kommentar zur Bewerbung; je nach Kurs-Einstellungen entweder nur als Notiz für die Kursverwalter oder Feedback für den Bewerber
|
||||
|
||||
Action: Aktion
|
||||
@ -1785,3 +1792,38 @@ ExamClosedSince time@Text: Klausur abgeschlossen seit #{time}
|
||||
|
||||
LecturerInfoTooltipNew: Neues Feature
|
||||
LecturerInfoTooltipProblem: Noch nicht implementiertes Feature oder Feature mit bekannten Problemen
|
||||
|
||||
BtnAcceptApplications: Bewerbungen akzeptieren
|
||||
BtnAcceptApplicationsTip: Mit dem untigen Knopf können Sie den Kurs (höchstens bis zur angegeben Maximalkapazität, falls eingestellt) mit Bewerbern auffüllen. Die Bewertungen der Bewerbungen werden dabei berücksichtigt (Unbewertet wird behandelt wie eine Note zwischen 2.3 und 2.7). Bewerber mit Veto oder 5.0 werden nicht angemeldet.
|
||||
AcceptApplicationsMode: Bewerbungen akzeptieren
|
||||
AcceptApplicationsModeTip: Sollen akzeptierte Bewerber direkt als Teilnehmer im Kurs eingetragen werden oder sollen Einladungen per E-Mail verschickt werden?
|
||||
AcceptApplicationsDirect: Direkt anmelden
|
||||
AcceptApplicationsInvite: Einladungen verschicken
|
||||
AcceptApplicationsSecondary: Gleichstände auflösen
|
||||
AcceptApplicationsSecondaryTip: Wenn es im Laufe des Verfahrens mehrere Bewerber mit der selben Bewertung für den selben Platz gibt, wie soll der Gleichstand aufgelöst werden?
|
||||
AcceptApplicationsSecondaryRandom: Zufällig
|
||||
AcceptApplicationsSecondaryTime: Nach Zeitpunkt der Bewerbung
|
||||
|
||||
CsvOptions: CSV-Optionen
|
||||
CsvOptionsTip: Diese Einstellungen betreffen nur den CSV-Export; beim Import werden die verwendeten Einstellungen automatisch ermittelt.
|
||||
CsvPresetRFC: Standard-Konform (RFC 4180)
|
||||
CsvPresetExcel: Excel-Kompatibel
|
||||
CsvCustom: Benutzerdefiniert
|
||||
CsvDelimiter: Trennzeichen
|
||||
CsvUseCrLf: Zeilenumbrüche
|
||||
CsvQuoting: Quoting
|
||||
CsvQuotingTip: Wann sollen Anführungszeichen (") um Felder platziert werden, um Interpretation von im Feld enthaltenen Zeichen als Trennzeichen zu verhindern?
|
||||
CsvDelimiterNull: Null-Byte
|
||||
CsvDelimiterTab: Tabulator
|
||||
CsvDelimiterComma: Komma
|
||||
CsvDelimiterColon: Doppelpunkt
|
||||
CsvDelimiterBar: Senkrechter Strich
|
||||
CsvDelimiterSpace: Leerzeichen
|
||||
CsvDelimiterUnitSep: Teilgruppentrennzeichen
|
||||
CsvCrLf: DOS (CRLF)
|
||||
CsvLf: Unix (LF)
|
||||
CsvQuoteNone: Nie
|
||||
CsvQuoteMinimal: Nur wenn nötig
|
||||
CsvQuoteAll: Immer
|
||||
CsvOptionsUpdated: CSV-Optionen erfolgreich angepasst
|
||||
CsvChangeOptionsLabel: Export-Optionen
|
||||
|
||||
@ -20,7 +20,7 @@ Course -- Information about a single course; contained info is always visible
|
||||
applicationsRequired Bool default=false
|
||||
applicationsInstructions Html Maybe
|
||||
applicationsText Bool default=false
|
||||
applicationsFiles UploadMode "default='{ \"mode\": \"no-upload\" }'::jsonb"
|
||||
applicationsFiles UploadMode "default='{\"mode\": \"no-upload\"}'::jsonb"
|
||||
applicationsRatingsVisible Bool default=false
|
||||
TermSchoolCourseShort term school shorthand -- shorthand must be unique within school and semester
|
||||
TermSchoolCourseName term school name -- name must be unique within school and semester
|
||||
|
||||
@ -30,6 +30,7 @@ User json -- Each Uni2work user has a corresponding row in this table; create
|
||||
mailLanguages MailLanguages "default='[]'::jsonb" -- Preferred language for eMail; i18n not yet implemented; user-defined
|
||||
notificationSettings NotificationSettings -- Bit-array for which events email notifications are requested by user; user-defined
|
||||
warningDays NominalDiffTime default=1209600 -- timedistance to pending deadlines for homepage infos
|
||||
csvOptions CsvOptions "default='{}'::jsonb"
|
||||
UniqueAuthentication ident -- Column 'ident' can be used as a row-key in this table
|
||||
UniqueEmail email -- Column 'email' can be used as a row-key in this table
|
||||
deriving Show Eq Ord Generic -- Haskell-specific settings for runtime-value representing a row in memory
|
||||
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "uni2work",
|
||||
"version": "6.11.1",
|
||||
"version": "7.3.2",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "uni2work",
|
||||
"version": "6.11.1",
|
||||
"version": "7.3.2",
|
||||
"description": "",
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
name: uniworx
|
||||
version: 6.11.1
|
||||
version: 7.3.2
|
||||
|
||||
dependencies:
|
||||
- base >=4.9.1.0 && <5
|
||||
|
||||
1
routes
1
routes
@ -72,6 +72,7 @@
|
||||
/user/profile ProfileDataR GET !free
|
||||
/user/authpreds AuthPredsR GET POST !free
|
||||
/user/set-display-email SetDisplayEmailR GET POST !free
|
||||
/user/csv-options CsvOptionsR GET POST !free
|
||||
|
||||
/exam-office ExamOfficeR !exam-office:
|
||||
/ EOExamsR GET
|
||||
|
||||
@ -24,7 +24,7 @@ import Language.Haskell.TH.Syntax (qLocation)
|
||||
import Network.Wai (Middleware)
|
||||
import Network.Wai.Handler.Warp (Settings, defaultSettings,
|
||||
defaultShouldDisplayException,
|
||||
runSettingsSocket, setHost,
|
||||
runSettings, runSettingsSocket, setHost,
|
||||
setBeforeMainLoop,
|
||||
setOnException, setPort, getPort)
|
||||
import Data.Streaming.Network (bindPortTCP)
|
||||
@ -74,14 +74,15 @@ import qualified Database.Memcached.Binary.IO as Memcached
|
||||
import qualified System.Systemd.Daemon as Systemd
|
||||
import System.Environment (lookupEnv)
|
||||
import System.Posix.Process (getProcessID)
|
||||
import System.Posix.Signals (SignalInfo(..), installHandler, sigTERM)
|
||||
import System.Posix.Signals (SignalInfo(..), installHandler, sigTERM, sigINT)
|
||||
import qualified System.Posix.Signals as Signals (Handler(..))
|
||||
|
||||
import Network.Socket (socketPort)
|
||||
import Network.Socket (socketPort, Socket, PortNumber)
|
||||
import qualified Network.Socket as Socket (close)
|
||||
|
||||
import Control.Concurrent.STM.Delay
|
||||
import Control.Monad.STM (retry)
|
||||
import Control.Monad.Trans.Cont (runContT, callCC)
|
||||
|
||||
import qualified Data.Set as Set
|
||||
|
||||
@ -366,11 +367,20 @@ develMain = runResourceT $ do
|
||||
wsettings <- liftIO . getDevSettings $ warpSettings foundation
|
||||
app <- makeApplication foundation
|
||||
|
||||
let
|
||||
awaitTermination :: IO ()
|
||||
awaitTermination
|
||||
= flip runContT return . forever $ do
|
||||
lift $ threadDelay 100e3
|
||||
whenM (lift $ doesFileExist "yesod-devel/devel-terminate") $
|
||||
callCC ($ ())
|
||||
|
||||
void . liftIO $ installHandler sigINT (Signals.Catch $ return ()) Nothing
|
||||
runAppLoggingT foundation $ handleJobs foundation
|
||||
liftIO . develMainHelper $ return (wsettings, app)
|
||||
void . liftIO $ awaitTermination `race` runSettings wsettings app
|
||||
|
||||
-- | The @main@ function for an executable running this site.
|
||||
appMain :: MonadUnliftIO m => m ()
|
||||
appMain :: forall m. (MonadUnliftIO m, MonadMask m) => m ()
|
||||
appMain = runResourceT $ do
|
||||
settings <- getAppSettings
|
||||
|
||||
@ -398,7 +408,7 @@ appMain = runResourceT $ do
|
||||
$logInfoS "bind" [st|Listening on #{tshow host} port #{tshow port} as per configuration|]
|
||||
liftIO $ pure <$> bindPortTCP port host
|
||||
|
||||
$logDebugS "bind" . tshow =<< mapM (liftIO . socketPort) sockets
|
||||
$logDebugS "bind" . tshow =<< mapM (liftIO . try . socketPort :: Socket -> _ (Either SomeException PortNumber)) sockets
|
||||
|
||||
mainThreadId <- myThreadId
|
||||
liftIO . void . flip (installHandler sigTERM) Nothing . Signals.CatchInfo $ \SignalInfo{..} -> runAppLoggingT foundation $ do
|
||||
@ -462,7 +472,7 @@ appMain = runResourceT $ do
|
||||
foundationStoreNum :: Word32
|
||||
foundationStoreNum = 2
|
||||
|
||||
getApplicationRepl :: (MonadResource m, MonadUnliftIO m) => m (Int, UniWorX, Application)
|
||||
getApplicationRepl :: (MonadResource m, MonadUnliftIO m, MonadMask m) => m (Int, UniWorX, Application)
|
||||
getApplicationRepl = do
|
||||
settings <- getAppDevSettings
|
||||
foundation <- makeFoundation settings
|
||||
|
||||
@ -315,6 +315,8 @@ embedRenderMessage ''UniWorX ''UploadModeDescr id
|
||||
embedRenderMessage ''UniWorX ''SecretJSONFieldException id
|
||||
embedRenderMessage ''UniWorX ''AFormMessage $ concat . drop 2 . splitCamel
|
||||
embedRenderMessage ''UniWorX ''SchoolFunction id
|
||||
embedRenderMessage ''UniWorX ''CsvPreset id
|
||||
embedRenderMessage ''UniWorX ''Quoting ("Csv" <>)
|
||||
|
||||
embedRenderMessage ''UniWorX ''AuthenticationMode id
|
||||
|
||||
@ -933,7 +935,7 @@ tagAccessPredicate AuthTime = APDB $ \mAuthId route _ -> case route of
|
||||
return Authorized
|
||||
|
||||
r -> $unsupportedAuthPredicate AuthTime r
|
||||
tagAccessPredicate AuthStaffTime = APDB $ \_ route _ -> case route of
|
||||
tagAccessPredicate AuthStaffTime = APDB $ \_ route isWrite -> case route of
|
||||
CApplicationR tid ssh csh _ _ -> maybeT (unauthorizedI MsgUnauthorizedApplicationTime) $ do
|
||||
course <- $cachedHereBinary (tid, ssh, csh) . MaybeT . getKeyBy $ TermSchoolCourseShort tid ssh csh
|
||||
allocationCourse <- $cachedHereBinary course . lift . getBy $ UniqueAllocationCourse course
|
||||
@ -944,7 +946,8 @@ tagAccessPredicate AuthStaffTime = APDB $ \_ route _ -> case route of
|
||||
Just Allocation{..} -> do
|
||||
cTime <- liftIO getCurrentTime
|
||||
guard $ NTop allocationStaffAllocationFrom <= NTop (Just cTime)
|
||||
guard $ NTop (Just cTime) <= NTop allocationStaffAllocationTo
|
||||
when isWrite $
|
||||
guard $ NTop (Just cTime) <= NTop allocationStaffAllocationTo
|
||||
|
||||
return Authorized
|
||||
|
||||
@ -1197,10 +1200,11 @@ tagAccessPredicate AuthRegisterGroup = APDB $ \mAuthId route _ -> case route of
|
||||
(Nothing, _) -> return Authorized
|
||||
(_, Nothing) -> return AuthenticationRequired
|
||||
(Just rGroup, Just uid) -> do
|
||||
[E.Value hasOther] <- $cachedHereBinary (uid, rGroup) . lift . E.select . return . E.exists . E.from $ \(tutorial `E.InnerJoin` participant) -> do
|
||||
hasOther <- $cachedHereBinary (uid, rGroup) . lift . E.selectExists . E.from $ \(tutorial `E.InnerJoin` participant) -> do
|
||||
E.on $ tutorial E.^. TutorialId E.==. participant E.^. TutorialParticipantTutorial
|
||||
E.where_ $ participant E.^. TutorialParticipantUser E.==. E.val uid
|
||||
E.&&. tutorial E.^. TutorialRegGroup E.==. E.just (E.val rGroup)
|
||||
E.&&. tutorial E.^. TutorialCourse E.==. E.val tutorialCourse
|
||||
E.&&. tutorial E.^. TutorialRegGroup E.==. E.just (E.val rGroup)
|
||||
E.&&. participant E.^. TutorialParticipantUser E.==. E.val uid
|
||||
guard $ not hasOther
|
||||
return Authorized
|
||||
r -> $unsupportedAuthPredicate AuthRegisterGroup r
|
||||
@ -3285,6 +3289,7 @@ upsertCampusUser ldapData Creds{..} = do
|
||||
, userWarningDays = userDefaultWarningDays
|
||||
, userNotificationSettings = def
|
||||
, userMailLanguages = def
|
||||
, userCsvOptions = def
|
||||
, userTokensIssuedAfter = Nothing
|
||||
, userCreated = now
|
||||
, userLastLdapSynchronisation = Just now
|
||||
|
||||
@ -82,8 +82,7 @@ applicationForm maId@(is _Just -> isAlloc) cid uid ApplicationFormMode{..} csrf
|
||||
coursesNum <- fromIntegral . fromMaybe 1 <$> for maId (\aId -> count [AllocationCourseAllocation ==. aId])
|
||||
course <- getJust cid
|
||||
(fromMaybe 0 -> maxPrio) <- fmap ((>>= E.unValue) . listToMaybe) . E.select . E.from $ \courseApplication -> do
|
||||
E.where_ $ courseApplication E.^. CourseApplicationCourse E.==. E.val cid
|
||||
E.&&. courseApplication E.^. CourseApplicationUser E.==. E.val uid
|
||||
E.where_ $ courseApplication E.^. CourseApplicationUser E.==. E.val uid
|
||||
E.&&. courseApplication E.^. CourseApplicationAllocation E.==. E.val maId
|
||||
E.&&. E.not_ (E.isNothing $ courseApplication E.^. CourseApplicationAllocationPriority)
|
||||
return . E.joinV . E.max_ $ courseApplication E.^. CourseApplicationAllocationPriority
|
||||
|
||||
@ -25,6 +25,10 @@ import qualified Data.Map as Map
|
||||
|
||||
import qualified Data.Conduit.List as C
|
||||
|
||||
import Handler.Course.ParticipantInvite
|
||||
|
||||
import Jobs.Queue
|
||||
|
||||
|
||||
type CourseApplicationsTableExpr = ( E.SqlExpr (Entity CourseApplication)
|
||||
`E.InnerJoin` E.SqlExpr (Entity User)
|
||||
@ -34,41 +38,49 @@ type CourseApplicationsTableExpr = ( E.SqlExpr (Entity CourseApplic
|
||||
`E.InnerJoin` E.SqlExpr (Maybe (Entity StudyTerms))
|
||||
`E.InnerJoin` E.SqlExpr (Maybe (Entity StudyDegree))
|
||||
)
|
||||
`E.LeftOuterJoin` E.SqlExpr (Maybe (Entity CourseParticipant))
|
||||
type CourseApplicationsTableData = DBRow ( Entity CourseApplication
|
||||
, Entity User
|
||||
, E.Value Bool -- hasFiles
|
||||
, Bool -- hasFiles
|
||||
, Maybe (Entity Allocation)
|
||||
, Maybe (Entity StudyFeatures)
|
||||
, Maybe (Entity StudyTerms)
|
||||
, Maybe (Entity StudyDegree)
|
||||
, Bool -- isParticipant
|
||||
)
|
||||
|
||||
courseApplicationsIdent :: Text
|
||||
courseApplicationsIdent = "applications"
|
||||
|
||||
queryCourseApplication :: Getter CourseApplicationsTableExpr (E.SqlExpr (Entity CourseApplication))
|
||||
queryCourseApplication = to $ $(sqlIJproj 2 1) . $(sqlLOJproj 3 1)
|
||||
queryCourseApplication = to $ $(sqlIJproj 2 1) . $(sqlLOJproj 4 1)
|
||||
|
||||
queryUser :: Getter CourseApplicationsTableExpr (E.SqlExpr (Entity User))
|
||||
queryUser = to $ $(sqlIJproj 2 2) . $(sqlLOJproj 3 1)
|
||||
queryUser = to $ $(sqlIJproj 2 2) . $(sqlLOJproj 4 1)
|
||||
|
||||
queryHasFiles :: Getter CourseApplicationsTableExpr (E.SqlExpr (E.Value Bool))
|
||||
queryHasFiles = to $ hasFiles . $(sqlIJproj 2 1) . $(sqlLOJproj 3 1)
|
||||
queryHasFiles = to $ hasFiles . $(sqlIJproj 2 1) . $(sqlLOJproj 4 1)
|
||||
where
|
||||
hasFiles appl = E.exists . E.from $ \courseApplicationFile ->
|
||||
E.where_ $ courseApplicationFile E.^. CourseApplicationFileApplication E.==. appl E.^. CourseApplicationId
|
||||
|
||||
queryAllocation :: Getter CourseApplicationsTableExpr (E.SqlExpr (Maybe (Entity Allocation)))
|
||||
queryAllocation = to $(sqlLOJproj 3 2)
|
||||
queryAllocation = to $(sqlLOJproj 4 2)
|
||||
|
||||
queryStudyFeatures :: Getter CourseApplicationsTableExpr (E.SqlExpr (Maybe (Entity StudyFeatures)))
|
||||
queryStudyFeatures = to $ $(sqlIJproj 3 1) . $(sqlLOJproj 3 3)
|
||||
queryStudyFeatures = to $ $(sqlIJproj 3 1) . $(sqlLOJproj 4 3)
|
||||
|
||||
queryStudyTerms :: Getter CourseApplicationsTableExpr (E.SqlExpr (Maybe (Entity StudyTerms)))
|
||||
queryStudyTerms = to $ $(sqlIJproj 3 2) . $(sqlLOJproj 3 3)
|
||||
queryStudyTerms = to $ $(sqlIJproj 3 2) . $(sqlLOJproj 4 3)
|
||||
|
||||
queryStudyDegree :: Getter CourseApplicationsTableExpr (E.SqlExpr (Maybe (Entity StudyDegree)))
|
||||
queryStudyDegree = to $ $(sqlIJproj 3 3) . $(sqlLOJproj 3 3)
|
||||
queryStudyDegree = to $ $(sqlIJproj 3 3) . $(sqlLOJproj 4 3)
|
||||
|
||||
queryCourseParticipant :: Getter CourseApplicationsTableExpr (E.SqlExpr (Maybe (Entity CourseParticipant)))
|
||||
queryCourseParticipant = to $(sqlLOJproj 4 4)
|
||||
|
||||
queryIsParticipant :: Getter CourseApplicationsTableExpr (E.SqlExpr (E.Value Bool))
|
||||
queryIsParticipant = to $ E.not_ . E.isNothing . (E.?. CourseParticipantId) . $(sqlLOJproj 4 4)
|
||||
|
||||
resultCourseApplication :: Lens' CourseApplicationsTableData (Entity CourseApplication)
|
||||
resultCourseApplication = _dbrOutput . _1
|
||||
@ -77,7 +89,7 @@ resultUser :: Lens' CourseApplicationsTableData (Entity User)
|
||||
resultUser = _dbrOutput . _2
|
||||
|
||||
resultHasFiles :: Lens' CourseApplicationsTableData Bool
|
||||
resultHasFiles = _dbrOutput . _3 . _Value
|
||||
resultHasFiles = _dbrOutput . _3
|
||||
|
||||
resultAllocation :: Traversal' CourseApplicationsTableData (Entity Allocation)
|
||||
resultAllocation = _dbrOutput . _4 . _Just
|
||||
@ -91,6 +103,9 @@ resultStudyTerms = _dbrOutput . _6 . _Just
|
||||
resultStudyDegree :: Traversal' CourseApplicationsTableData (Entity StudyDegree)
|
||||
resultStudyDegree = _dbrOutput . _7 . _Just
|
||||
|
||||
resultIsParticipant :: Lens' CourseApplicationsTableData Bool
|
||||
resultIsParticipant = _dbrOutput . _8
|
||||
|
||||
|
||||
newtype CourseApplicationsTableVeto = CourseApplicationsTableVeto Bool
|
||||
deriving (Eq, Ord, Read, Show, Generic, Typeable)
|
||||
@ -205,12 +220,44 @@ data CourseApplicationsTableCsvException
|
||||
instance Exception CourseApplicationsTableCsvException
|
||||
|
||||
embedRenderMessage ''UniWorX ''CourseApplicationsTableCsvException id
|
||||
|
||||
|
||||
data ButtonAcceptApplications = BtnAcceptApplications
|
||||
deriving (Enum, Eq, Ord, Bounded, Read, Show, Generic, Typeable)
|
||||
instance Universe ButtonAcceptApplications
|
||||
instance Finite ButtonAcceptApplications
|
||||
|
||||
nullaryPathPiece ''ButtonAcceptApplications $ camelToPathPiece' 1
|
||||
|
||||
embedRenderMessage ''UniWorX ''ButtonAcceptApplications id
|
||||
instance Button UniWorX ButtonAcceptApplications where
|
||||
btnClasses BtnAcceptApplications = [BCIsButton]
|
||||
|
||||
data AcceptApplicationsMode = AcceptApplicationsInvite
|
||||
| AcceptApplicationsDirect
|
||||
deriving (Enum, Eq, Ord, Bounded, Read, Show, Generic, Typeable)
|
||||
instance Universe AcceptApplicationsMode
|
||||
instance Finite AcceptApplicationsMode
|
||||
|
||||
nullaryPathPiece ''AcceptApplicationsMode $ camelToPathPiece' 2
|
||||
|
||||
embedRenderMessage ''UniWorX ''AcceptApplicationsMode id
|
||||
|
||||
data AcceptApplicationsSecondary = AcceptApplicationsSecondaryRandom
|
||||
| AcceptApplicationsSecondaryTime
|
||||
deriving (Enum, Eq, Ord, Bounded, Read, Show, Generic, Typeable)
|
||||
instance Universe AcceptApplicationsSecondary
|
||||
instance Finite AcceptApplicationsSecondary
|
||||
|
||||
nullaryPathPiece ''AcceptApplicationsSecondary $ camelToPathPiece' 3
|
||||
|
||||
embedRenderMessage ''UniWorX ''AcceptApplicationsSecondary id
|
||||
|
||||
|
||||
getCApplicationsR, postCApplicationsR :: TermId -> SchoolId -> CourseShorthand -> Handler Html
|
||||
getCApplicationsR = postCApplicationsR
|
||||
postCApplicationsR tid ssh csh = do
|
||||
(table, allocationsBounds) <- runDB $ do
|
||||
(table, allocationsBounds, mayAccept) <- runDB $ do
|
||||
Entity cid Course{..} <- getBy404 $ TermSchoolCourseShort tid ssh csh
|
||||
|
||||
csvName <- getMessageRender <*> pure (MsgCourseApplicationsTableCsvName tid ssh csh)
|
||||
@ -237,31 +284,43 @@ postCApplicationsR tid ssh csh = do
|
||||
studyFeatures <- view queryStudyFeatures
|
||||
studyTerms <- view queryStudyTerms
|
||||
studyDegree <- view queryStudyDegree
|
||||
courseParticipant <- view queryCourseParticipant
|
||||
|
||||
lift $ do
|
||||
E.on $ E.just (user E.^. UserId) E.==. courseParticipant E.?. CourseParticipantUser
|
||||
E.&&. courseParticipant E.?. CourseParticipantCourse E.==. E.just (E.val cid)
|
||||
E.on $ studyDegree E.?. StudyDegreeId E.==. studyFeatures E.?. StudyFeaturesDegree
|
||||
E.on $ studyTerms E.?. StudyTermsId E.==. studyFeatures E.?. StudyFeaturesField
|
||||
E.on $ studyFeatures E.?. StudyFeaturesId E.==. courseApplication E.^. CourseApplicationField
|
||||
E.on $ courseApplication E.^. CourseApplicationAllocation E.==. allocation E.?. AllocationId
|
||||
E.on $ user E.^. UserId E.==. courseApplication E.^. CourseApplicationUser
|
||||
E.where_ $ courseApplication E.^. CourseApplicationCourse E.==. E.val cid
|
||||
E.&&. courseApplication E.^. CourseApplicationCourse E.==. E.val cid
|
||||
|
||||
return (courseApplication, user, hasFiles, allocation, studyFeatures, studyTerms, studyDegree)
|
||||
return ( courseApplication
|
||||
, user
|
||||
, hasFiles
|
||||
, allocation
|
||||
, studyFeatures
|
||||
, studyTerms
|
||||
, studyDegree
|
||||
, E.not_ . E.isNothing $ courseParticipant E.?. CourseParticipantId
|
||||
)
|
||||
|
||||
dbtProj :: DBRow _ -> MaybeT (YesodDB UniWorX) CourseApplicationsTableData
|
||||
dbtProj = runReaderT $ do
|
||||
appId <- view $ resultCourseApplication . _entityKey
|
||||
appId <- view $ _dbrOutput . _1 . _entityKey
|
||||
cID <- encrypt appId
|
||||
|
||||
guardM . hasReadAccessTo $ CApplicationR tid ssh csh cID CAEditR
|
||||
|
||||
view id
|
||||
asks $ over (_dbrOutput . _3) E.unValue . over (_dbrOutput . _8) E.unValue
|
||||
|
||||
dbtRowKey = view $ queryCourseApplication . to (E.^. CourseApplicationId)
|
||||
|
||||
dbtColonnade :: Colonnade Sortable _ _
|
||||
dbtColonnade = mconcat
|
||||
[ emptyOpticColonnade (resultAllocation . _entityVal) $ \l -> anchorColonnade (views l allocationLink) $ colAllocationShorthand (l . _allocationShorthand)
|
||||
[ sortable (Just "participant") (i18nCell MsgCourseApplicationIsParticipant) $ bool mempty (cell $ toWidget iconOK) . view resultIsParticipant
|
||||
, emptyOpticColonnade (resultAllocation . _entityVal) $ \l -> anchorColonnade (views l allocationLink) $ colAllocationShorthand (l . _allocationShorthand)
|
||||
, anchorColonnadeM (views (resultCourseApplication . _entityKey) applicationLink) $ colApplicationId (resultCourseApplication . _entityKey)
|
||||
, anchorColonnadeM (views (resultUser . _entityKey) participantLink) $ colUserDisplayName (resultUser . _entityVal . $(multifocusL 2) _userDisplayName _userSurname)
|
||||
, colUserMatriculation (resultUser . _entityVal . _userMatrikelnummer)
|
||||
@ -276,7 +335,8 @@ postCApplicationsR tid ssh csh = do
|
||||
]
|
||||
|
||||
dbtSorting = mconcat
|
||||
[ sortAllocationShorthand $ queryAllocation . to (E.?. AllocationShorthand)
|
||||
[ singletonMap "participant" . SortColumn $ view queryIsParticipant
|
||||
, sortAllocationShorthand $ queryAllocation . to (E.?. AllocationShorthand)
|
||||
, sortUserName' $ $(multifocusG 2) (queryUser . to (E.^. UserDisplayName)) (queryUser . to (E.^. UserSurname))
|
||||
, sortUserMatriculation $ queryUser . to (E.^. UserMatrikelnummer)
|
||||
, sortStudyTerms queryStudyTerms
|
||||
@ -566,12 +626,67 @@ postCApplicationsR tid ssh csh = do
|
||||
|| numFirstChoice' /= numFirstChoice
|
||||
]
|
||||
|
||||
(, allocationsBounds) <$> dbTableWidget' psValidator DBTable{..}
|
||||
mayAccept <- hasWriteAccessTo $ CourseR tid ssh csh CAddUserR
|
||||
|
||||
(, allocationsBounds, mayAccept) <$> dbTableWidget' psValidator DBTable{..}
|
||||
|
||||
now <- liftIO getCurrentTime
|
||||
let title = prependCourseTitle tid ssh csh MsgCourseApplicationsListTitle
|
||||
registrationOpen = maybe True (now <)
|
||||
|
||||
|
||||
((acceptRes, acceptWgt'), acceptEnc) <- runFormPost . identifyForm BtnAcceptApplications . renderAForm FormStandard $
|
||||
(,) <$> apopt (selectField optionsFinite) (fslI MsgAcceptApplicationsMode & setTooltip MsgAcceptApplicationsModeTip) (Just AcceptApplicationsInvite)
|
||||
<*> apopt (selectField optionsFinite) (fslI MsgAcceptApplicationsSecondary & setTooltip MsgAcceptApplicationsSecondaryTip) (Just AcceptApplicationsSecondaryTime)
|
||||
|
||||
let acceptWgt = wrapForm' BtnAcceptApplications acceptWgt' def
|
||||
{ formSubmit = FormSubmit
|
||||
, formAction = Just . SomeRoute $ CourseR tid ssh csh CApplicationsR
|
||||
, formEncoding = acceptEnc
|
||||
}
|
||||
|
||||
when mayAccept $
|
||||
formResult acceptRes $ \(invMode, appsSecOrder) -> do
|
||||
runDBJobs $ do
|
||||
Entity cid Course{..} <- getBy404 $ TermSchoolCourseShort tid ssh csh
|
||||
participants <- count [ CourseParticipantCourse ==. cid ]
|
||||
let openCapacity = subtract participants <$> courseCapacity
|
||||
|
||||
applications <- E.select . E.from $ \(user `E.InnerJoin` application) -> do
|
||||
E.on $ user E.^. UserId E.==. application E.^. CourseApplicationUser
|
||||
|
||||
E.where_ $ application E.^. CourseApplicationCourse E.==. E.val cid
|
||||
E.&&. E.isNothing (application E.^. CourseApplicationAllocation)
|
||||
E.&&. E.not_ (application E.^. CourseApplicationRatingVeto)
|
||||
E.&&. E.maybe E.true (`E.in_` E.valList (filter (view $ passingGrade . _Wrapped) universeF)) (application E.^. CourseApplicationRatingPoints )
|
||||
|
||||
E.where_ . E.not_ . E.exists . E.from $ \participant ->
|
||||
E.where_ $ participant E.^. CourseParticipantCourse E.==. E.val cid
|
||||
E.&&. participant E.^. CourseParticipantUser E.==. user E.^. UserId
|
||||
|
||||
return (user, application)
|
||||
|
||||
let
|
||||
ratingL = _2 . _entityVal . _courseApplicationRatingPoints . to (Down . ExamGradeDefCenter)
|
||||
cmp = case appsSecOrder of
|
||||
AcceptApplicationsSecondaryTime
|
||||
-> comparing . view $ $(multifocusG 2) ratingL (_2 . _entityVal . _courseApplicationTime)
|
||||
AcceptApplicationsSecondaryRandom
|
||||
-> comparing $ view ratingL
|
||||
sortedApplications <- unstableSortBy cmp applications
|
||||
|
||||
let applicants = sortedApplications
|
||||
& nubOn (view $ _1 . _entityKey)
|
||||
& maybe id take openCapacity
|
||||
& setOf (case invMode of
|
||||
AcceptApplicationsDirect -> folded . _1 . _entityKey . to Right
|
||||
AcceptApplicationsInvite -> folded . _1 . _entityVal . _userEmail . to Left
|
||||
)
|
||||
|
||||
mapM_ addMessage' <=< execWriterT $ registerUsers cid applicants
|
||||
redirect $ CourseR tid ssh csh CUsersR
|
||||
|
||||
|
||||
siteLayoutMsg title $ do
|
||||
setTitleI title
|
||||
$(widgetFile "course/applications-list")
|
||||
|
||||
@ -107,12 +107,13 @@ makeCourseForm miButtonAction template = identifyForm FIDcourse . validateFormDB
|
||||
MsgRenderer mr <- getMsgRenderer
|
||||
|
||||
uid <- liftHandler requireAuthId
|
||||
(lecturerSchools, adminSchools) <- liftHandler . runDB $ do
|
||||
(lecturerSchools, adminSchools, oldSchool) <- liftHandler . runDB $ do
|
||||
lecturerSchools <- map (userFunctionSchool . entityVal) <$> selectList [UserFunctionUser ==. uid, UserFunctionFunction <-. [SchoolLecturer]] []
|
||||
protoAdminSchools <- map (userFunctionSchool . entityVal) <$> selectList [UserFunctionUser ==. uid, UserFunctionFunction <-. [SchoolAdmin]] []
|
||||
adminSchools <- filterM (hasWriteAccessTo . flip SchoolR SchoolEditR) protoAdminSchools
|
||||
return (lecturerSchools, adminSchools)
|
||||
let userSchools = nub $ lecturerSchools ++ adminSchools
|
||||
oldSchool <- forM (cfCourseId =<< template) $ fmap courseSchool . getJust
|
||||
return (lecturerSchools, adminSchools, oldSchool)
|
||||
let userSchools = nub . maybe id (:) oldSchool $ lecturerSchools ++ adminSchools
|
||||
|
||||
termsField <- case template of
|
||||
-- Change of term is only allowed if user may delete the course (i.e. no participants) or admin
|
||||
|
||||
@ -4,6 +4,9 @@ module Handler.Course.ParticipantInvite
|
||||
( InvitableJunction(..), InvitationDBData(..), InvitationTokenData(..)
|
||||
, getCInviteR, postCInviteR
|
||||
, getCAddUserR, postCAddUserR
|
||||
, AddParticipantsResult(..)
|
||||
, addParticipantsResultMessages
|
||||
, registerUsers, registerUser
|
||||
) where
|
||||
|
||||
import Import
|
||||
@ -96,16 +99,16 @@ participantInvitationConfig = InvitationConfig{..}
|
||||
return . SomeMessage $ MsgCourseParticipantInvitationAccepted (CI.original courseName)
|
||||
invitationUltDest (Entity _ Course{..}) _ = return . SomeRoute $ CourseR courseTerm courseSchool courseShorthand CShowR
|
||||
|
||||
data AddRecipientsResult = AddRecipientsResult
|
||||
data AddParticipantsResult = AddParticipantsResult
|
||||
{ aurAlreadyRegistered
|
||||
, aurNoUniquePrimaryField
|
||||
, aurSuccess :: [UserEmail]
|
||||
, aurSuccess :: Set UserId
|
||||
} deriving (Read, Show, Generic, Typeable)
|
||||
|
||||
instance Semigroup AddRecipientsResult where
|
||||
instance Semigroup AddParticipantsResult where
|
||||
(<>) = mappenddefault
|
||||
|
||||
instance Monoid AddRecipientsResult where
|
||||
instance Monoid AddParticipantsResult where
|
||||
mempty = memptydefault
|
||||
mappend = (<>)
|
||||
|
||||
@ -118,7 +121,9 @@ postCAddUserR tid ssh csh = do
|
||||
wreq (multiUserField (maybe True not $ formResultToMaybe enlist) Nothing)
|
||||
(fslI MsgCourseParticipantInviteField & setTooltip MsgMultiEmailFieldTip) Nothing
|
||||
|
||||
formResultModal usersToEnlist (CourseR tid ssh csh CUsersR) $ processUsers cid
|
||||
formResultModal usersToEnlist (CourseR tid ssh csh CUsersR) $
|
||||
hoist runDBJobs . registerUsers cid
|
||||
|
||||
|
||||
let heading = prependCourseTitle tid ssh csh MsgCourseParticipantsRegisterHeading
|
||||
|
||||
@ -128,57 +133,74 @@ postCAddUserR tid ssh csh = do
|
||||
{ formEncoding
|
||||
, formAction = Just . SomeRoute $ CourseR tid ssh csh CAddUserR
|
||||
}
|
||||
where
|
||||
processUsers :: CourseId -> Set (Either UserEmail UserId) -> WriterT [Message] Handler ()
|
||||
processUsers cid users = do
|
||||
let (emails,uids) = partitionEithers $ Set.toList users
|
||||
AddRecipientsResult{..} <- lift . runDBJobs $ do
|
||||
-- send Invitation eMails to unkown users
|
||||
sinkInvitationsF participantInvitationConfig [(mail,cid,(InvDBDataParticipant,InvTokenDataParticipant)) | mail <- emails]
|
||||
-- register known users
|
||||
execWriterT $ mapM (registerUser cid) uids
|
||||
|
||||
unless (null emails) $
|
||||
tell . pure <=< messageI Success . MsgCourseParticipantsInvited $ length emails
|
||||
registerUsers :: CourseId -> Set (Either UserEmail UserId) -> WriterT [Message] (YesodJobDB UniWorX) ()
|
||||
registerUsers cid users = do
|
||||
let (emails,uids) = partitionEithers $ Set.toList users
|
||||
|
||||
unless (null aurAlreadyRegistered) $ do
|
||||
let modalTrigger = [whamlet|_{MsgCourseParticipantsAlreadyRegistered (length aurAlreadyRegistered)}|]
|
||||
modalContent = $(widgetFile "messages/courseInvitationAlreadyRegistered")
|
||||
tell . pure <=< messageWidget Info $ msgModal modalTrigger (Right modalContent)
|
||||
-- send Invitation eMails to unkown users
|
||||
lift $ sinkInvitationsF participantInvitationConfig [(mail,cid,(InvDBDataParticipant,InvTokenDataParticipant)) | mail <- emails]
|
||||
-- register known users
|
||||
tell <=< lift . addParticipantsResultMessages <=< lift . execWriterT $ mapM_ (registerUser cid) uids
|
||||
|
||||
unless (null aurNoUniquePrimaryField) $ do
|
||||
let modalTrigger = [whamlet|_{MsgCourseParticipantsRegisteredWithoutField (length aurNoUniquePrimaryField)}|]
|
||||
modalContent = $(widgetFile "messages/courseInvitationRegisteredWithoutField")
|
||||
tell . pure <=< messageWidget Warning $ msgModal modalTrigger (Right modalContent)
|
||||
unless (null emails) $
|
||||
tell . pure <=< messageI Success . MsgCourseParticipantsInvited $ length emails
|
||||
|
||||
unless (null aurSuccess) $
|
||||
tell . pure <=< messageI Success . MsgCourseParticipantsRegistered $ length aurSuccess
|
||||
|
||||
registerUser :: CourseId -> UserId -> WriterT AddRecipientsResult (YesodJobDB UniWorX) ()
|
||||
registerUser cid uid = exceptT tell tell $ do
|
||||
User{..} <- lift . lift $ getJust uid
|
||||
addParticipantsResultMessages :: (MonadHandler m, HandlerSite m ~ UniWorX)
|
||||
=> AddParticipantsResult
|
||||
-> ReaderT (YesodPersistBackend UniWorX) m [Message]
|
||||
addParticipantsResultMessages AddParticipantsResult{..} = execWriterT $ do
|
||||
(aurAlreadyRegistered', aurNoUniquePrimaryField') <-
|
||||
(,) <$> fmap sort (lift . mapM (fmap userEmail . getJust) $ Set.toList aurAlreadyRegistered)
|
||||
<*> fmap sort (lift . mapM (fmap userEmail . getJust) $ Set.toList aurNoUniquePrimaryField)
|
||||
|
||||
whenM (lift . lift . existsBy $ UniqueParticipant uid cid) $
|
||||
throwError $ mempty { aurAlreadyRegistered = pure userEmail }
|
||||
unless (null aurAlreadyRegistered) $ do
|
||||
let modalTrigger = [whamlet|_{MsgCourseParticipantsAlreadyRegistered (length aurAlreadyRegistered)}|]
|
||||
modalContent = $(widgetFile "messages/courseInvitationAlreadyRegistered")
|
||||
tell . pure <=< messageWidget Info $ msgModal modalTrigger (Right modalContent)
|
||||
|
||||
features <- lift . lift $ selectKeysList [ StudyFeaturesUser ==. uid, StudyFeaturesValid ==. True, StudyFeaturesType ==. FieldPrimary ] []
|
||||
unless (null aurNoUniquePrimaryField) $ do
|
||||
let modalTrigger = [whamlet|_{MsgCourseParticipantsRegisteredWithoutField (length aurNoUniquePrimaryField)}|]
|
||||
modalContent = $(widgetFile "messages/courseInvitationRegisteredWithoutField")
|
||||
tell . pure <=< messageWidget Warning $ msgModal modalTrigger (Right modalContent)
|
||||
|
||||
let courseParticipantField
|
||||
| [f] <- features = Just f
|
||||
| otherwise = Nothing
|
||||
unless (null aurSuccess) $
|
||||
tell . pure <=< messageI Success . MsgCourseParticipantsRegistered $ length aurSuccess
|
||||
|
||||
courseParticipantRegistration <- liftIO getCurrentTime
|
||||
void . lift . lift . insert $ CourseParticipant
|
||||
{ courseParticipantCourse = cid
|
||||
, courseParticipantUser = uid
|
||||
, courseParticipantAllocated = False
|
||||
, ..
|
||||
}
|
||||
lift . lift . audit $ TransactionCourseParticipantEdit cid uid
|
||||
|
||||
return $ case courseParticipantField of
|
||||
Nothing -> mempty { aurNoUniquePrimaryField = pure userEmail }
|
||||
Just _ -> mempty { aurSuccess = pure userEmail }
|
||||
registerUser :: (MonadHandler m, HandlerSite m ~ UniWorX, MonadCatch m)
|
||||
=> CourseId
|
||||
-> UserId
|
||||
-> WriterT AddParticipantsResult (ReaderT (YesodPersistBackend UniWorX) m) ()
|
||||
registerUser cid uid = exceptT tell tell $ do
|
||||
whenM (lift . lift . existsBy $ UniqueParticipant uid cid) $
|
||||
throwError $ mempty { aurAlreadyRegistered = Set.singleton uid }
|
||||
|
||||
features <- lift . lift $ selectKeysList [ StudyFeaturesUser ==. uid, StudyFeaturesValid ==. True, StudyFeaturesType ==. FieldPrimary ] []
|
||||
applications <- lift . lift $ selectList [ CourseApplicationCourse ==. cid, CourseApplicationUser ==. uid ] []
|
||||
|
||||
let courseParticipantField
|
||||
| [f] <- features
|
||||
= Just f
|
||||
| [f'] <- nub $ mapMaybe (courseApplicationField . entityVal) applications
|
||||
, f' `elem` features
|
||||
= Just f'
|
||||
| otherwise
|
||||
= Nothing
|
||||
|
||||
courseParticipantRegistration <- liftIO getCurrentTime
|
||||
void . lift . lift . insert $ CourseParticipant
|
||||
{ courseParticipantCourse = cid
|
||||
, courseParticipantUser = uid
|
||||
, courseParticipantAllocated = False
|
||||
, ..
|
||||
}
|
||||
lift . lift . audit $ TransactionCourseParticipantEdit cid uid
|
||||
|
||||
return $ case courseParticipantField of
|
||||
Nothing -> mempty { aurNoUniquePrimaryField = Set.singleton uid }
|
||||
Just _ -> mempty { aurSuccess = Set.singleton uid }
|
||||
|
||||
|
||||
getCInviteR, postCInviteR :: TermId -> SchoolId -> CourseShorthand -> Handler Html
|
||||
|
||||
@ -72,11 +72,17 @@ getEShowR tid ssh csh examn = do
|
||||
examClosedShown = lecturerInfoShown
|
||||
|
||||
sumMaxPoints = sum [ fromRational examPartWeight * mPoints | Entity _ ExamPart{..} <- examParts, let Just mPoints = examPartMaxPoints ]
|
||||
sumPoints = getSum <$> foldMap (fmap Sum . examPartResultResult . entityVal) results
|
||||
|
||||
noBonus = fromMaybe False $ do
|
||||
guardM $ bonusOnlyPassed <$> examBonusRule
|
||||
return . fromMaybe True $ result ^? _Just . _entityVal . _examResultResult . _examResult . passingGrade . _Wrapped . to not
|
||||
|
||||
sumPoints = fmap getSum . mconcat $ catMaybes
|
||||
[ Just $ foldMap (fmap Sum . examPartResultResult . entityVal) results
|
||||
, guard (not noBonus) *> fmap (pure . Sum . examBonusBonus . entityVal) bonus
|
||||
]
|
||||
|
||||
hasRegistration = any snd occurrences
|
||||
|
||||
|
||||
let examTimes = all (\(Entity _ ExamOccurrence{..}, _) -> Just examOccurrenceStart == examStart && examOccurrenceEnd == examEnd) occurrences
|
||||
|
||||
@ -160,7 +160,7 @@ resultCourseNote = _dbrOutput . _10 . _Just
|
||||
|
||||
|
||||
resultAutomaticExamBonus :: Exam -> Map UserId SheetTypeSummary -> Fold ExamUserTableData Points
|
||||
resultAutomaticExamBonus exam examBonus' = resultUser . _entityKey . folding (\uid -> examResultBonus <$> examBonusRule exam <*> examBonusPossible uid examBonus' <*> examBonusAchieved uid examBonus')
|
||||
resultAutomaticExamBonus exam examBonus' = resultUser . _entityKey . folding (\uid -> examResultBonus <$> examBonusRule exam <*> pure (examBonusPossible uid examBonus') <*> pure (examBonusAchieved uid examBonus'))
|
||||
|
||||
resultAutomaticExamResult :: Exam -> Map UserId SheetTypeSummary -> Fold ExamUserTableData ExamResultGrade
|
||||
resultAutomaticExamResult exam examBonus' = folding . runReader $ do
|
||||
@ -396,7 +396,7 @@ postEUsersR tid ssh csh examn = do
|
||||
allBoni :: SheetGradeSummary
|
||||
allBoni = (mappend <$> normalSummary <*> bonusSummary) $ fold bonus
|
||||
|
||||
doBonus = is _Just examGradingRule || is _Just examBonusRule
|
||||
doBonus = is _Just examBonusRule
|
||||
showPasses = doBonus && numSheetsPasses allBoni /= 0
|
||||
showPoints = doBonus && getSum (numSheetsPoints allBoni) /= 0
|
||||
|
||||
@ -494,14 +494,14 @@ postEUsersR tid ssh csh examn = do
|
||||
, pure $ colDegreeShort resultStudyDegree
|
||||
, pure $ colFeaturesSemester resultStudyFeatures
|
||||
, pure $ sortable (Just "occurrence") (i18nCell MsgExamOccurrence) $ maybe mempty (anchorCell' (\n -> CExamR tid ssh csh examn EShowR :#: [st|exam-occurrence__#{n}|]) id . examOccurrenceName . entityVal) . view _userTableOccurrence
|
||||
, guardOn showPasses $ sortable Nothing (i18nCell MsgAchievedPasses) $ \(view $ resultUser . _entityKey -> uid) -> fromMaybe mempty $ do
|
||||
SheetGradeSummary{achievedPasses} <- examBonusAchieved uid bonus
|
||||
SheetGradeSummary{numSheetsPasses} <- examBonusPossible uid bonus
|
||||
return $ propCell (getSum achievedPasses) (getSum numSheetsPasses)
|
||||
, guardOn showPoints $ sortable Nothing (i18nCell MsgAchievedPoints) $ \(view $ resultUser . _entityKey -> uid) -> fromMaybe mempty $ do
|
||||
SheetGradeSummary{achievedPoints} <- examBonusAchieved uid bonus
|
||||
SheetGradeSummary{sumSheetsPoints} <- examBonusPossible uid bonus
|
||||
return $ propCell (getSum achievedPoints) (getSum sumSheetsPoints)
|
||||
, guardOn showPasses $ sortable Nothing (i18nCell MsgAchievedPasses) $ \(view $ resultUser . _entityKey -> uid) ->
|
||||
let SheetGradeSummary{achievedPasses} = examBonusAchieved uid bonus
|
||||
SheetGradeSummary{numSheetsPasses} = examBonusPossible uid bonus
|
||||
in propCell (getSum achievedPasses) (getSum numSheetsPasses)
|
||||
, guardOn showPoints $ sortable Nothing (i18nCell MsgAchievedPoints) $ \(view $ resultUser . _entityKey -> uid) ->
|
||||
let SheetGradeSummary{achievedPoints} = examBonusAchieved uid bonus
|
||||
SheetGradeSummary{sumSheetsPoints} = examBonusPossible uid bonus
|
||||
in propCell (getSum achievedPoints) (getSum sumSheetsPoints)
|
||||
, guardOn doBonus $ sortable (Just "bonus") (i18nCell MsgExamBonusAchieved) . automaticCell $ resultExamBonus . _entityVal . _examBonusBonus . to Right <> resultAutomaticExamBonus' . to Left
|
||||
, pure $ mconcat
|
||||
[ sortable (Just $ fromText [st|part-#{toPathPiece examPartNumber}|]) (i18nCell $ MsgExamPartNumbered examPartNumber) $ maybe mempty i18nCell . preview (resultExamPartResult epId . _Just . _entityVal . _examPartResultResult)
|
||||
@ -612,10 +612,10 @@ postEUsersR tid ssh csh examn = do
|
||||
<*> preview (resultStudyDegree . _entityVal . to (\StudyDegree{..} -> studyDegreeName <|> studyDegreeShorthand <|> Just (tshow studyDegreeKey)) . _Just)
|
||||
<*> preview (resultStudyFeatures . _entityVal . _studyFeaturesSemester)
|
||||
<*> preview (resultExamOccurrence . _entityVal . _examOccurrenceName)
|
||||
<*> previews (resultUser . _entityKey . to (examBonusAchieved ?? bonus) . _Just . _achievedPoints . _Wrapped) (bool (const Nothing) Just showPoints)
|
||||
<*> previews (resultUser . _entityKey . to (examBonusAchieved ?? bonus) . _Just . _achievedPasses . _Wrapped . integral) (bool (const Nothing) Just showPasses)
|
||||
<*> previews (resultUser . _entityKey . to (examBonusPossible ?? bonus) . _Just . _sumSheetsPoints . _Wrapped) (bool (const Nothing) Just showPoints)
|
||||
<*> previews (resultUser . _entityKey . to (examBonusPossible ?? bonus) . _Just . _numSheetsPasses . _Wrapped . integral) (bool (const Nothing) Just showPasses)
|
||||
<*> previews (resultUser . _entityKey . to (examBonusAchieved ?? bonus) . _achievedPoints . _Wrapped) (bool (const Nothing) Just showPoints)
|
||||
<*> previews (resultUser . _entityKey . to (examBonusAchieved ?? bonus) . _achievedPasses . _Wrapped . integral) (bool (const Nothing) Just showPasses)
|
||||
<*> previews (resultUser . _entityKey . to (examBonusPossible ?? bonus) . _sumSheetsPoints . _Wrapped) (bool (const Nothing) Just showPoints)
|
||||
<*> previews (resultUser . _entityKey . to (examBonusPossible ?? bonus) . _numSheetsPasses . _Wrapped . integral) (bool (const Nothing) Just showPasses)
|
||||
<*> previews (resultExamBonus . _entityVal . _examBonusBonus <> resultAutomaticExamBonus') (bool (const Nothing) Just doBonus)
|
||||
<*> (Map.fromList . map (over _1 examPartNumber . over (_2 . _Just) (examPartResultResult . entityVal)) <$> asks (toListOf resultExamParts))
|
||||
<*> previews (resultExamResult . _entityVal . _examResultResult <> resultAutomaticExamResult') resultView
|
||||
@ -645,7 +645,7 @@ postEUsersR tid ssh csh examn = do
|
||||
when (epNumber `elem` examPartNumbers) $
|
||||
yield $ ExamUserCsvSetPartResultData uid epNumber (Just epRes)
|
||||
|
||||
when (is _Just . join $ csvEUserBonus dbCsvNew) $
|
||||
when (doBonus && is _Just (join $ csvEUserBonus dbCsvNew)) $
|
||||
yield . ExamUserCsvSetBonusData False uid . join $ csvEUserBonus dbCsvNew
|
||||
|
||||
when (is _Just $ csvEUserExamResult dbCsvNew) $
|
||||
@ -684,15 +684,18 @@ postEUsersR tid ssh csh examn = do
|
||||
newResult = fmap resultView <$> examGrade examVal (newBonus <|> oldBonus) =<< newResults
|
||||
oldResult = dbCsvOld ^? (resultExamResult . _entityVal . _examResultResult <> resultAutomaticExamResult') . to resultView
|
||||
|
||||
case newBonus of
|
||||
_ | newBonus == oldBonus
|
||||
-> return ()
|
||||
_ | is _Nothing newBonus
|
||||
-> return ()
|
||||
Nothing
|
||||
-> yield $ ExamUserCsvSetBonusData False uid newBonus
|
||||
Just _
|
||||
-> yield $ ExamUserCsvSetBonusData True uid newBonus
|
||||
when doBonus $
|
||||
case newBonus of
|
||||
_ | newBonus == oldBonus
|
||||
-> return ()
|
||||
_ | is _Nothing newBonus
|
||||
-> return ()
|
||||
_ | Just ExamBonusManual{} <- examBonusRule
|
||||
-> yield $ ExamUserCsvSetBonusData False uid newBonus
|
||||
Nothing
|
||||
-> yield $ ExamUserCsvSetBonusData False uid newBonus
|
||||
Just _
|
||||
-> yield $ ExamUserCsvSetBonusData True uid newBonus
|
||||
|
||||
case newResult of
|
||||
_ | csvEUserExamResult dbCsvNew == oldResult
|
||||
@ -928,22 +931,31 @@ postEUsersR tid ssh csh examn = do
|
||||
guessUser :: ExamUserTableCsv -> DB (Bool, UserId)
|
||||
guessUser ExamUserTableCsv{..} = $cachedHereBinary (csvEUserMatriculation, csvEUserName, csvEUserSurname) $ do
|
||||
users <- E.select . E.from $ \user -> do
|
||||
E.where_ . E.and $ catMaybes
|
||||
E.where_ . E.or $ catMaybes
|
||||
[ (user E.^. UserMatrikelnummer E.==.) . E.val . Just <$> csvEUserMatriculation
|
||||
, (user E.^. UserDisplayName E.==.) . E.val <$> csvEUserName
|
||||
, (user E.^. UserSurname E.==.) . E.val <$> csvEUserSurname
|
||||
, (user E.^. UserFirstName E.==.) . E.val <$> csvEUserFirstName
|
||||
, (user E.^. UserDisplayName `E.hasInfix`) . E.val <$> csvEUserName
|
||||
, (user E.^. UserSurname `E.hasInfix`) . E.val <$> csvEUserSurname
|
||||
, (user E.^. UserFirstName `E.hasInfix`) . E.val <$> csvEUserFirstName
|
||||
]
|
||||
let isCourseParticipant = E.exists . E.from $ \courseParticipant ->
|
||||
E.where_ $ courseParticipant E.^. CourseParticipantCourse E.==. E.val examCourse
|
||||
E.&&. courseParticipant E.^. CourseParticipantUser E.==. user E.^. UserId
|
||||
E.limit 2
|
||||
return (isCourseParticipant, user E.^. UserId)
|
||||
case users of
|
||||
(filter . view $ _1 . _Value -> [(E.Value isPart, E.Value uid)])
|
||||
-> return (isPart, uid)
|
||||
[(E.Value isPart, E.Value uid)]
|
||||
-> return (isPart, uid)
|
||||
return (isCourseParticipant, user)
|
||||
let users' = reverse $ sortBy closeness users
|
||||
closeness :: (E.Value Bool, Entity User) -> (E.Value Bool, Entity User) -> Ordering
|
||||
closeness = mconcat $ catMaybes
|
||||
[ pure $ comparing (preview $ _2 . _entityVal . _userMatrikelnummer . only csvEUserMatriculation)
|
||||
, pure $ comparing (view _1)
|
||||
, csvEUserSurname <&> \surn -> comparing (preview $ _2 . _entityVal . _userSurname . to CI.mk . only (CI.mk surn))
|
||||
, csvEUserFirstName <&> \firstn -> comparing (preview $ _2 . _entityVal . _userFirstName . to CI.mk . only (CI.mk firstn))
|
||||
, csvEUserName <&> \dispn -> comparing (preview $ _2 . _entityVal . _userDisplayName . to CI.mk . only (CI.mk dispn))
|
||||
]
|
||||
case users' of
|
||||
[(E.Value isPart, Entity uid _)]
|
||||
-> return (isPart, uid)
|
||||
(x@(E.Value isPart, Entity uid _) : x' : _)
|
||||
| GT <- x `closeness` x'
|
||||
-> return (isPart, uid)
|
||||
_other
|
||||
-> throwM ExamUserCsvExceptionNoMatchingUser
|
||||
|
||||
|
||||
@ -1,4 +1,11 @@
|
||||
module Handler.Profile where
|
||||
module Handler.Profile
|
||||
( getProfileR, postProfileR
|
||||
, getProfileDataR, makeProfileData
|
||||
, getAuthPredsR, postAuthPredsR
|
||||
, getUserNotificationR, postUserNotificationR
|
||||
, getSetDisplayEmailR, postSetDisplayEmailR
|
||||
, getCsvOptionsR, postCsvOptionsR
|
||||
) where
|
||||
|
||||
import Import
|
||||
|
||||
@ -796,3 +803,25 @@ postSetDisplayEmailR = do
|
||||
siteLayoutMsg MsgTitleChangeUserDisplayEmail $ do
|
||||
setTitleI MsgTitleChangeUserDisplayEmail
|
||||
$(i18nWidgetFile "set-display-email")
|
||||
|
||||
getCsvOptionsR, postCsvOptionsR :: Handler Html
|
||||
getCsvOptionsR = postCsvOptionsR
|
||||
postCsvOptionsR = do
|
||||
Entity uid User{userCsvOptions} <- requireAuth
|
||||
|
||||
((optionsRes, optionsWgt'), optionsEnctype) <- runFormPost . renderAForm FormStandard $
|
||||
csvOptionsForm (fslI MsgCsvOptions & setTooltip MsgCsvOptionsTip) (Just userCsvOptions)
|
||||
|
||||
formResultModal optionsRes CsvOptionsR $ \opts -> do
|
||||
lift . runDB $ update uid [ UserCsvOptions =. opts ]
|
||||
tell . pure =<< messageI Success MsgCsvOptionsUpdated
|
||||
|
||||
siteLayoutMsg MsgCsvOptions $ do
|
||||
setTitleI MsgCsvOptions
|
||||
|
||||
isModal <- hasCustomHeader HeaderIsModal
|
||||
wrapForm optionsWgt' def
|
||||
{ formAction = Just $ SomeRoute CsvOptionsR
|
||||
, formEncoding = optionsEnctype
|
||||
, formAttrs = [ asyncSubmitAttr | isModal ]
|
||||
}
|
||||
|
||||
@ -686,7 +686,7 @@ defaultLoads shid = do
|
||||
return (sheetCorrector E.^. SheetCorrectorUser, sheetCorrector E.^. SheetCorrectorLoad, sheetCorrector E.^. SheetCorrectorState)
|
||||
where
|
||||
toMap :: [(E.Value UserId, E.Value Load, E.Value CorrectorState)] -> Loads
|
||||
toMap = foldMap $ \(E.Value uid, E.Value load, E.Value state) -> Map.singleton (Right uid) (state, load)
|
||||
toMap = foldMap $ \(E.Value uid, E.Value cLoad, E.Value cState) -> Map.singleton (Right uid) (cState, cLoad)
|
||||
|
||||
|
||||
correctorForm :: SheetId -> AForm Handler (Set (Either (Invitation' SheetCorrector) SheetCorrector))
|
||||
@ -809,7 +809,7 @@ correctorForm shid = wFormToAForm $ do
|
||||
|
||||
postProcess' :: (Either UserEmail UserId, (CorrectorState, Load)) -> Either (Invitation' SheetCorrector) SheetCorrector
|
||||
postProcess' (Right sheetCorrectorUser, (sheetCorrectorState, sheetCorrectorLoad)) = Right SheetCorrector{..}
|
||||
postProcess' (Left email, (state, load)) = Left (email, shid, (InvDBDataSheetCorrector load state, InvTokenDataSheetCorrector))
|
||||
postProcess' (Left email, (cState, load)) = Left (email, shid, (InvDBDataSheetCorrector load cState, InvTokenDataSheetCorrector))
|
||||
|
||||
filledData :: Maybe (Map ListPosition (Either UserEmail UserId, (CorrectorState, Load)))
|
||||
filledData = Just . Map.fromList . zip [0..] $ Map.toList loads -- TODO orderBy Name?!
|
||||
@ -906,7 +906,7 @@ correctorInvitationConfig = InvitationConfig{..}
|
||||
itAuthority <- liftHandler requireAuthId
|
||||
return $ InvitationTokenConfig itAuthority Nothing Nothing Nothing
|
||||
invitationRestriction _ _ = return Authorized
|
||||
invitationForm _ (InvDBDataSheetCorrector load state, _) _ = pure $ (JunctionSheetCorrector load state, ())
|
||||
invitationForm _ (InvDBDataSheetCorrector cLoad cState, _) _ = pure $ (JunctionSheetCorrector cLoad cState, ())
|
||||
invitationInsertHook _ _ _ _ = id
|
||||
invitationSuccessMsg (Entity _ Sheet{..}) _ = return . SomeMessage $ MsgCorrectorInvitationAccepted sheetName
|
||||
invitationUltDest (Entity _ Sheet{..}) _ = do
|
||||
|
||||
@ -1,480 +1,13 @@
|
||||
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
||||
|
||||
module Handler.Tutorial
|
||||
( module Handler.Tutorial
|
||||
) where
|
||||
|
||||
import Import
|
||||
import Handler.Utils
|
||||
import Handler.Utils.Tutorial
|
||||
import Handler.Utils.Delete
|
||||
import Handler.Utils.Communication
|
||||
import Handler.Utils.Form.Occurrences
|
||||
import Handler.Utils.Invitations
|
||||
import Jobs.Queue
|
||||
|
||||
import qualified Database.Esqueleto as E
|
||||
import qualified Database.Esqueleto.Utils as E
|
||||
import Database.Esqueleto.Utils.TH
|
||||
|
||||
import Data.Map ((!))
|
||||
import qualified Data.Map as Map
|
||||
import qualified Data.Set as Set
|
||||
|
||||
import qualified Data.CaseInsensitive as CI
|
||||
|
||||
import Data.Aeson hiding (Result(..))
|
||||
import Text.Hamlet (ihamlet)
|
||||
|
||||
import Handler.Tutorial.Users as Handler.Tutorial
|
||||
|
||||
{-# ANN module ("Hlint: ignore Redundant void" :: String) #-}
|
||||
|
||||
|
||||
getCTutorialListR :: TermId -> SchoolId -> CourseShorthand -> Handler Html
|
||||
getCTutorialListR tid ssh csh = do
|
||||
Entity cid Course{..} <- runDB . getBy404 $ TermSchoolCourseShort tid ssh csh
|
||||
|
||||
let
|
||||
tutorialDBTable = DBTable{..}
|
||||
where
|
||||
dbtSQLQuery tutorial = do
|
||||
E.where_ $ tutorial E.^. TutorialCourse E.==. E.val cid
|
||||
let participants = E.sub_select . E.from $ \tutorialParticipant -> do
|
||||
E.where_ $ tutorialParticipant E.^. TutorialParticipantTutorial E.==. tutorial E.^. TutorialId
|
||||
return E.countRows :: E.SqlQuery (E.SqlExpr (E.Value Int))
|
||||
return (tutorial, participants)
|
||||
dbtRowKey = (E.^. TutorialId)
|
||||
dbtProj = return . over (_dbrOutput . _2) E.unValue
|
||||
dbtColonnade = dbColonnade $ mconcat
|
||||
[ sortable (Just "type") (i18nCell MsgTutorialType) $ \DBRow{ dbrOutput = (Entity _ Tutorial{..}, _) } -> textCell $ CI.original tutorialType
|
||||
, sortable (Just "name") (i18nCell MsgTutorialName) $ \DBRow{ dbrOutput = (Entity _ Tutorial{..}, _) } -> anchorCell (CTutorialR tid ssh csh tutorialName TUsersR) [whamlet|#{tutorialName}|]
|
||||
, sortable Nothing (i18nCell MsgTutorialTutors) $ \DBRow{ dbrOutput = (Entity tutid _, _) } -> sqlCell $ do
|
||||
tutors <- fmap (map $(unValueN 3)) . E.select . E.from $ \(tutor `E.InnerJoin` user) -> do
|
||||
E.on $ tutor E.^. TutorUser E.==. user E.^. UserId
|
||||
E.where_ $ tutor E.^. TutorTutorial E.==. E.val tutid
|
||||
return (user E.^. UserEmail, user E.^. UserDisplayName, user E.^. UserSurname)
|
||||
return [whamlet|
|
||||
$newline never
|
||||
<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")
|
||||
import Handler.Tutorial.Communication as Handler.Tutorial
|
||||
import Handler.Tutorial.Delete as Handler.Tutorial
|
||||
import Handler.Tutorial.Edit as Handler.Tutorial
|
||||
import Handler.Tutorial.Form as Handler.Tutorial
|
||||
import Handler.Tutorial.List as Handler.Tutorial
|
||||
import Handler.Tutorial.New as Handler.Tutorial
|
||||
import Handler.Tutorial.Register as Handler.Tutorial
|
||||
import Handler.Tutorial.TutorInvite as Handler.Tutorial
|
||||
import Handler.Tutorial.Users as Handler.Tutorial
|
||||
|
||||
81
src/Handler/Tutorial/Communication.hs
Normal file
81
src/Handler/Tutorial/Communication.hs
Normal 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
|
||||
}
|
||||
39
src/Handler/Tutorial/Delete.hs
Normal file
39
src/Handler/Tutorial/Delete.hs
Normal 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
|
||||
}
|
||||
92
src/Handler/Tutorial/Edit.hs
Normal file
92
src/Handler/Tutorial/Edit.hs
Normal 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")
|
||||
96
src/Handler/Tutorial/Form.hs
Normal file
96
src/Handler/Tutorial/Form.hs
Normal 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
|
||||
87
src/Handler/Tutorial/List.hs
Normal file
87
src/Handler/Tutorial/List.hs
Normal 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")
|
||||
60
src/Handler/Tutorial/New.hs
Normal file
60
src/Handler/Tutorial/New.hs
Normal 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")
|
||||
28
src/Handler/Tutorial/Register.hs
Normal file
28
src/Handler/Tutorial/Register.hs
Normal 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"]
|
||||
79
src/Handler/Tutorial/TutorInvite.hs
Normal file
79
src/Handler/Tutorial/TutorInvite.hs
Normal 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
|
||||
@ -73,6 +73,7 @@ postAdminUserAddR = do
|
||||
, userWarningDays = userDefaultWarningDays
|
||||
, userNotificationSettings = def
|
||||
, userMailLanguages = def
|
||||
, userCsvOptions = def
|
||||
, userTokensIssuedAfter = Nothing
|
||||
, userCreated = now
|
||||
, userLastLdapSynchronisation = Nothing
|
||||
|
||||
@ -150,9 +150,9 @@ commR CommunicationRoute{..} = do
|
||||
-> Map (EnumPosition RecipientCategory, ListPosition) (FieldView UniWorX)
|
||||
-> Map (Natural, (EnumPosition RecipientCategory, ListPosition)) Widget
|
||||
-> Widget
|
||||
miLayout liveliness state cellWdgts _delButtons addWdgts = do
|
||||
miLayout liveliness cState cellWdgts _delButtons addWdgts = do
|
||||
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
|
||||
hasContent c = not (null $ categoryIndices c) || Map.member (1, (EnumPosition c, 0)) addWdgts
|
||||
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 = Set.fromList . map fst . filter snd . Map.elems
|
||||
|
||||
recipientsListMsg <- messageI Info MsgCommRecipientsList
|
||||
|
||||
((commRes,commWdgt),commEncoding) <- runFormPost . identifyForm FIDCommunication . renderAForm FormStandard $ Communication
|
||||
<$> recipientAForm
|
||||
<* aformMessage recipientsListMsg
|
||||
<*> aopt textField (fslI MsgCommSubject) Nothing
|
||||
<*> areq htmlField (fslpI MsgCommBody "Html") Nothing
|
||||
<*> areq htmlField (fslpI MsgCommBody "Html" & setTooltip MsgCommBodyTip) Nothing
|
||||
formResult commRes $ \comm -> do
|
||||
runDBJobs . runConduit $ transPipe (mapReaderT lift) (crJobs comm) .| sinkDBJobs
|
||||
addMessageI Success . MsgCommSuccess . Set.size $ cRecipients comm
|
||||
@ -183,4 +186,3 @@ commR CommunicationRoute{..} = do
|
||||
siteLayoutMsg crHeading $ do
|
||||
setTitleI crHeading
|
||||
formWdgt
|
||||
$(i18nWidgetFile "html-input")
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
||||
|
||||
module Handler.Utils.Csv
|
||||
( typeCsv, extensionCsv
|
||||
, decodeCsv
|
||||
( decodeCsv
|
||||
, encodeCsv
|
||||
, encodeDefaultOrderedCsv
|
||||
, respondCsv, respondCsvDB
|
||||
@ -12,9 +11,6 @@ module Handler.Utils.Csv
|
||||
, ToNamedRecord(..), FromNamedRecord(..)
|
||||
, DefaultOrdered(..)
|
||||
, ToField(..), FromField(..)
|
||||
, CsvRendered(..)
|
||||
, toCsvRendered
|
||||
, toDefaultOrderedCsvRendered
|
||||
) where
|
||||
|
||||
import Import hiding (Header, mapM_)
|
||||
@ -40,18 +36,6 @@ import qualified Data.ByteString.Lazy as LBS
|
||||
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 = transPipe throwExceptT $ do
|
||||
testBuffer <- accumTestBuffer LBS.empty
|
||||
@ -114,19 +98,23 @@ decodeCsv = transPipe throwExceptT $ do
|
||||
|
||||
|
||||
encodeCsv :: ( ToNamedRecord csv
|
||||
, Monad m
|
||||
, MonadHandler m
|
||||
, HandlerSite m ~ UniWorX
|
||||
)
|
||||
=> Header
|
||||
-> ConduitT csv ByteString m ()
|
||||
-- ^ Encode a stream of records
|
||||
--
|
||||
-- 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.
|
||||
( ToNamedRecord csv
|
||||
, DefaultOrdered csv
|
||||
, Monad m
|
||||
, MonadHandler m
|
||||
, HandlerSite m ~ UniWorX
|
||||
)
|
||||
=> ConduitT csv ByteString m ()
|
||||
encodeDefaultOrderedCsv = encodeCsv $ headerOrder (error "headerOrder" :: csv)
|
||||
@ -134,33 +122,30 @@ encodeDefaultOrderedCsv = encodeCsv $ headerOrder (error "headerOrder" :: csv)
|
||||
|
||||
respondCsv :: ToNamedRecord csv
|
||||
=> Header
|
||||
-> ConduitT () csv (HandlerFor site) ()
|
||||
-> HandlerFor site TypedContent
|
||||
-> ConduitT () csv Handler ()
|
||||
-> Handler TypedContent
|
||||
respondCsv hdr src = respondSource typeCsv' $ src .| encodeCsv hdr .| awaitForever sendChunk
|
||||
|
||||
respondDefaultOrderedCsv :: forall csv site.
|
||||
respondDefaultOrderedCsv :: forall csv.
|
||||
( ToNamedRecord csv
|
||||
, DefaultOrdered csv
|
||||
)
|
||||
=> ConduitT () csv (HandlerFor site) ()
|
||||
-> HandlerFor site TypedContent
|
||||
=> ConduitT () csv Handler ()
|
||||
-> Handler TypedContent
|
||||
respondDefaultOrderedCsv = respondCsv $ headerOrder (error "headerOrder" :: csv)
|
||||
|
||||
respondCsvDB :: ( ToNamedRecord csv
|
||||
, YesodPersistRunner site
|
||||
)
|
||||
respondCsvDB :: ToNamedRecord csv
|
||||
=> Header
|
||||
-> ConduitT () csv (YesodDB site) ()
|
||||
-> HandlerFor site TypedContent
|
||||
-> ConduitT () csv DB ()
|
||||
-> Handler TypedContent
|
||||
respondCsvDB hdr src = respondSourceDB typeCsv' $ src .| encodeCsv hdr .| awaitForever sendChunk
|
||||
|
||||
respondDefaultOrderedCsvDB :: forall csv site.
|
||||
respondDefaultOrderedCsvDB :: forall csv.
|
||||
( ToNamedRecord csv
|
||||
, DefaultOrdered csv
|
||||
, YesodPersistRunner site
|
||||
)
|
||||
=> ConduitT () csv (YesodDB site) ()
|
||||
-> HandlerFor site TypedContent
|
||||
=> ConduitT () csv DB ()
|
||||
-> Handler TypedContent
|
||||
respondDefaultOrderedCsvDB = respondCsvDB $ headerOrder (error "headerOrder" :: csv)
|
||||
|
||||
fileSourceCsv :: ( FromNamedRecord csv
|
||||
@ -173,11 +158,6 @@ fileSourceCsv :: ( FromNamedRecord csv
|
||||
fileSourceCsv = (.| decodeCsv) . fileSource
|
||||
|
||||
|
||||
data CsvRendered = CsvRendered
|
||||
{ csvRenderedHeader :: Header
|
||||
, csvRenderedData :: [NamedRecord]
|
||||
} deriving (Eq, Read, Show, Generic, Typeable)
|
||||
|
||||
instance ToWidget UniWorX CsvRendered where
|
||||
toWidget CsvRendered{..} = liftWidget $(widgetFile "widgets/csvRendered")
|
||||
where
|
||||
@ -188,21 +168,3 @@ instance ToWidget UniWorX CsvRendered where
|
||||
]
|
||||
|
||||
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)
|
||||
|
||||
@ -78,12 +78,12 @@ examBonus (Entity eId Exam{..}) = runConduit $
|
||||
)
|
||||
return (examRegistration E.^. ExamRegistrationUser, sheet E.^. SheetType, submission)
|
||||
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
|
||||
|
||||
examBonusPossible, examBonusAchieved :: UserId -> Map UserId SheetTypeSummary -> Maybe SheetGradeSummary
|
||||
examBonusPossible uid bonusMap = normalSummary <$> Map.lookup uid bonusMap
|
||||
examBonusAchieved uid bonusMap = (mappend <$> normalSummary <*> bonusSummary) <$> Map.lookup uid bonusMap
|
||||
examBonusPossible, examBonusAchieved :: UserId -> Map UserId SheetTypeSummary -> SheetGradeSummary
|
||||
examBonusPossible uid bonusMap = normalSummary $ Map.findWithDefault mempty uid bonusMap
|
||||
examBonusAchieved uid bonusMap = mappend <$> normalSummary <*> bonusSummary $ Map.findWithDefault mempty uid bonusMap
|
||||
|
||||
|
||||
examResultBonus :: ExamBonusRule
|
||||
@ -91,6 +91,8 @@ examResultBonus :: ExamBonusRule
|
||||
-> SheetGradeSummary -- ^ `examBonusAchieved`
|
||||
-> Points
|
||||
examResultBonus bonusRule bonusPossible bonusAchieved = case bonusRule of
|
||||
ExamBonusManual{}
|
||||
-> 0
|
||||
ExamBonusPoints{..}
|
||||
-> roundToPoints bonusRound $ toRational bonusMaxPoints * bonusProp
|
||||
where
|
||||
|
||||
@ -12,6 +12,7 @@ import Handler.Utils.Form.Types
|
||||
import Handler.Utils.DateTime
|
||||
|
||||
import Import
|
||||
import Data.Char (chr, ord)
|
||||
import qualified Data.Char as Char
|
||||
import qualified Data.Text as Text
|
||||
import qualified Data.CaseInsensitive as CI
|
||||
@ -220,11 +221,7 @@ multiAction :: forall action a.
|
||||
-> Maybe action
|
||||
-> (Html -> MForm Handler (FormResult a, [FieldView UniWorX]))
|
||||
multiAction acts fs@FieldSettings{..} defAction csrf = do
|
||||
mr <- getMessageRender
|
||||
|
||||
let
|
||||
options = OptionList [ Option (mr a) a (toPathPiece a) | a <- Map.keys acts ] fromPathPiece
|
||||
(actionRes, actionView) <- mreq (selectField $ return options) fs defAction
|
||||
(actionRes, actionView) <- mreq (selectField . optionsF $ Map.keysSet acts) fs defAction
|
||||
results <- mapM (fmap (over _2 ($ [])) . aFormToForm) acts
|
||||
|
||||
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)
|
||||
instance Universe ExamBonusRule'
|
||||
instance Finite ExamBonusRule'
|
||||
@ -530,6 +528,7 @@ embedRenderMessage ''UniWorX ''ExamBonusRule' id
|
||||
|
||||
classifyBonusRule :: ExamBonusRule -> ExamBonusRule'
|
||||
classifyBonusRule = \case
|
||||
ExamBonusManual{} -> ExamBonusManual'
|
||||
ExamBonusPoints{} -> ExamBonusPoints'
|
||||
|
||||
examBonusRuleForm :: Maybe ExamBonusRule -> AForm Handler ExamBonusRule
|
||||
@ -537,7 +536,11 @@ examBonusRuleForm prev = multiActionA actions (fslI MsgExamBonusRule) $ classify
|
||||
where
|
||||
actions :: Map ExamBonusRule' (AForm Handler ExamBonusRule)
|
||||
actions = Map.fromList
|
||||
[ ( ExamBonusPoints'
|
||||
[ ( ExamBonusManual'
|
||||
, ExamBonusManual
|
||||
<$> (fromMaybe False <$> aopt checkBoxField (fslI MsgExamBonusOnlyPassed) (Just <$> preview _bonusOnlyPassed =<< prev))
|
||||
)
|
||||
, ( ExamBonusPoints'
|
||||
, ExamBonusPoints
|
||||
<$> apreq (checkBool (> 0) MsgExamBonusMaxPointsNonPositive pointsField) (fslI MsgExamBonusMaxPoints & setTooltip MsgExamBonusMaxPointsTip) (preview _bonusMaxPoints =<< prev)
|
||||
<*> (fromMaybe False <$> aopt checkBoxField (fslI MsgExamBonusOnlyPassed) (Just <$> preview _bonusOnlyPassed =<< prev))
|
||||
@ -1193,3 +1196,84 @@ examPassedField :: forall m.
|
||||
)
|
||||
=> Field m ExamPassed
|
||||
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'
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
module Handler.Utils.Mail
|
||||
( addRecipientsDB
|
||||
, userAddress
|
||||
, userAddress, userAddressFrom
|
||||
, userMailT
|
||||
, addFileDB
|
||||
) where
|
||||
@ -28,7 +28,16 @@ addRecipientsDB uFilter = runConduit $ transPipe (liftHandler . runDB) (selectSo
|
||||
let addr = Address (Just userDisplayName) $ CI.original userEmail
|
||||
_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
|
||||
-- ^ Format an e-mail address suitable for usage as a recipient
|
||||
--
|
||||
-- Uses `userEmail`
|
||||
userAddress User{userEmail, userDisplayName} = Address (Just userDisplayName) $ CI.original userEmail
|
||||
|
||||
userMailT :: ( MonadHandler m
|
||||
|
||||
@ -20,7 +20,6 @@ import Control.Monad.State.Class as State
|
||||
import Control.Monad.Writer (MonadWriter(..), execWriterT, execWriter)
|
||||
import Control.Monad.RWS.Lazy (MonadRWS, RWST, execRWST)
|
||||
import qualified Control.Monad.Random as Rand
|
||||
import qualified System.Random.Shuffle as Rand (shuffleM)
|
||||
|
||||
import Data.Maybe ()
|
||||
|
||||
@ -248,9 +247,6 @@ planSubmissions sid restriction = do
|
||||
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
|
||||
|
||||
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 = E.selectSource . fmap snd . E.from . submissionFileQuery
|
||||
|
||||
@ -49,6 +49,7 @@ import Handler.Utils.Form
|
||||
import Handler.Utils.Csv
|
||||
import Handler.Utils.ContentDisposition
|
||||
import Handler.Utils.I18n
|
||||
import Handler.Utils.Widgets
|
||||
import Utils
|
||||
import Utils.Lens
|
||||
|
||||
|
||||
@ -96,3 +96,9 @@ editedByW fmt tm usr = do
|
||||
heat :: Integral a => a -> a -> Double
|
||||
heat (toInteger -> full) (toInteger -> achieved)
|
||||
= 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))
|
||||
|
||||
@ -84,6 +84,14 @@ import Control.Monad.Trans.Reader as Import
|
||||
( reader, Reader, runReader, mapReader, withReader
|
||||
, 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.Catch as Import hiding (Handler(..))
|
||||
import Control.Monad.Trans.Control as Import hiding (embed)
|
||||
|
||||
41
src/Jobs.hs
41
src/Jobs.hs
@ -86,6 +86,7 @@ instance Exception JobQueueException
|
||||
handleJobs :: ( MonadResource m
|
||||
, MonadLogger m
|
||||
, MonadUnliftIO m
|
||||
, MonadMask m
|
||||
)
|
||||
=> UniWorX -> m ()
|
||||
-- | 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
|
||||
UnliftIO{..} <- askUnliftIO
|
||||
|
||||
jobPoolManager <- allocateLinkedAsync . unliftIO $ manageJobPool foundation
|
||||
jobPoolManager <- allocateLinkedAsyncWithUnmask $ \unmask -> unliftIO $ manageJobPool foundation unmask
|
||||
|
||||
jobCron <- allocateLinkedAsync . unliftIO $ manageCrontab foundation
|
||||
|
||||
@ -129,20 +130,40 @@ manageJobPool :: forall m.
|
||||
( MonadResource m
|
||||
, MonadLogger m
|
||||
, MonadUnliftIO m
|
||||
, MonadMask m
|
||||
)
|
||||
=> UniWorX -> m ()
|
||||
manageJobPool foundation@UniWorX{..}
|
||||
= flip runContT return . forever . join . atomically $ asum
|
||||
[ spawnMissingWorkers
|
||||
, reapDeadWorkers
|
||||
, terminateGracefully
|
||||
]
|
||||
=> UniWorX -> (forall a. IO a -> IO a) -> m ()
|
||||
manageJobPool foundation@UniWorX{..} unmask = shutdownOnException $
|
||||
flip runContT return . forever . join . atomically $ asum
|
||||
[ spawnMissingWorkers
|
||||
, reapDeadWorkers
|
||||
, terminateGracefully
|
||||
]
|
||||
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 = fromIntegral $ foundation ^. _appJobWorkers
|
||||
|
||||
spawnMissingWorkers, reapDeadWorkers, terminateGracefully :: STM (ContT () m ())
|
||||
spawnMissingWorkers = do
|
||||
shouldTerminate' <- readTMVar appJobState >>= fmap not . isEmptyTMVar . jobShutdown
|
||||
guard $ not shouldTerminate'
|
||||
|
||||
oldState <- takeTMVar appJobState
|
||||
let missing = num - Map.size (jobWorkers oldState)
|
||||
guard $ missing > 0
|
||||
@ -204,6 +225,10 @@ manageJobPool foundation@UniWorX{..}
|
||||
terminateGracefully = do
|
||||
shouldTerminate <- readTMVar appJobState >>= fmap not . isEmptyTMVar . jobShutdown
|
||||
guard shouldTerminate
|
||||
|
||||
oldState <- takeTMVar appJobState
|
||||
guard $ 0 == Map.size (jobWorkers oldState)
|
||||
|
||||
return . callCC $ \terminate -> do
|
||||
$logInfoS "JobPoolManager" "Shutting down"
|
||||
terminate ()
|
||||
|
||||
@ -20,7 +20,7 @@ dispatchJobInvitation jInviter jInvitee jInvitationUrl jInvitationSubject jInvit
|
||||
|
||||
whenIsJust mInviter $ \jInviter' -> mailT def $ do
|
||||
_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 "Subject" $ Just jInvitationSubject
|
||||
addPart ($(ihamletFile "templates/mail/invitation.hamlet") :: HtmlUrlI18n UniWorXMessage (Route UniWorX))
|
||||
|
||||
@ -6,8 +6,6 @@ import Import
|
||||
|
||||
import Handler.Utils
|
||||
|
||||
import qualified Data.Set as Set
|
||||
|
||||
import qualified Data.CaseInsensitive as CI
|
||||
|
||||
|
||||
@ -24,13 +22,15 @@ dispatchJobSendCourseCommunication jRecipientEmail jAllRecipientAddresses jCours
|
||||
<$> getJust jSender
|
||||
<*> getJust jCourse
|
||||
either (\email -> mailT def . (assign _mailTo (pure . Address Nothing $ CI.original email) *>)) userMailT jRecipientEmail $ do
|
||||
MsgRenderer mr <- getMailMsgRenderer
|
||||
|
||||
void $ setMailObjectUUID jMailObjectUUID
|
||||
_mailFrom .= userAddress sender
|
||||
if -- Use `addMailHeader` instead of `_mailCc` to make `mailT` ignore the additional recipients
|
||||
| jRecipientEmail == Right jSender
|
||||
-> addMailHeader "Cc" . intercalate ", " . map renderAddress $ Set.toAscList (Set.delete (userAddress sender) jAllRecipientAddresses)
|
||||
| otherwise
|
||||
-> addMailHeader "Cc" "Undisclosed Recipients:;"
|
||||
_mailFrom .= userAddressFrom sender
|
||||
addMailHeader "Cc" [st|#{mr MsgCommUndisclosedRecipients}:;|]
|
||||
addMailHeader "Auto-Submitted" "no"
|
||||
setSubjectI . prependCourseTitle courseTerm courseSchool courseShorthand $ maybe (SomeMessage MsgCommCourseSubject) SomeMessage jSubject
|
||||
void $ addPart jMailContent
|
||||
when (jRecipientEmail == Right jSender) $
|
||||
addPart' $ do
|
||||
partIsAttachment $ unpack (mr MsgCommAllRecipients) `addExtension` unpack extensionCsv
|
||||
toMailPart (toDefaultOrderedCsvRendered jAllRecipientAddresses, userCsvOptions sender)
|
||||
|
||||
@ -23,7 +23,7 @@ dispatchNotificationSubmissionRated nSubmission jRecipient = userMailT jRecipien
|
||||
return (course, sheet, submission, corrector)
|
||||
|
||||
whenIsJust corrector $ \corrector' ->
|
||||
addMailHeader "Reply-To" . renderAddress $ userAddress corrector'
|
||||
addMailHeader "Reply-To" . renderAddress $ userAddressFrom corrector'
|
||||
replaceMailHeader "Auto-Submitted" $ Just "auto-generated"
|
||||
setSubjectI $ MsgMailSubjectSubmissionRated courseShorthand
|
||||
|
||||
|
||||
34
src/Mail.hs
34
src/Mail.hs
@ -21,7 +21,7 @@ module Mail
|
||||
, PrioritisedAlternatives
|
||||
, ToMailPart(..)
|
||||
, addAlternatives, provideAlternative, providePreferredAlternative
|
||||
, addPart
|
||||
, addPart, addPart', modifyPart, partIsAttachment
|
||||
, MonadHeader(..)
|
||||
, MailHeader
|
||||
, MailObjectId
|
||||
@ -43,6 +43,8 @@ import Model.Types.TH.JSON
|
||||
import Network.Mail.Mime hiding (addPart, addAttachment)
|
||||
import qualified Network.Mail.Mime as Mime (addPart)
|
||||
|
||||
import Settings.Mime
|
||||
|
||||
import Data.Monoid (Last(..))
|
||||
import Control.Monad.Trans.RWS (RWST(..))
|
||||
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.Lens.TH
|
||||
|
||||
import Control.Lens hiding (from)
|
||||
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
|
||||
toMailPart text = do
|
||||
_partType .= "text/plain; charset=utf-8"
|
||||
_partType .= decodeUtf8 typePlain
|
||||
_partEncoding .= QuotedPrintableText
|
||||
_partContent .= encodeUtf8 text
|
||||
|
||||
@ -348,7 +351,7 @@ instance YesodMail site => ToMailPart site LTB.Builder where
|
||||
|
||||
instance YesodMail site => ToMailPart site Html where
|
||||
toMailPart html = do
|
||||
_partType .= "text/html; charset=utf-8"
|
||||
_partType .= decodeUtf8 typeHtml
|
||||
_partEncoding .= QuotedPrintableText
|
||||
_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
|
||||
toMailPart val = do
|
||||
_partType .= "application/json; charset=utf-8"
|
||||
_partType .= decodeUtf8 typeJson
|
||||
_partEncoding .= QuotedPrintableText
|
||||
_partContent .= Aeson.encodePretty val
|
||||
|
||||
@ -396,20 +399,35 @@ addPart :: ( MonadMail m
|
||||
, HandlerSite m ~ site
|
||||
, ToMailPart site a
|
||||
) => a -> m (MailPartReturn site a)
|
||||
addPart part = do
|
||||
(ret, part') <- runStateT (toMailPart part) initialPart
|
||||
addPart = addPart' . toMailPart
|
||||
|
||||
addPart' :: MonadMail m
|
||||
=> StateT Part m a
|
||||
-> m a
|
||||
addPart' part = do
|
||||
(ret, part') <- runStateT part initialPart
|
||||
modify . Mime.addPart $ pure part'
|
||||
return ret
|
||||
|
||||
initialPart :: Part
|
||||
initialPart = Part
|
||||
{ partType = "text/plain"
|
||||
, partEncoding = None
|
||||
{ partType = decodeUtf8 defaultMimeType
|
||||
, partEncoding = Base64
|
||||
, partFilename = Nothing
|
||||
, partHeaders = []
|
||||
, 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
|
||||
modifyHeaders :: (Headers -> Headers) -> m ()
|
||||
|
||||
@ -13,6 +13,7 @@ module Model.Types.Exam
|
||||
, ExamOccurrenceRule(..)
|
||||
, ExamGrade(..)
|
||||
, numberGrade
|
||||
, ExamGradeDefCenter(..)
|
||||
, ExamGradingRule(..)
|
||||
, ExamPassed(..)
|
||||
, passingGrade
|
||||
@ -116,7 +117,10 @@ instance Universe res => Universe (ExamResult' res) where
|
||||
instance Finite res => Finite (ExamResult' res)
|
||||
|
||||
|
||||
data ExamBonusRule = ExamBonusPoints
|
||||
data ExamBonusRule = ExamBonusManual
|
||||
{ bonusOnlyPassed :: Bool
|
||||
}
|
||||
| ExamBonusPoints
|
||||
{ bonusMaxPoints :: Points
|
||||
, bonusOnlyPassed :: Bool
|
||||
, bonusRound :: Points
|
||||
@ -215,6 +219,15 @@ instance PersistFieldSql ExamGrade where
|
||||
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
|
||||
= 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@
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
||||
|
||||
{-|
|
||||
Module: Model.Types.Misc
|
||||
Description: Additional uncategorized types
|
||||
@ -5,6 +7,7 @@ Description: Additional uncategorized types
|
||||
|
||||
module Model.Types.Misc
|
||||
( module Model.Types.Misc
|
||||
, Quoting(..)
|
||||
) where
|
||||
|
||||
import Import.NoModel
|
||||
@ -14,6 +17,11 @@ import Data.Maybe (fromJust)
|
||||
import qualified Data.Text 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
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
@ -14,6 +14,8 @@ import Data.Aeson.TH
|
||||
import Utils.PathPiece
|
||||
|
||||
import Utils (assertM)
|
||||
|
||||
import qualified Data.Csv as Csv
|
||||
|
||||
|
||||
deriving instance Read Address
|
||||
@ -32,3 +34,13 @@ instance FromJSON Address where
|
||||
addressName <- assertM (not . null) <$> (obj .:? "name")
|
||||
addressEmail <- obj .: "email"
|
||||
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" ]
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
module Settings
|
||||
( module Settings
|
||||
, module Settings.Cluster
|
||||
, module Settings.Mime
|
||||
) where
|
||||
|
||||
import Import.NoModel
|
||||
@ -58,6 +59,7 @@ import qualified Database.Memcached.Binary.Types as Memcached
|
||||
|
||||
import Model
|
||||
import Settings.Cluster
|
||||
import Settings.Mime
|
||||
|
||||
import Control.Monad.Trans.Maybe (MaybeT(..))
|
||||
|
||||
@ -67,10 +69,6 @@ import Jose.Jwt (JwtEncoding(..))
|
||||
|
||||
import System.FilePath.Glob
|
||||
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
|
||||
@ -458,18 +456,6 @@ widgetFileSettings = def
|
||||
submissionBlacklist :: [Pattern]
|
||||
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
|
||||
-- user.
|
||||
|
||||
|
||||
31
src/Settings/Mime.hs
Normal file
31
src/Settings/Mime.hs
Normal 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")
|
||||
@ -1,5 +1,7 @@
|
||||
module UnliftIO.Async.Utils
|
||||
( allocateAsync, allocateLinkedAsync
|
||||
, allocateAsyncWithUnmask, allocateLinkedAsyncWithUnmask
|
||||
, allocateAsyncMasked, allocateLinkedAsyncMasked
|
||||
) where
|
||||
|
||||
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 = 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
|
||||
|
||||
19
src/Utils.hs
19
src/Utils.hs
@ -85,6 +85,9 @@ import Algebra.Lattice (top, bottom, (/\), (\/), BoundedJoinSemiLattice, Bounded
|
||||
|
||||
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) #-}
|
||||
|
||||
|
||||
@ -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 = setOf folded . imap (,)
|
||||
|
||||
|
||||
mapF :: (Ord k, Finite k) => (k -> v) -> Map k v
|
||||
mapF = flip Map.fromSet $ Set.fromList universeF
|
||||
|
||||
---------------
|
||||
-- Functions --
|
||||
@ -953,3 +957,16 @@ clampMin, clampMax :: Ord a
|
||||
-> a -- ^ Clamped Value
|
||||
clampMin = max
|
||||
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
|
||||
|
||||
@ -1,14 +1,39 @@
|
||||
{-# OPTIONS -fno-warn-orphans #-}
|
||||
|
||||
module Utils.Csv
|
||||
( pathPieceCsv
|
||||
( typeCsv, typeCsv', extensionCsv
|
||||
, pathPieceCsv
|
||||
, (.:??)
|
||||
, CsvRendered(..)
|
||||
, toCsvRendered
|
||||
, toDefaultOrderedCsvRendered
|
||||
) where
|
||||
|
||||
import ClassyPrelude hiding (lookup)
|
||||
import Settings.Mime
|
||||
|
||||
import Data.Csv hiding (Name)
|
||||
import Data.Csv.Conduit (CsvParseError)
|
||||
|
||||
import Language.Haskell.TH (Name)
|
||||
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 (conT -> t) =
|
||||
@ -22,3 +47,27 @@ pathPieceCsv (conT -> t) =
|
||||
|
||||
(.:??) :: FromField (Maybe a) => NamedRecord -> ByteString -> Parser (Maybe a)
|
||||
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)
|
||||
|
||||
@ -328,7 +328,7 @@ combinedButtonField :: forall a m.
|
||||
) => [a] -> FieldSettings (HandlerSite m) -> AForm m [Maybe a]
|
||||
combinedButtonField bs FieldSettings{..} = formToAForm $ do
|
||||
mr <- getMessageRender
|
||||
fvId <- maybe newFormIdent return fsId
|
||||
fvId <- maybe newIdent return fsId
|
||||
name <- maybe newFormIdent return fsName
|
||||
(ress, fvs) <- fmap unzip . for bs $ \b -> mopt (buttonField b) ("" { fsId = Just $ fvId <> "__" <> toPathPiece b
|
||||
, fsName = Just $ name <> "__" <> toPathPiece b
|
||||
@ -491,6 +491,24 @@ reorderField optList = Field{..}
|
||||
withNum t n = tshow n <> "." <> t
|
||||
$(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
|
||||
, RenderMessage site (Element mono)
|
||||
, HandlerSite m ~ site
|
||||
@ -498,16 +516,7 @@ optionsF :: ( MonadHandler m
|
||||
, MonoFoldable mono
|
||||
)
|
||||
=> mono -> m (OptionList (Element mono))
|
||||
optionsF (otoList -> opts) = do
|
||||
mr <- getMessageRender
|
||||
let
|
||||
mkOption a = Option
|
||||
{ optionDisplay = mr a
|
||||
, optionInternalValue = a
|
||||
, optionExternalValue = toPathPiece a
|
||||
}
|
||||
return . mkOptionList $ mkOption <$> opts
|
||||
|
||||
optionsF = optionsPathPiece . map (id &&& id) . otoList
|
||||
|
||||
optionsFinite :: ( MonadHandler m
|
||||
, Finite a
|
||||
|
||||
@ -7,6 +7,7 @@ import ClassyPrelude.Yesod
|
||||
import Database.PostgreSQL.Simple (SqlError(SqlError), sqlErrorHint)
|
||||
import Control.Monad.Catch (MonadMask)
|
||||
|
||||
import Database.Persist.Sql
|
||||
import Database.Persist.Sql.Raw.QQ
|
||||
|
||||
import Control.Retry
|
||||
@ -14,20 +15,22 @@ import Control.Retry
|
||||
import Control.Lens ((&))
|
||||
|
||||
|
||||
retryTransaction :: forall m a. (MonadLogger m, MonadMask m, MonadIO m) => m a -> m a
|
||||
retryTransaction = recovering policy [logRetries suggestRetry logRetry] . const
|
||||
setSerializable :: forall m a. (MonadLogger m, MonadMask m, MonadIO m) => ReaderT SqlBackend m a -> ReaderT SqlBackend m a
|
||||
setSerializable act = recovering policy [logRetries suggestRetry logRetry] act'
|
||||
where
|
||||
policy :: RetryPolicyM m
|
||||
policy :: RetryPolicyM (ReaderT SqlBackend m)
|
||||
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
|
||||
|
||||
logRetry :: Bool -- ^ Will retry
|
||||
-> SqlError
|
||||
-> RetryStatus
|
||||
-> m ()
|
||||
-> ReaderT SqlBackend m ()
|
||||
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
|
||||
setSerializable act = retryTransaction $ [executeQQ|SET TRANSACTION ISOLATION LEVEL SERIALIZABLE|] *> act
|
||||
act' :: RetryStatus -> ReaderT SqlBackend m a
|
||||
act' RetryStatus{..}
|
||||
| rsIterNumber == 0 = [executeQQ|SET TRANSACTION ISOLATION LEVEL SERIALIZABLE|] *> act
|
||||
| otherwise = transactionUndoWithIsolation Serializable *> act
|
||||
|
||||
@ -26,6 +26,8 @@ import Control.Monad.Trans.Reader (ReaderT, mapReaderT, runReaderT)
|
||||
import Control.Monad.Base (MonadBase)
|
||||
import Control.Monad.Trans.Control (MonadBaseControl)
|
||||
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)
|
||||
@ -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 (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`
|
||||
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
|
||||
, MonadResource, MonadHandler, MonadWidget
|
||||
)
|
||||
deriving newtype ( MFunctor, MMonad, MonadTrans )
|
||||
|
||||
deriving newtype instance MonadBase b m => MonadBase 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
|
||||
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@
|
||||
@ -69,3 +76,7 @@ runCachedMemoT :: Q Exp
|
||||
runCachedMemoT = do
|
||||
loc <- location
|
||||
[e| flip runReaderT loc . runCachedMemoT' |]
|
||||
|
||||
|
||||
instance site ~ site' => ToWidget site (SomeMessage site') where
|
||||
toWidget msg = toWidget =<< (getMessageRender <*> pure msg)
|
||||
|
||||
@ -56,5 +56,7 @@ extra-deps:
|
||||
|
||||
- persistent-qq-2.9.1
|
||||
|
||||
- process-1.6.5.1
|
||||
|
||||
resolver: lts-13.21
|
||||
allow-newer: true
|
||||
|
||||
@ -1,22 +1,28 @@
|
||||
$newline never
|
||||
$if not (null allocationsBounds)
|
||||
<h2>_{MsgCourseAllocationsBounds (length allocationsBounds)}
|
||||
<dl .deflist>
|
||||
$forall (Allocation{allocationName, allocationRegisterTo}, numApps, numFirstChoice, capped) <- allocationsBounds
|
||||
<dt .deflist__dt>
|
||||
#{allocationName}
|
||||
<dd .deflist__dd>
|
||||
<p>
|
||||
$if numApps == numFirstChoice
|
||||
_{MsgCourseAllocationsBoundCoincide numFirstChoice}
|
||||
$else
|
||||
_{MsgCourseAllocationsBound numApps numFirstChoice}
|
||||
$if capped
|
||||
<p .bound_explanation>
|
||||
_{MsgCourseAllocationsBoundCapped}
|
||||
$if registrationOpen allocationRegisterTo
|
||||
<p .bound_explanation>
|
||||
_{MsgCourseAllocationsBoundWarningOpen}
|
||||
<section>
|
||||
<h2>_{MsgCourseAllocationsBounds (length allocationsBounds)}
|
||||
<dl .deflist>
|
||||
$forall (Allocation{allocationName, allocationRegisterTo}, numApps, numFirstChoice, capped) <- allocationsBounds
|
||||
<dt .deflist__dt>
|
||||
#{allocationName}
|
||||
<dd .deflist__dd>
|
||||
<p>
|
||||
$if numApps == numFirstChoice
|
||||
_{MsgCourseAllocationsBoundCoincide numFirstChoice}
|
||||
$else
|
||||
_{MsgCourseAllocationsBound numApps numFirstChoice}
|
||||
$if capped
|
||||
<p .bound_explanation>
|
||||
_{MsgCourseAllocationsBoundCapped}
|
||||
$if registrationOpen allocationRegisterTo
|
||||
<p .bound_explanation>
|
||||
_{MsgCourseAllocationsBoundWarningOpen}
|
||||
$if mayAccept
|
||||
<section>
|
||||
<p>_{MsgBtnAcceptApplicationsTip}
|
||||
^{acceptWgt}
|
||||
|
||||
<h2>_{MsgMenuCourseApplications}
|
||||
^{table}
|
||||
<section>
|
||||
<h2>_{MsgMenuCourseApplications}
|
||||
^{table}
|
||||
|
||||
@ -114,7 +114,7 @@ $if not (null occurrences)
|
||||
<th .table__th>_{MsgExamRoomDescription}
|
||||
<tbody>
|
||||
$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
|
||||
<td .table__td #exam-occurrence__#{examOccurrenceName}>#{examOccurrenceName}
|
||||
$if occurrenceAssignmentsShown
|
||||
|
||||
@ -1,5 +1,20 @@
|
||||
$newline never
|
||||
<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>
|
||||
^{formatGregorianW 2019 09 16}
|
||||
<dd .deflist__dd>
|
||||
|
||||
@ -1,12 +1,23 @@
|
||||
$newline never
|
||||
<h3>Hinweise zum Import von CSV-Dateien
|
||||
<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
|
||||
<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.
|
||||
<dt .deflist__dt>Vorschau
|
||||
<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.
|
||||
<dt .deflist__dt>Leere Zellen
|
||||
<dd .deflist__dd>
|
||||
@ -16,22 +27,22 @@
|
||||
<p>
|
||||
Es werden nur konsistente Änderungen akzeptiert!
|
||||
<p>
|
||||
Daraus folgt, dass es sinnvoll sein kann, gewisse Zellen frei zu lassen;
|
||||
z.B. ändert man ein Studienfachzuordnung eines Teilnehmers ab,
|
||||
dann müsste man auch Abschluss und Semesterzahl passend ändern.
|
||||
Daraus folgt, dass es sinnvoll sein kann, gewisse Zellen frei zu lassen; #
|
||||
ändert man z.B. die Studienfachzuordnung eines Teilnehmers ab, #
|
||||
so müsste man auch Abschluss und Fachsemester passend ändern.<br />
|
||||
Da diese jedoch eindeutig sind, kann man diese Zellen einfach frei lassen.
|
||||
<dt .deflist__dt>Zeilen Identifikation
|
||||
<dd .deflist__dd>
|
||||
Mehrere Spalten werden zur Identifikation der Zeile verwendet.
|
||||
Es muss nicht in jeder Spalte der Zeile ein Wert vorhanden sein,
|
||||
so lange die Identifikation noch eindeutig ist.
|
||||
Mehrere Spalten werden zur Identifikation der Zeile verwendet.<br />
|
||||
Es muss nicht in jeder Spalte der Zeile ein Wert vorhanden sein, #
|
||||
so lange die Identifikation noch eindeutig ist.<br />
|
||||
Sind mehrere Werte vorhanden, so müssen diese natürlich zueinander passen.
|
||||
<dt .deflist__dt>Zeilen hinzufügen
|
||||
<dd .deflist__dd>
|
||||
Es können auch neue Zeilen hinzugefügt werden, so fern ausreichend
|
||||
eindeutige Informationen vorhanden sind;
|
||||
Es können auch neue Zeilen hinzugefügt werden, sofern ausreichend #
|
||||
eindeutige Informationen vorhanden sind; #
|
||||
z.B. können so Prüfungsteilnehmer nachgemeldet werden.
|
||||
<dt .deflist__dt>Zeilen löschen
|
||||
<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.
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<h2>
|
||||
_{MsgCourseParticipantsAlreadyRegistered (length aurAlreadyRegistered)}
|
||||
<ul>
|
||||
$forall email <- aurAlreadyRegistered
|
||||
$forall email <- aurAlreadyRegistered'
|
||||
<li style="font-family: monospace">#{email}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<h2>
|
||||
_{MsgCourseParticipantsRegisteredWithoutField (length aurNoUniquePrimaryField)}
|
||||
<ul>
|
||||
$forall email <- aurNoUniquePrimaryField
|
||||
$forall email <- aurNoUniquePrimaryField'
|
||||
<li style="font-family: monospace">#{email}
|
||||
|
||||
@ -14,5 +14,7 @@ $if is _Just dbtCsvEncode
|
||||
<div .csv-export__content>
|
||||
<p>
|
||||
^{csvColExplanations'}
|
||||
<p>
|
||||
^{modal (i18n MsgCsvChangeOptionsLabel) (Left (SomeRoute CsvOptionsR))}
|
||||
^{csvExportWdgt'}
|
||||
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
$newline never
|
||||
$case bonusRule
|
||||
$of ExamBonusManual _
|
||||
_{MsgExamBonusManualParticipants}
|
||||
$of ExamBonusPoints ps False _
|
||||
_{MsgExamBonusPoints ps}
|
||||
$of ExamBonusPoints ps True _
|
||||
|
||||
2
test.sh
2
test.sh
@ -1,5 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
[[ -n "${FORCE_RELEASE}" ]] && exit 0
|
||||
|
||||
set -e
|
||||
|
||||
[ "${FLOCKER}" != "$0" ] && exec env FLOCKER="$0" flock -en .stack-work.lock "$0" "$@" || :
|
||||
|
||||
@ -111,6 +111,7 @@ fillDb = do
|
||||
, userNotificationSettings = def
|
||||
, userCreated = now
|
||||
, userLastLdapSynchronisation = Nothing
|
||||
, userCsvOptions = csvPreset # CsvPresetRFC
|
||||
}
|
||||
fhamann <- insert User
|
||||
{ userIdent = "felix.hamann@campus.lmu.de"
|
||||
@ -135,6 +136,7 @@ fillDb = do
|
||||
, userNotificationSettings = def
|
||||
, userCreated = now
|
||||
, userLastLdapSynchronisation = Nothing
|
||||
, userCsvOptions = csvPreset # CsvPresetExcel
|
||||
}
|
||||
jost <- insert User
|
||||
{ userIdent = "jost@tcs.ifi.lmu.de"
|
||||
@ -159,6 +161,7 @@ fillDb = do
|
||||
, userNotificationSettings = def
|
||||
, userCreated = now
|
||||
, userLastLdapSynchronisation = Nothing
|
||||
, userCsvOptions = def
|
||||
}
|
||||
maxMuster <- insert User
|
||||
{ userIdent = "max@campus.lmu.de"
|
||||
@ -183,6 +186,7 @@ fillDb = do
|
||||
, userNotificationSettings = def
|
||||
, userCreated = now
|
||||
, userLastLdapSynchronisation = Nothing
|
||||
, userCsvOptions = def
|
||||
}
|
||||
tinaTester <- insert $ User
|
||||
{ userIdent = "tester@campus.lmu.de"
|
||||
@ -207,6 +211,7 @@ fillDb = do
|
||||
, userNotificationSettings = def
|
||||
, userCreated = now
|
||||
, userLastLdapSynchronisation = Nothing
|
||||
, userCsvOptions = def
|
||||
}
|
||||
svaupel <- insert User
|
||||
{ userIdent = "vaupel.sarah@campus.lmu.de"
|
||||
@ -231,6 +236,7 @@ fillDb = do
|
||||
, userNotificationSettings = def
|
||||
, userCreated = now
|
||||
, userLastLdapSynchronisation = Nothing
|
||||
, userCsvOptions = def
|
||||
}
|
||||
void . repsert (TermKey summer2017) $ Term
|
||||
{ termName = summer2017
|
||||
|
||||
@ -18,10 +18,11 @@ instance Arbitrary (Route Auth) where
|
||||
instance Arbitrary (Route EmbeddedStatic) where
|
||||
arbitrary = do
|
||||
let printableText = pack . filter (/= '/') . getPrintableString <$> arbitrary
|
||||
printableText' = printableText `suchThat` (not . null)
|
||||
pathLength <- getPositive <$> arbitrary
|
||||
path <- replicateM pathLength printableText
|
||||
path <- replicateM pathLength printableText'
|
||||
paramNum <- getNonNegative <$> arbitrary
|
||||
params <- replicateM paramNum $ (,) <$> printableText <*> printableText
|
||||
params <- replicateM paramNum $ (,) <$> printableText' <*> printableText
|
||||
return $ embeddedResourceR path params
|
||||
|
||||
instance Arbitrary SchoolR where
|
||||
|
||||
12
test/Model/MigrationSpec.hs
Normal file
12
test/Model/MigrationSpec.hs
Normal 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
|
||||
@ -8,6 +8,7 @@ import Settings
|
||||
import Control.Lens (review, preview)
|
||||
import Data.Aeson (Value)
|
||||
import qualified Data.Aeson as Aeson
|
||||
import qualified Data.Aeson.Types as Aeson
|
||||
|
||||
import MailSpec ()
|
||||
|
||||
@ -32,6 +33,8 @@ import Data.Scientific
|
||||
|
||||
import Utils.Lens
|
||||
|
||||
import qualified Data.Char as Char
|
||||
|
||||
|
||||
instance (Arbitrary a, MonoFoldable a) => Arbitrary (NonNull a) where
|
||||
arbitrary = arbitrary `suchThatMap` fromNullable
|
||||
@ -250,6 +253,28 @@ instance Arbitrary ExamPassed where
|
||||
arbitrary = genericArbitrary
|
||||
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 = do
|
||||
@ -334,6 +359,12 @@ spec = do
|
||||
[ eqLaws, ordLaws, showReadLaws, jsonLaws, persistFieldLaws ]
|
||||
lawsCheckHspec (Proxy @ExamPassed)
|
||||
[ 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
|
||||
it "has compatible encoding/decoding to/from Text" . property $
|
||||
@ -365,6 +396,9 @@ spec = do
|
||||
parse "1.8" `shouldSatisfy` is _Left
|
||||
parse "voided" `shouldBe` Right ExamVoided
|
||||
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 (term, encoded) = example $ do
|
||||
|
||||
@ -22,10 +22,13 @@ import Utils
|
||||
import System.FilePath
|
||||
import Data.Time
|
||||
|
||||
import Mail (MailLanguages(..))
|
||||
|
||||
|
||||
instance Arbitrary EmailAddress where
|
||||
arbitrary = do
|
||||
local <- suchThat arbitrary (\l -> isEmail l (CBS.pack "example.com"))
|
||||
domain <- suchThat arbitrary (\d -> isEmail (CBS.pack "example") d)
|
||||
local <- suchThat (CBS.pack . getPrintableString <$> arbitrary) (\l -> isEmail l (CBS.pack "example.com"))
|
||||
domain <- suchThat (CBS.pack . getPrintableString <$> arbitrary) (\d -> isEmail (CBS.pack "example") d)
|
||||
let (Just result) = emailAddress (makeEmailLike local domain)
|
||||
pure result
|
||||
|
||||
@ -100,8 +103,9 @@ instance Arbitrary User where
|
||||
|
||||
userDownloadFiles <- arbitrary
|
||||
userWarningDays <- arbitrary
|
||||
userMailLanguages <- arbitrary
|
||||
userMailLanguages <- fmap MailLanguages $ sublistOf =<< shuffle (toList appLanguages)
|
||||
userNotificationSettings <- arbitrary
|
||||
userCsvOptions <- arbitrary
|
||||
|
||||
userCreated <- arbitrary
|
||||
userLastLdapSynchronisation <- arbitrary
|
||||
|
||||
@ -140,6 +140,7 @@ createUser adjUser = do
|
||||
userNotificationSettings = def
|
||||
userCreated = now
|
||||
userLastLdapSynchronisation = Nothing
|
||||
userCsvOptions = def
|
||||
runDB . insertEntity $ adjUser User{..}
|
||||
|
||||
lawsCheckHspec :: Typeable a => Proxy a -> [Proxy a -> Laws] -> Spec
|
||||
|
||||
Reference in New Issue
Block a user