Merge branch 'master' into 476-interface-fur-klausurkorrekturen

This commit is contained in:
Sarah Vaupel 2020-01-20 09:42:21 +01:00
commit 36e90102c4
78 changed files with 2933 additions and 508 deletions

1
.gitignore vendored
View File

@ -38,4 +38,5 @@ test.log
tunnel.log
/static
/well-known
/.well-known-cache
/**/tmp-*

View File

@ -6,6 +6,7 @@ default:
- node_modules
- .stack
- .stack-work
- .well-known-cache
variables:
STACK_ROOT: "${CI_PROJECT_DIR}/.stack"
@ -13,6 +14,7 @@ variables:
POSTGRES_DB: uniworx_test
POSTGRES_USER: uniworx
POSTGRES_PASSWORD: uniworx
N_PREFIX: "${HOME}/.n"
stages:
- setup
@ -30,7 +32,8 @@ npm install:
before_script: &npm
- apt-get update -y
- npm install -g n
- n stable
- n 13.5.0
- export PATH="${N_PREFIX}/bin:$PATH"
- npm install -g npm
- hash -r
- apt-get -y install openssh-client exiftool
@ -138,7 +141,8 @@ frontend:test:
before_script:
- apt-get update -y
- npm install -g n
- n stable
- n 13.5.0
- export PATH="${N_PREFIX}/bin:$PATH"
- npm install -g npm
- hash -r
- apt-get install -y --no-install-recommends chromium-browser

View File

@ -2,6 +2,54 @@
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.
### [10.4.1](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/v10.4.0...v10.4.1) (2020-01-17)
### Bug Fixes
* hlint ([4348efc](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/4348efc))
## [10.4.0](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/v10.3.0...v10.4.0) (2020-01-17)
### Bug Fixes
* add missing translations ([d798dc4](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d798dc4))
* improve csv import explanation ([729a8e8](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/729a8e8))
* restrict guessUser to consistent queries ([bcd5326](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/bcd5326))
* tests & hlint ([4e9b618](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/4e9b618))
* ui improvements for (external-)exams ([b3ce3dd](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/b3ce3dd))
* **hide-columns:** bump storage manager minor version ([9053b87](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/9053b87))
* **hide-columns:** no hide-columns in tail.datetime ([03bcf56](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/03bcf56))
### Features
* course-participant-lists ([88dd5a9](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/88dd5a9))
* external exam csv export ([553c117](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/553c117))
* external exam csv import & ldap lookup during csv import ([1d14b6a](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/1d14b6a))
* external exams in exam office exams table ([3b739f7](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/3b739f7))
* notification about externalExamResults to exam-office ([a304840](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/a304840))
* **external-exams:** auditing ([2b153c1](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/2b153c1))
* **external-exams:** create new exams ([94bb391](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/94bb391))
* **external-exams:** display staff & add' schools ([c14d90f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/c14d90f))
* **external-exams:** edit existing exams ([1252a5f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/1252a5f))
* **external-exams:** list ([fa3521d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/fa3521d))
* **external-exams:** plan for student grade access ([b7506a0](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/b7506a0))
* **external-exams:** requisite routes ([f25b21a](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/f25b21a))
* **hide-columns:** add hider label th attr ([6c05a8f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/6c05a8f))
* **hide-columns:** add hider label th attr ([71e90a1](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/71e90a1))
* **hide-columns:** add hider labels for material list ([ccafd95](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/ccafd95))
* **hide-columns:** add hider labels for tutorial list on course page ([3553df2](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/3553df2))
* **hide-columns:** add hider labels for tutorial list on course page ([03e4ac1](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/03e4ac1))
* **hide-columns:** add more hider labels ([555c4ae](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/555c4ae))
* **hide-columns:** add more hider labels ([eba58d8](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/eba58d8))
* **hide-columns:** opt-out on select columns ([b03c10f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/b03c10f))
## [10.3.0](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/v10.2.0...v10.3.0) (2020-01-12)

View File

@ -72,6 +72,5 @@
},
"settings": {
"html_code_file": true
},
"versioning": false
}
}

View File

@ -401,8 +401,10 @@ UnauthorizedToken404: Authorisierungs-Tokens können nicht auf Fehlerseiten ausg
UnauthorizedSiteAdmin: Sie sind kein System-weiter Administrator.
UnauthorizedSchoolAdmin: Sie sind nicht als Administrator für dieses Institut eingetragen.
UnauthorizedAdminEscalation: Sie sind nicht Administrator für alle Institute, für die dieser Nutzer Administrator oder Veranstalter ist.
UnauthorizedExamOffice: Sie sind nicht Teil eines Prüfungsamts.
UnauthorizedExamExamOffice: Es existieren keine Prüfungsergebnisse für Nutzer, für die Sie Teil eines assoziierten Prüfungsamts sind.
UnauthorizedExamOffice: Sie sind nicht mit Prüfungsverwaltung beauftragt.
UnauthorizedExamExamOffice: Es existieren keine Prüfungsergebnisse für Nutzer, für die Sie mit der Prüfungsverwaltung beauftragt sind.
UnauthorizedExternalExamExamOffice: Es existieren keine Prüfungsergebnisse für Nutzer, für die Sie mit der Prüfungsverwaltung beauftragt sind.
UnauthorizedEvaluation: Sie sind nicht mit der Kursumfragenverwaltung beauftragt.
UnauthorizedSchoolLecturer: Sie sind nicht als Veranstalter für dieses Institut eingetragen.
UnauthorizedLecturer: Sie sind nicht als Veranstalter für diese Veranstaltung eingetragen.
UnauthorizedAllocationLecturer: Sie sind nicht als Veranstalter für eine Veranstaltung dieser Zentralanmeldung eingetragen.
@ -414,6 +416,7 @@ UnauthorizedRegistered: Sie sind nicht als Teilnehmer für diese Veranstaltung r
UnauthorizedAllocationRegistered: Sie sind nicht als Teilnehmer für diese Zentralanmeldung registriert.
UnauthorizedExamResult: Sie haben keine Ergebnisse in dieser Prüfung.
UnauthorizedExamOccurrenceRegistration: Anmeldung zur Klausur erfolgt nicht inkl. Raum/Termin.
UnauthorizedExternalExamResult: Sie haben keine Ergebnisse in dieser Prüfung.
UnauthorizedParticipant: Angegebener Benutzer ist nicht als Teilnehmer dieser Veranstaltung registriert.
UnauthorizedParticipantSelf: Sie sind kein Teilnehmer dieser Veranstaltung.
UnauthorizedApplicant: Angegebener Benutzer hat sich nicht für diese Veranstaltung beworben.
@ -450,6 +453,8 @@ UnauthorizedTutor: Sie sind nicht Tutor.
UnauthorizedTutorialRegisterGroup: Sie sind bereits in einem Tutorium mit derselben Registrierungs-Gruppe.
UnauthorizedLDAP: Angegebener Nutzer meldet sich nicht mit Campus-Kennung an.
UnauthorizedPWHash: Angegebener Nutzer meldet sich nicht mit Uni2work-Kennung an.
UnauthorizedExternalExamListNotEmpty: Liste von externen Prüfungen ist nicht leer
UnauthorizedExternalExamLecturer: Sie sind nicht als Prüfer für diese externe Prüfung eingetragen
UnauthorizedPasswordResetToken: Dieses Authorisierungs-Token kann nicht mehr zum Passwort ändern benutzt werden
@ -714,6 +719,7 @@ LoginNecessary: Bitte melden Sie sich dazu vorher an!
InternalLdapError: Interner Fehler beim Campus-Login
CampusUserInvalidIdent: Konnte anhand des Campus-Logins keine eindeutige Identifikation
CampusUserInvalidEmail: Konnte anhand des Campus-Logins keine EMail-Addresse ermitteln
CampusUserInvalidDisplayName: Konnte anhand des Campus-Logins keinen vollen Namen ermitteln
CampusUserInvalidGivenName: Konnte anhand des Campus-Logins keinen Vornamen ermitteln
@ -862,6 +868,9 @@ MailExamOfficeExamResultsIntro courseName@Text termDesc@Text examn@ExamName: Ein
MailSubjectExamOfficeExamResultsChanged csh@CourseShorthand examn@ExamName: Ergebnisse für #{examn} in #{csh} wurden verändert
MailExamOfficeExamResultsChangedIntro courseName@Text termDesc@Text examn@ExamName: Ein Kursverwalter hat Prüfungsleistungen für #{examn} im Kurs #{courseName} (#{termDesc}) verändert.
MailSubjectExamOfficeExternalExamResults coursen@CourseName examn@ExamName: Ergebnisse für #{examn} in #{coursen}
MailExamOfficeExternalExamResultsIntro coursen@CourseName termDesc@Text examn@ExamName: Ein Kursverwalter hat Prüfungsleistungen für #{examn} im Kurs #{coursen} (#{termDesc}) erstellt oder angepasst.
MailSubjectExamRegistrationActive csh@CourseShorthand examn@ExamName: Anmeldung für #{examn} in #{csh} ist möglich
MailExamRegistrationActiveIntro courseName@Text termDesc@Text examn@ExamName: Sie können sich nun für #{examn} im Kurs #{courseName} (#{termDesc}) anmelden.
@ -997,7 +1006,7 @@ NotificationTriggerKindCorrector: Für Korrektoren
NotificationTriggerKindLecturer: Für Dozenten
NotificationTriggerKindCourseLecturer: Für Kursverwalter
NotificationTriggerKindAdmin: Für Administratoren
NotificationTriggerKindExamOffice: Für das Prüfungsamt
NotificationTriggerKindExamOffice: Für Prüfungsverwalter
NotificationTriggerKindEvaluation: Für Vorlesungsumfragen
NotificationTriggerKindAllocationStaff: Für Zentralanmeldungen (Dozenten)
NotificationTriggerKindAllocationParticipant: Für Zentralanmeldungen
@ -1124,7 +1133,7 @@ MenuCourseMembers: Kursteilnehmer
MenuCourseAddMembers: Kursteilnehmer hinzufügen
MenuCourseCommunication: Kursmitteilung (E-Mail)
MenuCourseApplications: Bewerbungen
MenuCourseExamOffice: Prüfungsämter
MenuCourseExamOffice: Prüfungsbeauftragte
MenuTermShow: Semester
MenuSubmissionDelete: Abgabe löschen
MenuUsers: Benutzer
@ -1192,6 +1201,12 @@ MenuCourseNewsNew: Neue Kursnachricht
MenuCourseNewsEdit: Kursnachricht bearbeiten
MenuCourseEventNew: Neuer Kurstermin
MenuCourseEventEdit: Kurstermin bearbeiten
MenuExternalExamGrades: Prüfungsleistungen
MenuExternalExamUsers: Teilnehmer
MenuExternalExamEdit: Bearbeiten
MenuExternalExamNew: Neue externe Prüfung
MenuExternalExamList: Externe Prüfungen
MenuParticipantsList: Kursteilnehmerlisten
BreadcrumbSubmissionFile: Datei
BreadcrumbSubmissionUserInvite: Einladung zur Abgabe
@ -1245,6 +1260,20 @@ BreadcrumbExamCorrect: Eintragen von Prüfungsergebnissen
BreadcrumbApplicationFiles: Bewerbungsdateien
BreadcrumbCourseNewsArchive: Archiv
BreadcrumbCourseNewsFile: Datei
BreadcrumbExternalExam: Externe Prüfung
BreadcrumbExternalExamList: Externe Prüfungen
BreadcrumbExternalExamNew: Neue externe Prüfung
BreadcrumbExternalExamShow coursen@CourseName examn@ExamName: #{coursen}, #{examn}
BreadcrumbExternalExamEdit: Editieren
BreadcrumbExternalExamUsers: Teilnehmer
BreadcrumbExternalExamGrades: Prüfungsleistungen
BreadcrumbExternalExamStaffInvite: Einladung zum Prüfer
BreadcrumbParticipantsList: Kursteilnehmerlisten
BreadcrumbParticipants: Kursteilnehmerliste
ExternalExamEdit coursen@CourseName examn@ExamName: Bearbeiten: #{coursen}, #{examn}
ExternalExamGrades coursen@CourseName examn@ExamName: Prüfungsleistungen: #{coursen}, #{examn}
ExternalExamUsers coursen@CourseName examn@ExamName: Teilnehmer: #{coursen}, #{examn}
TitleMetrics: Metriken
@ -1253,7 +1282,8 @@ AuthPredsActive: Aktive Authorisierungsprädikate
AuthPredsActiveChanged: Authorisierungseinstellungen für aktuelle Sitzung gespeichert
AuthTagFree: Seite ist universell zugänglich
AuthTagAdmin: Nutzer ist Administrator
AuthTagExamOffice: Nutzer ist Teil eines Prüfungsamts
AuthTagExamOffice: Nutzer ist mit Prüfungsverwaltung beauftragt
AuthTagEvaluation: Nutzer ist mit Kursumfragenverwaltung beauftragt
AuthTagToken: Nutzer präsentiert Authorisierungs-Token
AuthTagNoEscalation: Nutzer-Rechte werden nicht auf fremde Institute ausgeweitet
AuthTagDeprecated: Seite ist nicht überholt
@ -1523,9 +1553,9 @@ ExamFinishedOffice: Noten bekannt gegeben
ExamFinishedParticipant: Bewertung vorrausichtlich abgeschlossen
ExamFinishedTip: Zeitpunkt zu dem Prüfungergebnisse den Teilnehmern gemeldet werden
ExamClosed: Noten gemeldet
ExamClosedTip: Prüfungsämter, die im System Noten einsehen, werden zu diesem Zeitpunkt benachrichtigt und danach bei Änderungen informiert
ExamClosedTip: Prüfungsbeauftraget, die im System Noten einsehen, werden zu diesem Zeitpunkt benachrichtigt und danach bei Änderungen informiert
ExamShowGrades: Klausur ist benotet
ExamShowGradesTip: Sollen genaue Noten angezeigt werden, oder sollen Teilnehmer und Prüfungsämter nur informiert werden, ob die Klausur bestanden wurde?
ExamShowGradesTip: Sollen genaue Noten angezeigt werden, oder sollen Teilnehmer und Prüfungsbeauftragte nur informiert werden, ob die Klausur bestanden wurde?
ExamPublicStatistics: Statistik veröffentlichen
ExamPublicStatisticsTip: Soll die automatisch berechnete statistische Auswertung auch den Teilnehmern angezeigt werden, sobald diese ihre Noten einsehen können?
ExamAutomaticGrading: Automatische Notenberechnung
@ -1545,6 +1575,7 @@ ExamBonusRule: Prüfungsbonus aus Übungsbetrieb
ExamNoBonus': Kein automatischer Bonus
ExamBonusPoints': Umrechnung von Übungspunkten
ExamBonusManual': Manuelle Berechnung
ExamGradesExplanation: Diese Ansicht zeigt die selben Daten an, wie die Tabelle von Prüfungsteilnehmern. Anpassen der Teilnehmerdaten und Ergebnisse ist nur dort möglich. Hier können Sie vor Allem einsehen und markieren, welche Prüfungsleistungen von den zuständigen Prüfungsbeauftragten bereits vollständig bearbeitet wurden.
ExamRegisterForOccurrence: Anmeldung zur Klausur erfolgt durch Anmeldung zu einem Termin/Raum
@ -1674,10 +1705,16 @@ ExamUserSyncTime: Zeitpunkt
ExamUserSyncSchools: Institute
ExamUserSyncLastChange: Zuletzt geändert
ExamUserMarkSynchronised: Prüfungsleistung als synchronisiert markieren
ExternalExamUserMarkSynchronised: Prüfungsleistung als synchronisiert markieren
ExternalExamUserMarkSynchronisedTip: Sollen beim CSV-Export automatisch alle heruntergeladenen Prüfungsleistungen als synchronisiert markiert werden? Diese Markierung dient als Hinweis an andere Prüfungsbeauftragte und die Kursverwalter, dass die Leistung an der korrekten Stelle vermerkt wurde und keiner weiteren Handlung bedarf.
ExamUserMarkSynchronisedCsv: Prüfungsleistungen beim Export als synchronisiert markieren
ExamUserMarkSynchronisedCsvTip: Sollen beim CSV-Export automatisch alle heruntergeladenen Prüfungsleistungen als synchronisiert markiert werden? Diese Markierung dient als Hinweis an andere Prüfungsbeauftragte und die Kursverwalter, dass die Leistung an der korrekten Stelle vermerkt wurde und keiner weiteren Handlung bedarf.
ExamUserMarkedSynchronised n@Int: #{n} #{pluralDE n "Prüfungsleistung" "Prüfungsleistungen"} als synchronisiert markiert
ExternalExamUserMarkSynchronisedCsv: Prüfungsleistungen beim Export als synchronisiert markieren
ExternalExamUserMarkedSynchronised n@Int: #{n} #{pluralDE n "Prüfungsleistung" "Prüfungsleistungen"} als synchronisiert markiert
ExamOfficeExamUsersHeading: Prüfungsleistungen
ActionsHead: Aktionen
@ -1705,7 +1742,9 @@ ProportionNoRatio c@Text of@Text: #{c}/#{of}
CourseUserCsvName tid@TermId ssh@SchoolId csh@CourseShorthand: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-teilnehmer
ExamUserCsvName tid@TermId ssh@SchoolId csh@CourseShorthand examn@ExamName: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-#{foldedCase examn}-teilnehmer
ExternalExamUserCsvName tid@TermId ssh@SchoolId coursen@CourseName examn@ExamName: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase coursen}-#{foldedCase examn}-teilnehmer
CourseApplicationsTableCsvName tid@TermId ssh@SchoolId csh@CourseShorthand: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-bewerbungen
ParticipantsCsvName tid@TermId ssh@SchoolId: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-kursteilnehmer
CsvColumnsExplanationsLabel: Spalten- & Zellenformat
CsvColumnsExplanationsTip: Bedeutung und Format der in der CSV-Datei enthaltenen Spalten
@ -1780,6 +1819,11 @@ ExamUserCsvExceptionNoMatchingUser: Kursteilnehmer konnte nicht eindeutig identi
ExamUserCsvExceptionNoMatchingStudyFeatures: Das angegebene Studienfach konnte keinem Studienfach des Kursteilnehmers zugeordnet werden
ExamUserCsvExceptionNoMatchingOccurrence: Raum/Termin konnte nicht eindeutig identifiziert werden
ExternalExamUserCsvRegister: Prüfungsleistung hinterlegen
ExternalExamUserCsvSetTime: Zeitpunkt anpassen
ExternalExamUserCsvSetResult: Ergebnis anpassen
ExternalExamUserCsvDeregister: Hinterlegte Prüfungsleistung löschen
CourseApplicationsTableCsvSetField: Bewerbungs-assoziiertes Studienfach ändern
CourseApplicationsTableCsvSetVeto: Veto setzen/entfernen
CourseApplicationsTableCsvSetRating: Bewertung eintragen
@ -1837,6 +1881,11 @@ MailSchoolFunctionInviteHeading school@SchoolName renderedFunction@Text: #{rende
SchoolFunctionInviteExplanation renderedFunction@Text: Sie wurden eingeladen, als #{renderedFunction} für ein Institut zu wirken. Sie erhalten, nachdem Sie die Einladung annehmen, erweiterte Rechte innerhalb des Instituts.
SchoolFunctionInvitationAccepted school@SchoolName renderedFunction@Text: #{renderedFunction}-Einladung zum Dozent für „#{school}“ angenommen
MailSubjectExternalExamStaffInvitation coursen@CourseName examn@ExamName: Einladung zum Prüfer für „#{examn}“ in „#{coursen}“
ExternalExamStaffInviteHeading coursen@CourseName examn@ExamName: Einladung zum Prüfer für „#{examn}“ in „#{coursen}“
ExternalExamStaffInviteExplanation: Sie wurden eingeladen als Prüfer für eine Uni2work-externe Prüfung zu wirken. Sie können dann u.A. Noten für die Prüfung hinterlegen.
ExternalExamStaffInvitationAccepted coursen@CourseName examn@ExamName: Sie sind nun als Prüfer für „#{examn}“ in „#{coursen}“ eingetragen.
AllocationActive: Aktiv
AllocationName: Name
AllocationAvailableCourses: Kurse
@ -1935,7 +1984,7 @@ SchoolExists ssh@SchoolId: Institut „#{ssh}“ existiert bereits
SchoolAdmin: Admin
SchoolLecturer: Dozent
SchoolEvaluation: Kursumfragenverwaltung
SchoolExamOffice: Prüfungsamt
SchoolExamOffice: Prüfungsverwaltung
ApplicationEditTip: Während des Bewerbungszeitraums können eigene Bewerbungen beliebig angepasst und auch wieder zurückgezogen werden.
@ -2013,10 +2062,10 @@ MailSubjectChangeUserDisplayEmail: Diese E-Mail Adresse in Uni2work veröffentli
MailIntroChangeUserDisplayEmail displayEmail@UserEmail: Der oben genannte Benutzer möchte „#{displayEmail}“ als öffentliche Adresse, assoziiert mit sich selbst, angeben. Wenn Sie diese Aktion nicht selbst ausgelöst haben, ignorieren Sie diese Mitteilung bitte!
MailTitleChangeUserDisplayEmail displayName@Text: #{displayName} möchte diese E-Mail Adresse in Uni2work veröffentlichen
ExamOfficeOptOutsChanged: Zuständige Prüfungsämter erfolgreich angepasst
ExamOfficeOptOutsChanged: Zuständige Prüfungsbeauftragte erfolgreich angepasst
BtnCloseExam: Klausur abschließen
ExamCloseTip: Wenn eine Klausur abgeschlossen wird, werden Prüfungsämter, die im System Noten einsehen, benachrichtigt und danach bei Änderungen informiert.
ExamCloseTip: Wenn eine Klausur abgeschlossen wird, werden Prüfungsbeauftragte, die im System Noten einsehen, benachrichtigt und danach bei Änderungen informiert.
ExamCloseReminder: Bitte schließen Sie die Klausur frühstmöglich, sobald die Prüfungsleistungen sich voraussichtlich nicht mehr ändern werden. Z.B. direkt nach der Klausureinsicht.
ExamDidClose: Klausur erfolgreich abgeschlossen
@ -2217,3 +2266,27 @@ Deficit: Defizit
MetricNoSamples: Keine Messwerte
MetricName: Name
MetricValue: Wert
ExternalExamSemester: Semester
ExternalExamSchool: Institut
ExternalExamCourseName: Veranstaltung
ExternalExamCourseNameTip: Muss nur innerhalb von Semester und Institut eindeutig sein.
ExternalExamCourseNamePlaceholder: Analysis I, Programmierung und Modellierung, ...
ExternalExamExamName: Prüfung
ExternalExamExamNameTip: Muss innerhalb der Veranstaltung eindeutig sein.
ExternalExamExamNamePlaceholder: Klausur, Nachklausur, Projektabnahme, ...
ExternalExamDefaultTime: Voreingestellter Zeitpunkt
ExternalExamDefaultTimePlaceholder: Zeitpunkt
ExternalExamDefaultTimeTip: Der Zeitpunkt zu dem die Prüfung abgelegt wurde, muss pro Teilnehmer festgelegt werden. Der hier angegebene Zeitpunkt wird als Standardwert für Teilnehmer verwendet, bei denen später nicht ein abweichender Zeitpunkt angegeben wird.
ExternalExamShowGrades: Klausur ist benotet
ExternalExamShowGradesTip: Sollen genaue Noten angezeigt werden, oder sollen Teilnehmer und Prüfungsbeauftragte nur informiert werden, ob die Klausur bestanden wurde?
ExternalExamExamOfficeSchools: Zusätzliche Institute
ExternalExamExamOfficeSchoolsTip: Prüfungsbeauftragte von Instituten, die Sie hier angeben, erhalten im System (zusätzlich zum angegebenen primären Institut) volle Einsicht in sämtliche für diese Prüfung hinterlegten Leistungen, unabhängig von den Studiendaten der Teilnehmer.
ExternalExamStaff: Assoziierte Personen
ExternalExamStaffTip: Assoziierte Personen werden den Prüfungsbeauftragten und Teilnehmern angezeigt und dürfen Leistungen für die Prüfung hinterlegen.
ExternalExamStaffAlreadyAdded: Person wurde bereits der Prüfung hinzugefügt
ExternalExamUserMustBeStaff: Sie selbst müssen stets assoziierte Person sein, für die externen Prüfungen, die Sie anlegen
ExternalExamCourseExists: Der angegebene Kurs existiert im System. Prüfungen sollten daher direkt beim Kurs (statt extern) hinterlegt werden.
ExternalExamExists coursen@CourseName examn@ExamName: Prüfung „#{examn}“ für Kurs „#{coursen}“ existiert bereits.
ExternalExamCreated coursen@CourseName examn@ExamName: Prüfung „#{examn}“ für Kurs „#{coursen}“ erfolgreich angelegt.
ExternalExamEdited coursen@CourseName examn@ExamName: Prüfung „#{examn}“ für Kurs „#{coursen}“ erfolgreich bearbeitet.

View File

@ -400,7 +400,9 @@ UnauthorizedSiteAdmin: You are no system-wide administrator.
UnauthorizedSchoolAdmin: You are no administrator for this department.
UnauthorizedAdminEscalation: You aren't an administrator for all departments for which this user is an administrator.
UnauthorizedExamOffice: You are not part of an exam office.
UnauthorizedEvaluation: You are not charged with course evaluation.
UnauthorizedExamExamOffice: You are not part of the appropriate exam office for any of the participants of this exam.
UnauthorizedExternalExamExamOffice: You are not part of the appropriate exam office for any of the participants of this exam.
UnauthorizedSchoolLecturer: You are no lecturer for this department.
UnauthorizedLecturer: You are no administrator for this course.
UnauthorizedAllocationLecturer: You are no administrator for any of the courses of this central allocation.
@ -412,6 +414,7 @@ UnauthorizedRegistered: You are no participant in this course.
UnauthorizedAllocationRegistered: You are no participant in this central allocation.
UnauthorizedExamResult: You have no results in this exam.
UnauthorizedExamOccurrenceRegistration: Registration for exam is not done including occurrence/room.
UnauthorizedExternalExamResult: You have no results in this exam.
UnauthorizedParticipant: The specified user is no participant of this course.
UnauthorizedParticipantSelf: You are no participant of this course.
UnauthorizedApplicant: The specified user is no applicant for this course.
@ -448,6 +451,8 @@ UnauthorizedTutor: You are no tutor.
UnauthorizedTutorialRegisterGroup: You are already registered for a tutorial with the same registration group.
UnauthorizedLDAP: Specified user does not log in with their campus account.
UnauthorizedPWHash: Specified user does not log in with an Uni2work-account.
UnauthorizedExternalExamListNotEmpty: List of external exams is not empty
UnauthorizedExternalExamLecturer: You are not an associated person for this external exam
UnauthorizedPasswordResetToken: This authorisation-token may no longer be used to change passwords
@ -711,6 +716,7 @@ LoginNecessary: Please log in first!
InternalLdapError: Internal error during campus login
CampusUserInvalidIdent: Could not determine unique identification during campus login
CampusUserInvalidEmail: Could not determine email address during campus login
CampusUserInvalidDisplayName: Could not determine display name during campus login
CampusUserInvalidGivenName: Could not determine given name during campus login
@ -862,6 +868,9 @@ MailExamOfficeExamResultsIntro courseName termDesc examn: A course administrator
MailSubjectExamOfficeExamResultsChanged csh examn: Results for #{examn} of #{csh} were changed
MailExamOfficeExamResultsChangedIntro courseName termDesc examn: A course administrator has changed exam results for #{examn} of the course #{courseName} (#{termDesc}).
MailSubjectExamOfficeExternalExamResults coursen@CourseName examn@ExamName: Results for #{examn} in #{coursen}
MailExamOfficeExternalExamResultsIntro coursen@CourseName termDesc@Text examn@ExamName: A course administrator has changed or initially made available the results for #{examn} of the course {coursen} (#{termDesc}).
MailSubjectExamRegistrationActive csh examn: Registration is now allowed for #{examn} of #{csh}
MailExamRegistrationActiveIntro courseName termDesc examn: You may now register for #{examn} of the course #{courseName} (#{termDesc}).
@ -1191,6 +1200,12 @@ MenuCourseNewsNew: Add course news
MenuCourseNewsEdit: Edit course news
MenuCourseEventNew: New course occurrence
MenuCourseEventEdit: Edit course occurrence
MenuExternalExamGrades: Exam results
MenuExternalExamUsers: Participants
MenuExternalExamEdit: Edit
MenuExternalExamNew: New external exam
MenuExternalExamList: External exams
MenuParticipantsList: Lists of course participants
BreadcrumbSubmissionFile: File
BreadcrumbSubmissionUserInvite: Invitation to participate in a submission
@ -1244,6 +1259,20 @@ BreadcrumbExamCorrect: Exam corrections
BreadcrumbApplicationFiles: Application files
BreadcrumbCourseNewsArchive: Archive
BreadcrumbCourseNewsFile: File
BreadcrumbExternalExam: External exam
BreadcrumbExternalExamList: External exams
BreadcrumbExternalExamNew: New external exam
BreadcrumbExternalExamShow coursen@CourseName examn@ExamName: #{coursen}, #{examn}
BreadcrumbExternalExamEdit: Edit
BreadcrumbExternalExamUsers: Participants
BreadcrumbExternalExamGrades: Exam results
BreadcrumbExternalExamStaffInvite: Invitation
BreadcrumbParticipantsList: Lists of course participants
BreadcrumbParticipants: Course participants
ExternalExamEdit coursen examn: Edit: #{coursen}, #{examn}
ExternalExamGrades coursen examn: Exam achievements: #{coursen}, #{examn}
ExternalExamUsers coursen examn: Exam participants: #{coursen}, #{examn}
TitleMetrics: Metrics
@ -1253,6 +1282,7 @@ AuthPredsActiveChanged: Authorisation settings saved for the current session
AuthTagFree: Page is freely accessable
AuthTagAdmin: User is administrator
AuthTagExamOffice: User is part of an exam office
AuthTagEvaluation: User is charged with course evaluation
AuthTagToken: User is presenting an authorisation-token
AuthTagNoEscalation: User permissions are not being expanded to other departments
AuthTagDeprecated: Page is not deprecated
@ -1543,6 +1573,7 @@ ExamBonusRule: Bonus points from exercises
ExamNoBonus': No automatic exam bonus
ExamBonusPoints': Compute from exercise achievements
ExamBonusManual': Manual computation
ExamGradesExplanation: This view shows the same data as the table of exam participants. Changing participant's data and achievements is only possible via the table of exam participants. Primarily, this view allows you to check and adjust which exam achievements were properly handled by the relevant exam offices.
ExamRegisterForOccurrence: Registration for this exam is done by registering for an occurrence/room
@ -1671,10 +1702,16 @@ ExamUserSyncTime: Timestamp
ExamUserSyncSchools: Department
ExamUserSyncLastChange: Last changed
ExamUserMarkSynchronised: Mark exam achievements as synchronised
ExternalExamUserMarkSynchronised: Mark exam achievements as synchronised
ExternalExamUserMarkSynchronisedTip: Should all exam achievements, that are included in the download, be marked as synchronised? Marking exam achievemnts as synchronised serves as a notice to other exam offices and course administrators, that the exam achievement has been dealt with properly such that no further action is required.
ExamUserMarkSynchronisedCsv: Mark exam achievements as synchronised while exporting
ExamUserMarkSynchronisedCsvTip: Should all exam achievements, that are included in the download, be marked as synchronised? Marking exam achievemnts as synchronised serves as a notice to other exam offices and course administrators, that the exam achievement has been dealt with properly such that no further action is required.
ExamUserMarkedSynchronised n: Successfully marked #{n} #{pluralEN n "exam achievement" "exam achievements"} as synchronised
ExternalExamUserMarkSynchronisedCsv: Mark exam achievements as synchronised while exporting
ExternalExamUserMarkedSynchronised n: Successfully marked #{n} #{pluralEN n "exam achievement" "exam achievements"} as synchronised
ExamOfficeExamUsersHeading: Exam achievements
ActionsHead: Actions
@ -1702,7 +1739,9 @@ ProportionNoRatio c of: #{c}/#{of}
CourseUserCsvName tid ssh csh: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-participants
ExamUserCsvName tid ssh csh examn: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-#{foldedCase examn}-participants
ExternalExamUserCsvName tid@TermId ssh@SchoolId coursen@CourseName examn@ExamName: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase coursen}-#{foldedCase examn}-participants
CourseApplicationsTableCsvName tid ssh csh: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-applications
ParticipantsCsvName tid ssh: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-participants
CsvColumnsExplanationsLabel: Column & cell format
CsvColumnsExplanationsTip: Meaning and format of the columns contained in imported and exported CSV files
@ -1777,6 +1816,11 @@ ExamUserCsvExceptionNoMatchingUser: Course participant could not be identified u
ExamUserCsvExceptionNoMatchingStudyFeatures: The specified field did not match with any of the participant's fields of study
ExamUserCsvExceptionNoMatchingOccurrence: Occurrence/room could not be identified uniquely
ExternalExamUserCsvRegister: Store exam achievement
ExternalExamUserCsvSetTime: Adjust exam time
ExternalExamUserCsvSetResult: Adjust exam result
ExternalExamUserCsvDeregister: Delete stored exam achievement
CourseApplicationsTableCsvSetField: Modify field of study associated with the applicatio
CourseApplicationsTableCsvSetVeto: Set/remove veto
CourseApplicationsTableCsvSetRating: Set grading
@ -1834,6 +1878,11 @@ MailSchoolFunctionInviteHeading school renderedFunction: Invitation to be #{rend
SchoolFunctionInviteExplanation renderedFunction: You were invited to act as #{renderedFunction} for a department. By accepting the invitation you are granted elevated rights within the department.
SchoolFunctionInvitationAccepted school renderedFunction: Successfully accepted invitation to be #{renderedFunction} for “#{school}”
MailSubjectExternalExamStaffInvitation coursen examn: Invitation to act as examiner for “#{examn}” of “#{coursen}”
ExternalExamStaffInviteHeading coursen examn: Invitation to act as examiner for “#{examn}” of “#{coursen}”
ExternalExamStaffInviteExplanation: You have been invited to act as an examiner for a uni2work-external exam. After accepting you will be able to upload exam results.
ExternalExamStaffInvitationAccepted coursen examn: You are now registered as an examiner for “#{examn}” of “#{coursen}”.
AllocationActive: Active
AllocationName: Name
AllocationAvailableCourses: Courses
@ -2215,3 +2264,27 @@ Deficit: Deficit
MetricNoSamples: No samples
MetricName: Name
MetricValue: Value
ExternalExamSemester: Semester
ExternalExamSchool: Department
ExternalExamCourseName: Course
ExternalExamCourseNameTip: Needs only be unique among within semester and department.
ExternalExamCourseNamePlaceholder: Analysis I, Programming and Modelling, ...
ExternalExamExamName: Exam title
ExternalExamExamNameTip: Needs only be unique within the course.
ExternalExamExamNamePlaceholder: Exam, Exam resit, Project discussion, ...
ExternalExamDefaultTime: Default time
ExternalExamDefaultTimePlaceholder: Time
ExternalExamDefaultTimeTip: The time of the exam needs to be specified for each participant. The time entered here is used as a default value for participants for whom no different time is later specified.
ExternalExamShowGrades: Exam is graded
ExternalExamShowGradesTip: Should participants and relevant exam offices be show exact grades or only whether the exam was passed or failed?
ExternalExamExamOfficeSchools: Additional departments
ExternalExamExamOfficeSchoolsTip: Exam offices of departments you specify here will also have full access to all results for this exam disregarding the individual participants' features of study.
ExternalExamStaff: Associated persons
ExternalExamStaffTip: The list of ssociated persons is shown to exam offices and participants. Additionally associated persons may upload results for the exam.
ExternalExamStaffAlreadyAdded: Person is already associated with the exam.
ExternalExamUserMustBeStaff: You yourself must always be an associated person for exams you create.
ExternalExamCourseExists: This course already exists with uni2work. Exams for courses that exist within uni2work should be associated with the course directly instead of being created as an external exam.
ExternalExamExists coursen@CourseName examn@ExamName: Exam “#{examn}” already exists for course “#{coursen}”.
ExternalExamCreated coursen@CourseName examn@ExamName: Succesfully created exam “#{examn}” for course “#{coursen}”.
ExternalExamEdited coursen@CourseName examn@ExamName: Succesfully edited exam “#{examn}” for course “#{coursen}”.

52
missing-translations.sh Executable file
View File

@ -0,0 +1,52 @@
#!/usr/bin/env zsh
set -e
function translations() {
msgFile=$1
sed -r 's/^([^ :]+).*$/\1/' ${msgFile} \
| sort
}
typeset -a msgFiles
msgFiles=(messages/**/*.msg)
typeset -a msgDirectories
msgDirectories=()
for msgFile (${msgFiles}); do
if ! [[ ${msgDirectories[(ie)${msgFile:h}]} -le ${#msgDirectories} ]]; then
msgDirectories+=(${msgFile:h})
fi
done
for msgDirectory (${msgDirectories}); do
typeset -a dirMsgFiles
dirMsgFiles=()
for msgFile (${msgFiles}); do
if [[ ${msgFile:h} == ${msgDirectory} ]]; then
dirMsgFiles+=(${msgFile})
fi
done
(
diffDir=""
function cleanup() {
cd
[[ -n ${diffDir} ]] && rm -rf ${diffDir}
}
trap cleanup EXIT
diffDir=$(mktemp -d)
typeset -a diffArgs
diffArgs=()
for msgFile (${dirMsgFiles}); do
translations ${msgFile} > ${diffDir}/${msgFile:t}
diffArgs+=(${diffDir}/${msgFile:t})
done
printf ">>> %s\n" ${msgDirectory}
diff --suppress-common-lines -wB ${diffArgs}
)
done

View File

@ -11,4 +11,9 @@ ExamOfficeResultSynced
school SchoolId Maybe
office UserId
result ExamResultId
time UTCTime
ExamOfficeExternalResultSynced
school SchoolId Maybe
office UserId
result ExternalExamResultId
time UTCTime

View File

@ -0,0 +1,23 @@
ExternalExam
term TermId
school SchoolId
courseName (CI Text)
examName (CI Text)
defaultTime UTCTime Maybe
showGrades Bool
UniqueExternalExam term school courseName examName
ExternalExamResult
user UserId
exam ExternalExamId
result ExamResultGrade
time UTCTime
lastChanged UTCTime
UniqueExternalExamResult exam user
ExternalExamStaff
user UserId
exam ExternalExamId
UniqueExternalExamStaff exam user
ExternalExamOfficeSchool
school SchoolId
exam ExternalExamId
UniqueExternalExamOfficeSchool exam school

239
package-lock.json generated
View File

@ -1,6 +1,6 @@
{
"name": "uni2work",
"version": "10.3.0",
"version": "10.4.1",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@ -4752,6 +4752,18 @@
"yargs": "12.0.1"
},
"dependencies": {
"ajv": {
"version": "5.5.2",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.2.tgz",
"integrity": "sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU=",
"dev": true,
"requires": {
"co": "^4.6.0",
"fast-deep-equal": "^1.0.0",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.3.0"
}
},
"date-format": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/date-format/-/date-format-1.2.0.tgz",
@ -4767,6 +4779,28 @@
"ms": "^2.1.1"
}
},
"fast-deep-equal": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz",
"integrity": "sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ=",
"dev": true
},
"har-validator": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.0.3.tgz",
"integrity": "sha1-ukAsJmGU8VlW7xXg/PJCmT9qff0=",
"dev": true,
"requires": {
"ajv": "^5.1.0",
"har-schema": "^2.0.0"
}
},
"json-schema-traverse": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz",
"integrity": "sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A=",
"dev": true
},
"log4js": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/log4js/-/log4js-3.0.3.tgz",
@ -4785,6 +4819,52 @@
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
"dev": true
},
"oauth-sign": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz",
"integrity": "sha1-Rqarfwrq2N6unsBWV4C31O/rnUM=",
"dev": true
},
"punycode": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
"integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=",
"dev": true
},
"qs": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz",
"integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==",
"dev": true
},
"request": {
"version": "2.87.0",
"resolved": "https://registry.npmjs.org/request/-/request-2.87.0.tgz",
"integrity": "sha512-fcogkm7Az5bsS6Sl0sibkbhcKsnyon/jV1kF3ajGmF0c8HrttdKTPRT9hieOaQHA5HEq6r8OyWOo/o781C1tNw==",
"dev": true,
"requires": {
"aws-sign2": "~0.7.0",
"aws4": "^1.6.0",
"caseless": "~0.12.0",
"combined-stream": "~1.0.5",
"extend": "~3.0.1",
"forever-agent": "~0.6.1",
"form-data": "~2.3.1",
"har-validator": "~5.0.3",
"http-signature": "~1.2.0",
"is-typedarray": "~1.0.0",
"isstream": "~0.1.2",
"json-stringify-safe": "~5.0.1",
"mime-types": "~2.1.17",
"oauth-sign": "~0.8.2",
"performance-now": "^2.1.0",
"qs": "~6.5.1",
"safe-buffer": "^5.1.1",
"tough-cookie": "~2.3.3",
"tunnel-agent": "^0.6.0",
"uuid": "^3.1.0"
}
},
"streamroller": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/streamroller/-/streamroller-0.7.0.tgz",
@ -4797,6 +4877,15 @@
"readable-stream": "^2.3.0"
}
},
"tough-cookie": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.4.tgz",
"integrity": "sha512-TZ6TTfI5NtZnuyy/Kecv+CnoROnyXn2DN97LontgQpCwsX2XyLYCC0ENhYkehSOwAp8rTQKc/NUIF7BkQ5rKLA==",
"dev": true,
"requires": {
"punycode": "^1.4.1"
}
},
"ws": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-6.0.0.tgz",
@ -8626,14 +8715,22 @@
}
},
"fs-extra": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz",
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
"dev": true,
"requires": {
"graceful-fs": "^4.1.2",
"graceful-fs": "^4.2.0",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
},
"dependencies": {
"graceful-fs": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.3.tgz",
"integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==",
"dev": true
}
}
},
"fs-minipass": {
@ -9836,39 +9933,13 @@
"dev": true
},
"har-validator": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.0.3.tgz",
"integrity": "sha1-ukAsJmGU8VlW7xXg/PJCmT9qff0=",
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.3.tgz",
"integrity": "sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==",
"dev": true,
"requires": {
"ajv": "^5.1.0",
"ajv": "^6.5.5",
"har-schema": "^2.0.0"
},
"dependencies": {
"ajv": {
"version": "5.5.2",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.2.tgz",
"integrity": "sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU=",
"dev": true,
"requires": {
"co": "^4.6.0",
"fast-deep-equal": "^1.0.0",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.3.0"
}
},
"fast-deep-equal": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz",
"integrity": "sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ=",
"dev": true
},
"json-schema-traverse": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz",
"integrity": "sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A=",
"dev": true
}
}
},
"has": {
@ -15639,9 +15710,9 @@
"dev": true
},
"oauth-sign": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz",
"integrity": "sha1-Rqarfwrq2N6unsBWV4C31O/rnUM=",
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz",
"integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==",
"dev": true
},
"object-assign": {
@ -17332,6 +17403,12 @@
"integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=",
"dev": true
},
"psl": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.7.0.tgz",
"integrity": "sha512-5NsSEDv8zY70ScRnOTn7bK7eanl2MvFrOrS/R6x+dBt5g1ghnj9Zv90kO8GwT8gxcu2ANyFprnFYB85IogIJOQ==",
"dev": true
},
"public-encrypt": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz",
@ -17802,31 +17879,31 @@
}
},
"request": {
"version": "2.87.0",
"resolved": "https://registry.npmjs.org/request/-/request-2.87.0.tgz",
"integrity": "sha512-fcogkm7Az5bsS6Sl0sibkbhcKsnyon/jV1kF3ajGmF0c8HrttdKTPRT9hieOaQHA5HEq6r8OyWOo/o781C1tNw==",
"version": "2.88.0",
"resolved": "https://registry.npmjs.org/request/-/request-2.88.0.tgz",
"integrity": "sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==",
"dev": true,
"requires": {
"aws-sign2": "~0.7.0",
"aws4": "^1.6.0",
"aws4": "^1.8.0",
"caseless": "~0.12.0",
"combined-stream": "~1.0.5",
"extend": "~3.0.1",
"combined-stream": "~1.0.6",
"extend": "~3.0.2",
"forever-agent": "~0.6.1",
"form-data": "~2.3.1",
"har-validator": "~5.0.3",
"form-data": "~2.3.2",
"har-validator": "~5.1.0",
"http-signature": "~1.2.0",
"is-typedarray": "~1.0.0",
"isstream": "~0.1.2",
"json-stringify-safe": "~5.0.1",
"mime-types": "~2.1.17",
"oauth-sign": "~0.8.2",
"mime-types": "~2.1.19",
"oauth-sign": "~0.9.0",
"performance-now": "^2.1.0",
"qs": "~6.5.1",
"safe-buffer": "^5.1.1",
"tough-cookie": "~2.3.3",
"qs": "~6.5.2",
"safe-buffer": "^5.1.2",
"tough-cookie": "~2.4.3",
"tunnel-agent": "^0.6.0",
"uuid": "^3.1.0"
"uuid": "^3.3.2"
},
"dependencies": {
"qs": {
@ -17837,6 +17914,27 @@
}
}
},
"request-promise": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/request-promise/-/request-promise-4.2.5.tgz",
"integrity": "sha512-ZgnepCykFdmpq86fKGwqntyTiUrHycALuGggpyCZwMvGaZWgxW6yagT0FHkgo5LzYvOaCNvxYwWYIjevSH1EDg==",
"dev": true,
"requires": {
"bluebird": "^3.5.0",
"request-promise-core": "1.1.3",
"stealthy-require": "^1.1.1",
"tough-cookie": "^2.3.3"
}
},
"request-promise-core": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.3.tgz",
"integrity": "sha512-QIs2+ArIGQVp5ZYbWD5ZLCY29D5CfWizP8eWnm8FoGD1TX61veauETVQbrV60662V0oFBkrDOuaBI8XgtuyYAQ==",
"dev": true,
"requires": {
"lodash": "^4.17.15"
}
},
"require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@ -19098,6 +19196,12 @@
"integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=",
"dev": true
},
"stealthy-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz",
"integrity": "sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=",
"dev": true
},
"stream-browserify": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.2.tgz",
@ -19159,6 +19263,17 @@
"ms": "^2.1.1"
}
},
"fs-extra": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz",
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true,
"requires": {
"graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
}
},
"ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@ -19752,11 +19867,12 @@
"dev": true
},
"tough-cookie": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.4.tgz",
"integrity": "sha512-TZ6TTfI5NtZnuyy/Kecv+CnoROnyXn2DN97LontgQpCwsX2XyLYCC0ENhYkehSOwAp8rTQKc/NUIF7BkQ5rKLA==",
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.4.3.tgz",
"integrity": "sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ==",
"dev": true,
"requires": {
"psl": "^1.1.24",
"punycode": "^1.4.1"
},
"dependencies": {
@ -20550,6 +20666,19 @@
"lodash": ">=3.5 <5",
"object.entries": "^1.1.0",
"tapable": "^1.0.0"
},
"dependencies": {
"fs-extra": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz",
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true,
"requires": {
"graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
}
}
}
},
"webpack-plugin-hash-output": {

View File

@ -1,6 +1,6 @@
{
"name": "uni2work",
"version": "10.3.0",
"version": "10.4.1",
"description": "",
"keywords": [],
"author": "",
@ -74,6 +74,7 @@
"css-loader": "^2.1.1",
"eslint": "^5.16.0",
"file-loader": "^5.0.2",
"fs-extra": "^8.1.0",
"glob": "^7.1.6",
"html-webpack-plugin": "^3.2.0",
"husky": "^2.7.0",
@ -96,6 +97,8 @@
"postcss-preset-env": "^6.7.0",
"real-favicon-webpack-plugin": "^0.2.3",
"remove-files-webpack-plugin": "^1.1.3",
"request": "^2.88.0",
"request-promise": "^4.2.5",
"resolve-url-loader": "^3.1.1",
"sass": "^1.23.7",
"sass-loader": "^7.3.1",

View File

@ -1,5 +1,5 @@
name: uniworx
version: 10.3.0
version: 10.4.1
dependencies:
- base

13
routes
View File

@ -80,6 +80,16 @@
/users EOUsersR GET POST
/users/invite EOUsersInviteR GET POST
/external-exam EExamListR GET !lecturer !¬empty
/external-exam/new EExamNewR GET POST !lecturer
/external-exam/#TermId/#SchoolId/#CourseName/#ExamName EExamR !lecturer:
/ EEShowR GET !exam-office !exam-result
/edit EEEditR GET POST
/users EEUsersR GET POST
/grades EEGradesR GET POST !exam-office
/staff-invite EEStaffInviteR GET POST
/term TermShowR GET !free
/term/current TermCurrentR GET !free
/term/edit TermEditR GET POST
@ -98,6 +108,9 @@
/register ARegisterR POST !time
/course/#CryptoUUIDCourse/apply AApplyR POST !timeANDallocation-registered
/participants ParticipantsListR GET !evaluation
/participants/#TermId/#SchoolId ParticipantsR GET !evaluation
-- For Pattern Synonyms see Foundation
/course/ CourseListR GET !free

View File

@ -19,7 +19,7 @@ let
'';
override = oldAttrs: {
nativeBuildInputs = oldAttrs.nativeBuildInputs ++ (with pkgs; [ nodejs-12_x postgresql openldap google-chrome exiftool ]) ++ (with haskellPackages; [ stack yesod-bin hlint cabal-install weeder ]);
nativeBuildInputs = oldAttrs.nativeBuildInputs ++ (with pkgs; [ nodejs-13_x postgresql openldap google-chrome exiftool ]) ++ (with haskellPackages; [ stack yesod-bin hlint cabal-install weeder ]);
shellHook = ''
export PROMPT_INFO="${oldAttrs.name}"

View File

@ -122,6 +122,8 @@ import Handler.Exam
import Handler.Allocation
import Handler.ExamOffice
import Handler.Metrics
import Handler.ExternalExam
import Handler.Participants
-- This line actually creates our YesodDispatch instance. It is the second half

View File

@ -139,6 +139,45 @@ data Transaction
{ transactionTutorial :: TutorialId
}
| TransactionExternalExamEdit
{ transactionExternalExam :: ExternalExamId
}
| TransactionExternalExamOfficeSchoolEdit
{ transactionExternalExam :: ExternalExamId
, transactionSchool :: SchoolId
}
| TransactionExternalExamOfficeSchoolDelete
{ transactionExternalExam :: ExternalExamId
, transactionSchool :: SchoolId
}
| TransactionExternalExamStaffEdit
{ transactionExternalExam :: ExternalExamId
, transactionUser :: UserId
}
| TransactionExternalExamStaffDelete
{ transactionExternalExam :: ExternalExamId
, transactionUser :: UserId
}
| TransactionExternalExamStaffInviteEdit
{ transactionExternalExam :: ExternalExamId
, transactionEmail :: UserEmail
}
| TransactionExternalExamStaffInviteDelete
{ transactionExternalExam :: ExternalExamId
, transactionEmail :: UserEmail
}
| TransactionExternalExamResultEdit
{ transactionExternalExam :: ExternalExamId
, transactionUser :: UserId
}
| TransactionExternalExamResultDelete
{ transactionExternalExam :: ExternalExamId
, transactionUser :: UserId
}
deriving (Eq, Ord, Read, Show, Generic, Typeable)
deriveJSON defaultOptions

View File

@ -3,6 +3,7 @@ module Auth.LDAP
, campusLogin
, CampusUserException(..)
, campusUser, campusUser'
, campusUserMatr, campusUserMatr'
, CampusMessage(..)
, ldapUserPrincipalName, ldapUserEmail, ldapUserDisplayName
, ldapUserMatriculation, ldapUserFirstName, ldapUserSurname
@ -43,7 +44,7 @@ data CampusMessage = MsgCampusIdentPlaceholder
findUser :: LdapConf -> Ldap -> Text -> [Ldap.Attr] -> IO [Ldap.SearchEntry]
findUser LdapConf{..} ldap ident retAttrs = fromMaybe [] <$> findM (assertM (not . null) . lift . flip (Ldap.search ldap ldapBase userSearchSettings) retAttrs) userFilters
findUser conf@LdapConf{..} ldap ident retAttrs = fromMaybe [] <$> findM (assertM (not . null) . lift . flip (Ldap.search ldap ldapBase $ userSearchSettings conf) retAttrs) userFilters
where
userFilters =
[ ldapUserPrincipalName Ldap.:= Text.encodeUtf8 ident
@ -54,14 +55,24 @@ findUser LdapConf{..} ldap ident retAttrs = fromMaybe [] <$> findM (assertM (not
, ldapUserEmail' <- toList ldapUserEmail
] ++
[ ldapUserDisplayName Ldap.:= Text.encodeUtf8 ident
, ldapUserMatriculation Ldap.:= Text.encodeUtf8 ident
]
userSearchSettings = mconcat
[ Ldap.scope ldapScope
, Ldap.size 2
, Ldap.time ldapSearchTimeout
, Ldap.derefAliases Ldap.DerefAlways
findUserMatr :: LdapConf -> Ldap -> Text -> [Ldap.Attr] -> IO [Ldap.SearchEntry]
findUserMatr conf@LdapConf{..} ldap userMatr retAttrs = fromMaybe [] <$> findM (assertM (not . null) . lift . flip (Ldap.search ldap ldapBase $ userSearchSettings conf) retAttrs) userFilters
where
userFilters =
[ ldapUserMatriculation Ldap.:= Text.encodeUtf8 userMatr
]
userSearchSettings :: LdapConf -> Ldap.Mod Ldap.Search
userSearchSettings LdapConf{..} = mconcat
[ Ldap.scope ldapScope
, Ldap.size 2
, Ldap.time ldapSearchTimeout
, Ldap.derefAliases Ldap.DerefAlways
]
ldapUserPrincipalName, ldapUserDisplayName, ldapUserMatriculation, ldapUserFirstName, ldapUserSurname, ldapUserTitle, ldapUserStudyFeatures, ldapUserFieldName, ldapUserSchoolAssociation, ldapSex, ldapUserSubTermsSemester :: Ldap.Attr
ldapUserPrincipalName = Ldap.Attr "userPrincipalName"
ldapUserDisplayName = Ldap.Attr "displayName"
@ -99,13 +110,7 @@ campusUser conf@LdapConf{..} pool Creds{..} = liftIO . (`catches` errHandlers) $
results <- case lookup "DN" credsExtra of
Just userDN -> do
let userFilter = Ldap.Present ldapUserPrincipalName
userSearchSettings = mconcat
[ Ldap.scope Ldap.BaseObject
, Ldap.size 2
, Ldap.time ldapSearchTimeout
, Ldap.derefAliases Ldap.DerefAlways
]
Ldap.search ldap (Ldap.Dn userDN) userSearchSettings userFilter []
Ldap.search ldap (Ldap.Dn userDN) (userSearchSettings conf) userFilter []
Nothing -> do
findUser conf ldap credsIdent []
case results of
@ -123,6 +128,26 @@ campusUser' conf pool User{userIdent}
= runMaybeT . catchIfMaybeT (is _CampusUserNoResult) $ campusUser conf pool (Creds apLdap (CI.original userIdent) [])
campusUserMatr :: MonadUnliftIO m => LdapConf -> LdapPool -> UserMatriculation -> m (Ldap.AttrList [])
campusUserMatr conf@LdapConf{..} pool userMatr = liftIO . (`catches` errHandlers) $ either (throwM . CampusUserLdapError) return <=< withLdap pool $ \ldap -> do
Ldap.bind ldap ldapDn ldapPassword
results <- findUserMatr conf ldap userMatr []
case results of
[] -> throwM CampusUserNoResult
[Ldap.SearchEntry _ attrs] -> return attrs
_otherwise -> throwM CampusUserAmbiguous
where
errHandlers = [ Exc.Handler $ \LineTooLong -> throwM CampusUserLineTooLong
, Exc.Handler $ \(HostNotResolved host) -> throwM $ CampusUserHostNotResolved host
, Exc.Handler $ \(HostCannotConnect host excs) -> throwM $ CampusUserHostCannotConnect host excs
]
campusUserMatr' :: (MonadCatch m, MonadUnliftIO m) => LdapConf -> LdapPool -> UserMatriculation -> m (Maybe (Ldap.AttrList []))
campusUserMatr' conf pool
= runMaybeT . catchIfMaybeT (is _CampusUserNoResult) . campusUserMatr conf pool
campusForm :: ( RenderMessage (HandlerSite m) FormMessage
, RenderMessage (HandlerSite m) CampusMessage
, MonadHandler m

View File

@ -17,7 +17,7 @@ module Database.Esqueleto.Utils
, selectExists
, SqlHashable
, sha256
, maybe
, maybe, unsafeCoalesce
, SqlProject(..)
, (->.)
, fromSqlKey
@ -236,6 +236,9 @@ maybe onNothing onJust val = E.case_
]
(E.else_ onNothing)
unsafeCoalesce :: E.PersistField a => [E.SqlExpr (E.Value (Maybe a))] -> E.SqlExpr (E.Value a)
unsafeCoalesce = E.veryUnsafeCoerceSqlExprValue . E.coalesce
class (PersistEntity entity, PersistField value) => SqlProject entity value entity' value' | entity value entity' -> value', entity value value' -> entity' where
sqlProject :: E.SqlExpr entity' -> EntityField entity value -> E.SqlExpr (E.Value value')

View File

@ -23,7 +23,7 @@ import Auth.Dummy
import qualified Network.Wai as W (pathInfo)
import qualified Yesod.Core.Unsafe as Unsafe
import Data.CaseInsensitive (original, mk)
import qualified Data.CaseInsensitive as CI
import Data.ByteArray (convert)
import Crypto.Hash (Digest, SHAKE256, SHAKE128)
@ -45,6 +45,7 @@ import qualified Data.Set as Set
import Data.Map (Map, (!?))
import qualified Data.Map as Map
import qualified Data.HashSet as HashSet
import qualified Data.List.NonEmpty as NonEmpty
import Data.List (nubBy, (!!), findIndex, inits)
import qualified Data.List as List
@ -69,6 +70,7 @@ import qualified Control.Monad.Catch as C
import Handler.Utils.StudyFeatures
import Handler.Utils.SchoolLdap
import Handler.Utils.ExamOffice.Exam
import Handler.Utils.ExamOffice.ExternalExam
import Handler.Utils.ExamOffice.Course
import Handler.Utils.Profile
import Handler.Utils.Routes
@ -371,10 +373,34 @@ tagAccessPredicate AuthExamOffice = APDB $ \mAuthId route _ -> case route of
E.where_ $ examOfficeExamResultAuth (E.val authId) examResult
guardMExceptT hasUsers (unauthorizedI MsgUnauthorizedExamExamOffice)
return Authorized
EExamR tid ssh coursen examn _ -> $cachedHereBinary (mAuthId, tid, ssh, coursen, examn) . exceptT return return $ do
authId <- maybeExceptT AuthenticationRequired $ return mAuthId
hasUsers <- lift . E.selectExists . E.from $ \(eexam `E.InnerJoin` eexamResult) -> do
E.on $ eexam E.^. ExternalExamId E.==. eexamResult E.^. ExternalExamResultExam
E.where_ $ eexam E.^. ExternalExamTerm E.==. E.val tid
E.&&. eexam E.^. ExternalExamSchool E.==. E.val ssh
E.&&. eexam E.^. ExternalExamCourseName E.==. E.val coursen
E.&&. eexam E.^. ExternalExamExamName E.==. E.val examn
E.where_ $ examOfficeExternalExamResultAuth (E.val authId) eexamResult
guardMExceptT hasUsers $ unauthorizedI MsgUnauthorizedExternalExamExamOffice
return Authorized
_other -> $cachedHereBinary mAuthId . exceptT return return $ do
authId <- maybeExceptT AuthenticationRequired $ return mAuthId
adrights <- lift $ selectFirst [UserFunctionUser ==. authId, UserFunctionFunction ==. SchoolExamOffice] []
guardMExceptT (isJust adrights) (unauthorizedI MsgUnauthorizedExamOffice)
isExamOffice <- lift $ exists [UserFunctionUser ==. authId, UserFunctionFunction ==. SchoolExamOffice]
guardMExceptT isExamOffice (unauthorizedI MsgUnauthorizedExamOffice)
return Authorized
tagAccessPredicate AuthEvaluation = APDB $ \mAuthId route _ -> case route of
ParticipantsR tid ssh -> $cachedHereBinary (mAuthId, tid, ssh) . exceptT return return $ do
authId <- maybeExceptT AuthenticationRequired $ return mAuthId
isEvaluation <- lift . existsBy $ UniqueUserFunction authId ssh SchoolEvaluation
guardMExceptT isEvaluation $ unauthorizedI MsgUnauthorizedEvaluation
return Authorized
_other -> $cachedHereBinary mAuthId . exceptT return return $ do
authId <- maybeExceptT AuthenticationRequired $ return mAuthId
isEvaluation <- lift $ exists [UserFunctionUser ==. authId, UserFunctionFunction ==. SchoolEvaluation]
guardMExceptT isEvaluation $ unauthorizedI MsgUnauthorizedEvaluation
return Authorized
tagAccessPredicate AuthToken = APDB $ \mAuthId route isWrite -> exceptT return return $
lift . validateToken mAuthId route isWrite =<< askTokenUnsafe
@ -420,7 +446,18 @@ tagAccessPredicate AuthLecturer = APDB $ \mAuthId route _ -> case route of
E.&&. allocation E.^. AllocationTerm E.==. E.val tid
E.&&. allocation E.^. AllocationSchool E.==. E.val ssh
E.&&. allocation E.^. AllocationShorthand E.==. E.val ash
guardMExceptT isLecturer (unauthorizedI MsgUnauthorizedAllocationLecturer)
guardMExceptT isLecturer $ unauthorizedI MsgUnauthorizedAllocationLecturer
return Authorized
EExamR tid ssh coursen examn _ -> $cachedHereBinary (mAuthId, tid, ssh, coursen, examn) . exceptT return return $ do
authId <- maybeExceptT AuthenticationRequired $ return mAuthId
isLecturer <- lift . E.selectExists . E.from $ \(eexam `E.InnerJoin` staff) -> do
E.on $ eexam E.^. ExternalExamId E.==. staff E.^. ExternalExamStaffExam
E.where_ $ staff E.^. ExternalExamStaffUser E.==. E.val authId
E.&&. eexam E.^. ExternalExamTerm E.==. E.val tid
E.&&. eexam E.^. ExternalExamSchool E.==. E.val ssh
E.&&. eexam E.^. ExternalExamCourseName E.==. E.val coursen
E.&&. eexam E.^. ExternalExamExamName E.==. E.val examn
guardMExceptT isLecturer $ unauthorizedI MsgUnauthorizedExternalExamLecturer
return Authorized
-- lecturer for any school will do
_ -> $cachedHereBinary mAuthId . exceptT return return $ do
@ -886,6 +923,17 @@ tagAccessPredicate AuthExamResult = APDB $ \mAuthId route _ -> case route of
E.&&. exam E.^. ExamName E.==. E.val examn
guardMExceptT (hasResult || hasPartResult) (unauthorizedI MsgUnauthorizedExamResult)
return Authorized
EExamR tid ssh coursen examn _ -> $cachedHereBinary (mAuthId, tid, ssh, coursen, examn) . exceptT return return $ do
authId <- maybeExceptT AuthenticationRequired $ return mAuthId
hasResult <- $cachedHereBinary (authId, tid, ssh, coursen, examn) . lift . E.selectExists . E.from $ \(eexam `E.InnerJoin` eexamResult) -> do
E.on $ eexam E.^. ExternalExamId E.==. eexamResult E.^. ExternalExamResultExam
E.where_ $ eexamResult E.^. ExternalExamResultUser E.==. E.val authId
E.&&. eexam E.^. ExternalExamTerm E.==. E.val tid
E.&&. eexam E.^. ExternalExamSchool E.==. E.val ssh
E.&&. eexam E.^. ExternalExamCourseName E.==. E.val coursen
E.&&. eexam E.^. ExternalExamExamName E.==. E.val examn
guardMExceptT hasResult $ unauthorizedI MsgUnauthorizedExternalExamResult
return Authorized
CourseR tid ssh csh _ -> exceptT return return $ do
authId <- maybeExceptT AuthenticationRequired $ return mAuthId
hasResult <- $cachedHereBinary (authId, tid, ssh, csh) . lift . E.selectExists . E.from $ \(course `E.InnerJoin` exam `E.InnerJoin` examResult) -> do
@ -1049,7 +1097,14 @@ tagAccessPredicate AuthRegisterGroup = APDB $ \mAuthId route _ -> case route of
guard $ not hasOther
return Authorized
r -> $unsupportedAuthPredicate AuthRegisterGroup r
tagAccessPredicate AuthEmpty = APDB $ \_ route _ -> case route of
tagAccessPredicate AuthEmpty = APDB $ \mAuthId route _ -> case route of
EExamListR -> exceptT return return $ do
authId <- maybeExceptT AuthenticationRequired $ return mAuthId
hasExternalExams <- $cachedHereBinary authId . lift . E.selectExists . E.from $ \(eexam `E.InnerJoin` eexamStaff) -> do
E.on $ eexam E.^. ExternalExamId E.==. eexamStaff E.^. ExternalExamStaffExam
E.where_ $ eexamStaff E.^. ExternalExamStaffUser E.==. E.val authId
guardMExceptT (not hasExternalExams) $ unauthorizedI MsgUnauthorizedExternalExamListNotEmpty
return Authorized
CourseR tid ssh csh _ -> maybeT (unauthorizedI MsgCourseNotEmpty) $ do
-- Entity cid Course{..} <- MaybeT . getBy $ TermSchoolCourseShort tid ssh csh
cid <- $cachedHereBinary (tid, ssh, csh) . MaybeT . getKeyBy $ TermSchoolCourseShort tid ssh csh
@ -1516,8 +1571,9 @@ siteLayout' headingOverride widget = do
where
go crumbs Nothing = return crumbs
go crumbs (Just cRoute) = do
hasAccess <- hasReadAccessTo cRoute
(title, next) <- breadcrumb cRoute
go ((cRoute, title) : crumbs) next
go ((cRoute, title, hasAccess) : crumbs) next
(title, parents) <- breadcrumbs' mcurrentRoute
-- let isParent :: Route UniWorX -> Bool
@ -1604,7 +1660,7 @@ siteLayout' headingOverride widget = do
}
let highlight :: Route UniWorX -> Bool -- highlight last route in breadcrumbs, favorites taking priority
highlight = let crumbs = mcons mcurrentRoute $ fst <$> reverse parents
highlight = let crumbs = mcons mcurrentRoute $ view _1 <$> reverse parents
navItems = map (view _2) favourites ++ map (urlRoute . menuItemRoute . view _1) menuTypes
highR = find (`elem` navItems) . uncurry (++) $ partition (`elem` map (view _2) favourites) crumbs
in \r -> Just r == highR
@ -1753,7 +1809,7 @@ instance YesodBreadcrumbs UniWorX where
breadcrumb SchoolListR = i18nCrumb MsgMenuSchoolList $ Just AdminR
breadcrumb (SchoolR ssh SchoolEditR) = maybeT (i18nCrumb MsgBreadcrumbSchool $ Just SchoolListR) $ do
School{..} <- MaybeT . runDB $ get ssh
return (original schoolName, Just SchoolListR)
return (CI.original schoolName, Just SchoolListR)
breadcrumb SchoolNewR = i18nCrumb MsgMenuSchoolNew $ Just SchoolListR
breadcrumb (ExamOfficeR EOExamsR) = i18nCrumb MsgMenuExamOfficeExams Nothing
@ -1793,13 +1849,13 @@ instance YesodBreadcrumbs UniWorX where
guardM . lift . runDB $
(&&) <$> fmap isJust (get ssh)
<*> fmap isJust (get tid)
return (original $ unSchoolKey ssh, Just $ TermCourseListR tid)
return (CI.original $ unSchoolKey ssh, Just $ TermCourseListR tid)
breadcrumb AllocationListR = i18nCrumb MsgAllocationListTitle $ Just HomeR
breadcrumb (AllocationR tid ssh ash AShowR) = maybeT (i18nCrumb MsgBreadcrumbAllocation $ Just AllocationListR) $ do
mr <- getMessageRender
Entity _ Allocation{allocationName} <- MaybeT . runDB . getBy $ TermSchoolAllocationShort tid ssh ash
return ([st|#{allocationName} (#{mr (ShortTermIdentifier (unTermKey tid))}, #{original (unSchoolKey ssh)})|], Just $ AllocationListR)
return ([st|#{allocationName} (#{mr (ShortTermIdentifier (unTermKey tid))}, #{CI.original (unSchoolKey ssh)})|], Just $ AllocationListR)
breadcrumb (AllocationR tid ssh ash ARegisterR) = i18nCrumb MsgBreadcrumbAllocationRegister . Just $ AllocationR tid ssh ash AShowR
breadcrumb (AllocationR tid ssh ash (AApplyR cID)) = maybeT (i18nCrumb MsgBreadcrumbCourse . Just $ AllocationR tid ssh ash AShowR) $ do
cid <- decrypt cID
@ -1807,13 +1863,16 @@ instance YesodBreadcrumbs UniWorX where
aid <- MaybeT . getKeyBy $ TermSchoolAllocationShort tid ssh ash
guardM . lift $ exists [ AllocationCourseAllocation ==. aid, AllocationCourseCourse ==. cid ]
MaybeT $ get cid
return (original courseName, Just $ AllocationR tid ssh ash AShowR)
return (CI.original courseName, Just $ AllocationR tid ssh ash AShowR)
breadcrumb ParticipantsListR = i18nCrumb MsgBreadcrumbParticipantsList $ Just CourseListR
breadcrumb (ParticipantsR _ _) = i18nCrumb MsgBreadcrumbParticipants $ Just ParticipantsListR
breadcrumb CourseListR = i18nCrumb MsgMenuCourseList Nothing
breadcrumb CourseNewR = i18nCrumb MsgMenuCourseNew $ Just CourseListR
breadcrumb (CourseR tid ssh csh CShowR) = maybeT (i18nCrumb MsgBreadcrumbCourse . Just $ TermSchoolCourseListR tid ssh) $ do
guardM . lift . runDB . existsBy $ TermSchoolCourseShort tid ssh csh
return (original csh, Just $ TermSchoolCourseListR tid ssh)
return (CI.original csh, Just $ TermSchoolCourseListR tid ssh)
breadcrumb (CourseR tid ssh csh CEditR) = i18nCrumb MsgMenuCourseEdit . Just $ CourseR tid ssh csh CShowR
breadcrumb (CourseR tid ssh csh CUsersR) = i18nCrumb MsgMenuCourseMembers . Just $ CourseR tid ssh csh CShowR
breadcrumb (CourseR tid ssh csh CAddUserR) = i18nCrumb MsgMenuCourseAddMembers . Just $ CourseR tid ssh csh CUsersR
@ -1871,7 +1930,7 @@ instance YesodBreadcrumbs UniWorX where
breadcrumb (CourseR tid ssh csh (ExamR examn sRoute)) = case sRoute of
EShowR -> maybeT (i18nCrumb MsgBreadcrumbExam . Just $ CourseR tid ssh csh CExamListR) $ do
guardM . hasReadAccessTo $ CExamR tid ssh csh examn EShowR
return (original examn, Just $ CourseR tid ssh csh CExamListR)
return (CI.original examn, Just $ CourseR tid ssh csh CExamListR)
EEditR -> i18nCrumb MsgMenuExamEdit . Just $ CExamR tid ssh csh examn EShowR
EUsersR -> i18nCrumb MsgMenuExamUsers . Just $ CExamR tid ssh csh examn EShowR
EAddUserR -> i18nCrumb MsgMenuExamAddMembers . Just $ CExamR tid ssh csh examn EUsersR
@ -1885,7 +1944,7 @@ instance YesodBreadcrumbs UniWorX where
breadcrumb (CourseR tid ssh csh (TutorialR tutn sRoute)) = case sRoute of
TUsersR -> maybeT (i18nCrumb MsgBreadcrumbTutorial . Just $ CourseR tid ssh csh CTutorialListR) $ do
guardM . hasReadAccessTo $ CTutorialR tid ssh csh tutn TUsersR
return (original tutn, Just $ CourseR tid ssh csh CTutorialListR)
return (CI.original tutn, Just $ CourseR tid ssh csh CTutorialListR)
TEditR -> i18nCrumb MsgMenuTutorialEdit . Just $ CTutorialR tid ssh csh tutn TUsersR
TDeleteR -> i18nCrumb MsgMenuTutorialDelete . Just $ CTutorialR tid ssh csh tutn TUsersR
TCommR -> i18nCrumb MsgMenuTutorialComm . Just $ CTutorialR tid ssh csh tutn TUsersR
@ -1895,7 +1954,7 @@ instance YesodBreadcrumbs UniWorX where
breadcrumb (CourseR tid ssh csh (SheetR shn sRoute)) = case sRoute of
SShowR -> maybeT (i18nCrumb MsgBreadcrumbSheet . Just $ CourseR tid ssh csh SheetListR) $ do
guardM . hasReadAccessTo $ CSheetR tid ssh csh shn SShowR
return (original shn, Just $ CourseR tid ssh csh SheetListR)
return (CI.original shn, Just $ CourseR tid ssh csh SheetListR)
SEditR -> i18nCrumb MsgMenuSheetEdit . Just $ CSheetR tid ssh csh shn SShowR
SDelR -> i18nCrumb MsgMenuSheetDelete . Just $ CSheetR tid ssh csh shn SShowR
SSubsR -> i18nCrumb MsgMenuSubmissions . Just $ CSheetR tid ssh csh shn SShowR
@ -1928,7 +1987,7 @@ instance YesodBreadcrumbs UniWorX where
breadcrumb (CourseR tid ssh csh (MaterialR mnm sRoute)) = case sRoute of
MShowR -> maybeT (i18nCrumb MsgBreadcrumbMaterial . Just $ CourseR tid ssh csh MaterialListR) $ do
guardM . hasReadAccessTo $ CMaterialR tid ssh csh mnm MShowR
return (original mnm, Just $ CourseR tid ssh csh MaterialListR)
return (CI.original mnm, Just $ CourseR tid ssh csh MaterialListR)
MEditR -> i18nCrumb MsgMenuMaterialEdit . Just $ CMaterialR tid ssh csh mnm MShowR
MDelR -> i18nCrumb MsgMenuMaterialDelete . Just $ CMaterialR tid ssh csh mnm MShowR
MArchiveR -> i18nCrumb MsgBreadcrumbMaterialArchive . Just $ CMaterialR tid ssh csh mnm MShowR
@ -1950,6 +2009,26 @@ instance YesodBreadcrumbs UniWorX where
breadcrumb MessageListR = i18nCrumb MsgMenuMessageList $ Just AdminR
breadcrumb GlossaryR = i18nCrumb MsgMenuGlossary $ Just InfoR
breadcrumb EExamListR = i18nCrumb MsgMenuExternalExamList Nothing
breadcrumb EExamNewR = do
isEO <- hasReadAccessTo $ ExamOfficeR EOExamsR
i18nCrumb MsgBreadcrumbExternalExamNew . Just $ if
| isEO -> ExamOfficeR EOExamsR
| otherwise -> EExamListR
breadcrumb (EExamR tid ssh coursen examn sRoute) = case sRoute of
EEShowR -> do
isEO <- hasReadAccessTo $ ExamOfficeR EOExamsR
maybeT (i18nCrumb MsgBreadcrumbExternalExam . Just $ bool EExamListR (ExamOfficeR EOExamsR) isEO) $ do
guardM . hasReadAccessTo $ EExamR tid ssh coursen examn EEShowR
i18nCrumb (MsgBreadcrumbExternalExamShow coursen examn) . Just $ if
| isEO -> ExamOfficeR EOExamsR
| otherwise -> EExamListR
EEEditR -> i18nCrumb MsgBreadcrumbExternalExamEdit . Just $ EExamR tid ssh coursen examn EEShowR
EEUsersR -> i18nCrumb MsgBreadcrumbExternalExamUsers . Just $ EExamR tid ssh coursen examn EEShowR
EEGradesR -> i18nCrumb MsgBreadcrumbExternalExamGrades . Just $ EExamR tid ssh coursen examn EEShowR
EEStaffInviteR -> i18nCrumb MsgBreadcrumbExternalExamStaffInvite . Just $ EExamR tid ssh coursen examn EEShowR
-- breadcrumb _ = return ("Uni2work", Nothing) -- Default is no breadcrumb at all
submissionList :: TermId -> CourseShorthand -> SheetName -> UserId -> DB [E.Value SubmissionId]
@ -2136,6 +2215,14 @@ pageActions (HomeR) =
, menuItemModal = False
, menuItemAccessCallback' = return True
}
, MenuItem
{ menuItemType = PageActionSecondary
, menuItemLabel = MsgMenuExternalExamList
, menuItemIcon = Nothing
, menuItemRoute = SomeRoute EExamListR
, menuItemModal = False
, menuItemAccessCallback' = return True
}
, MenuItem
{ menuItemType = PageActionPrime
, menuItemLabel = MsgMenuOpenCourses
@ -2355,6 +2442,14 @@ pageActions TermShowR =
, menuItemModal = False
, menuItemAccessCallback' = return True
}
, MenuItem
{ menuItemType = PageActionPrime
, menuItemLabel = MsgMenuParticipantsList
, menuItemIcon = Nothing
, menuItemRoute = SomeRoute ParticipantsListR
, menuItemModal = False
, menuItemAccessCallback' = return True
}
]
pageActions (TermCourseListR tid) =
[ MenuItem
@ -2411,6 +2506,14 @@ pageActions (CourseListR) =
, menuItemModal = False
, menuItemAccessCallback' = return True
}
, MenuItem
{ menuItemType = PageActionPrime
, menuItemLabel = MsgMenuParticipantsList
, menuItemIcon = Nothing
, menuItemRoute = SomeRoute ParticipantsListR
, menuItemModal = False
, menuItemAccessCallback' = return True
}
]
pageActions (CourseNewR) = [
MenuItem
@ -2633,8 +2736,8 @@ pageActions (CourseR tid ssh csh SheetListR) =
, menuItemLabel = MsgMenuCorrectionsOwn
, menuItemIcon = Nothing
, menuItemRoute = SomeRoute (CorrectionsR, [ ("corrections-term" , termToText $ unTermKey tid)
, ("corrections-school", original $ unSchoolKey ssh)
, ("corrections-course", original csh)
, ("corrections-school", CI.original $ unSchoolKey ssh)
, ("corrections-course", CI.original csh)
])
, menuItemModal = False
, menuItemAccessCallback' = do
@ -2868,9 +2971,9 @@ pageActions (CSheetR tid ssh csh shn SShowR) =
, menuItemLabel = MsgMenuCorrectionsOwn
, menuItemIcon = Nothing
, menuItemRoute = SomeRoute (CorrectionsR, [ ("corrections-term" , termToText $ unTermKey tid)
, ("corrections-school", original $ unSchoolKey ssh)
, ("corrections-course", original csh)
, ("corrections-sheet" , original shn)
, ("corrections-school", CI.original $ unSchoolKey ssh)
, ("corrections-course", CI.original csh)
, ("corrections-sheet" , CI.original shn)
])
, menuItemModal = False
, menuItemAccessCallback' = (== Authorized) <$> evalAccessCorrector tid ssh csh
@ -3080,6 +3183,72 @@ pageActions (CorrectionsGradeR) =
return $ orOf (traverse . _Value . _submissionModeCorrector) sheets
}
]
pageActions EExamListR =
[ MenuItem
{ menuItemType = PageActionPrime
, menuItemLabel = MsgMenuExternalExamNew
, menuItemIcon = Nothing
, menuItemRoute = SomeRoute EExamNewR
, menuItemModal = False
, menuItemAccessCallback' = return True
}
]
pageActions (EExamR tid ssh coursen examn EEShowR) =
[ MenuItem
{ menuItemType = PageActionPrime
, menuItemLabel = MsgMenuExternalExamEdit
, menuItemIcon = Nothing
, menuItemRoute = SomeRoute $ EExamR tid ssh coursen examn EEEditR
, menuItemModal = False
, menuItemAccessCallback' = return True
}
, MenuItem
{ menuItemType = PageActionPrime
, menuItemLabel = MsgMenuExternalExamUsers
, menuItemIcon = Nothing
, menuItemRoute = SomeRoute $ EExamR tid ssh coursen examn EEUsersR
, menuItemModal = False
, menuItemAccessCallback' = return True
}
, MenuItem
{ menuItemType = PageActionPrime
, menuItemLabel = MsgMenuExternalExamGrades
, menuItemIcon = Nothing
, menuItemRoute = SomeRoute $ EExamR tid ssh coursen examn EEGradesR
, menuItemModal = False
, menuItemAccessCallback' = return True
}
]
pageActions (EExamR tid ssh coursen examn EEGradesR) =
[ MenuItem
{ menuItemType = PageActionPrime
, menuItemLabel = MsgMenuExternalExamUsers
, menuItemIcon = Nothing
, menuItemRoute = SomeRoute $ EExamR tid ssh coursen examn EEUsersR
, menuItemModal = False
, menuItemAccessCallback' = return True
}
]
pageActions (EExamR tid ssh coursen examn EEUsersR) =
[ MenuItem
{ menuItemType = PageActionPrime
, menuItemLabel = MsgMenuExternalExamGrades
, menuItemIcon = Nothing
, menuItemRoute = SomeRoute $ EExamR tid ssh coursen examn EEGradesR
, menuItemModal = False
, menuItemAccessCallback' = return True
}
]
pageActions ParticipantsListR =
[ MenuItem
{ menuItemType = PageActionPrime
, menuItemLabel = MsgCsvOptions
, menuItemIcon = Nothing
, menuItemRoute = SomeRoute CsvOptionsR
, menuItemModal = True
, menuItemAccessCallback' = return True
}
]
pageActions _ = []
@ -3197,6 +3366,7 @@ routeNormalizers =
, ncMaterial
, ncTutorial
, ncExam
, ncExternalExam
, verifySubmission
, verifyCourseApplication
, verifyCourseNews
@ -3220,7 +3390,7 @@ routeNormalizers =
caseChanged :: (Eq a, Show a) => CI a -> CI a -> MaybeT (WriterT Any DB) ()
caseChanged a b
| ((/=) `on` original) a b = do
| ((/=) `on` CI.original) a b = do
$logDebugS "routeNormalizers" [st|#{tshow a} /= #{tshow b}|]
tell $ Any True
| otherwise = return ()
@ -3265,6 +3435,14 @@ routeNormalizers =
Entity _ Exam{..} <- MaybeT . $cachedHereBinary (cid, examn) . lift . getBy $ UniqueExam cid examn
caseChanged examn examName
return $ route & typesUsing @RouteChildren @ExamName . filtered (== examn) .~ examName
ncExternalExam = maybeOrig $ \route -> do
EExamR tid ssh coursen examn _ <- return route
Entity _ ExternalExam{..} <- MaybeT . $cachedHereBinary (tid, ssh, coursen, examn) . lift . getBy $ UniqueExternalExam tid ssh coursen examn
caseChanged coursen externalExamCourseName
caseChanged examn externalExamExamName
return $ route
& typesUsing @RouteChildren @CourseName . filtered (== coursen) .~ externalExamCourseName
& typesUsing @RouteChildren @ExamName . filtered (== examn) .~ externalExamExamName
verifySubmission = maybeOrig $ \route -> do
CSubmissionR _tid _ssh _csh _shn cID sr <- return route
sId <- $cachedHereBinary cID $ decrypt cID
@ -3304,7 +3482,8 @@ instance YesodPersistRunner UniWorX where
return . (, cleanup) $ DBRunner (\act -> $logDebugS "YesodPersist" "runDBRunner" >> runDBRunner act)
data CampusUserConversionException
= CampusUserInvalidEmail
= CampusUserInvalidIdent
| CampusUserInvalidEmail
| CampusUserInvalidDisplayName
| CampusUserInvalidGivenName
| CampusUserInvalidSurname
@ -3318,12 +3497,36 @@ instance Exception CampusUserConversionException
embedRenderMessage ''UniWorX ''CampusUserConversionException id
upsertCampusUser :: Ldap.AttrList [] -> Creds UniWorX -> DB (Entity User)
upsertCampusUser ldapData Creds{..} = do
data UpsertCampusUserMode
= UpsertCampusUser
| UpsertCampusUserDummy { upsertCampusUserIdent :: UserIdent }
| UpsertCampusUserOther { uspertCampusUserIdent :: UserIdent }
deriving (Eq, Ord, Read, Show, Generic, Typeable)
makeLenses_ ''UpsertCampusUserMode
makePrisms ''UpsertCampusUserMode
_upsertCampusUserMode :: Traversal' (Creds UniWorX) UpsertCampusUserMode
_upsertCampusUserMode mMode cs@Creds{..}
| credsPlugin == "dummy" = setMode <$> mMode (UpsertCampusUserDummy $ CI.mk credsIdent)
| credsPlugin `elem` others = setMode <$> mMode (UpsertCampusUserOther $ CI.mk credsIdent)
| otherwise = setMode <$> mMode UpsertCampusUser
where
setMode UpsertCampusUser
= cs{ credsPlugin = "LDAP" }
setMode (UpsertCampusUserDummy ident)
= cs{ credsPlugin = "dummy", credsIdent = CI.original ident }
setMode (UpsertCampusUserOther ident)
= cs{ credsPlugin = bool (NonEmpty.head others) credsPlugin (credsPlugin `elem` others), credsIdent = CI.original ident }
others = "PWHash" :| []
upsertCampusUser :: UpsertCampusUserMode -> Ldap.AttrList [] -> DB (Entity User)
upsertCampusUser plugin ldapData = do
now <- liftIO getCurrentTime
UserDefaultConf{..} <- getsYesod $ view _appUserDefaults
let
userIdent'' = fold [ v | (k, v) <- ldapData, k == ldapUserPrincipalName ]
userMatrikelnummer' = fold [ v | (k, v) <- ldapData, k == ldapUserMatriculation ]
userEmail' = fold $ do
k' <- toList ldapUserEmail
@ -3337,13 +3540,23 @@ upsertCampusUser ldapData Creds{..} = do
userSex' = fold [ v | (k, v) <- ldapData, k == ldapSex ]
userAuthentication
| isPWHash = error "PWHash should only work for users that are already known"
| is _UpsertCampusUserOther plugin
= error "PWHash should only work for users that are already known"
| otherwise = AuthLDAP
userLastAuthentication = now <$ guard (not isDummy)
userLastAuthentication = now <$ guard (isn't _UpsertCampusUserDummy plugin)
userIdent <- if
| [bs] <- userIdent''
, Right userIdent' <- CI.mk <$> Text.decodeUtf8' bs
, hasn't _upsertCampusUserIdent plugin || has (_upsertCampusUserIdent . only userIdent') plugin
-> return userIdent'
| Just userIdent' <- plugin ^? _upsertCampusUserIdent
-> return userIdent'
| otherwise
-> throwM CampusUserInvalidIdent
userEmail <- if
| userEmail : _ <- mapMaybe (assertM (elem '@') . either (const Nothing) Just . Text.decodeUtf8') userEmail'
-> return $ mk userEmail
-> return $ CI.mk userEmail
| otherwise
-> throwM CampusUserInvalidEmail
userDisplayName' <- if
@ -3393,8 +3606,7 @@ upsertCampusUser ldapData Creds{..} = do
let
newUser = User
{ userIdent = mk credsIdent
, userMaxFavourites = userDefaultMaxFavourites
{ userMaxFavourites = userDefaultMaxFavourites
, userMaxFavouriteTerms = userDefaultMaxFavouriteTerms
, userTheme = userDefaultTheme
, userDateTimeFormat = userDefaultDateTimeFormat
@ -3422,9 +3634,9 @@ upsertCampusUser ldapData Creds{..} = do
, UserSex =. userSex
, UserLastLdapSynchronisation =. Just now
] ++
[ UserLastAuthentication =. Just now | not isDummy ]
[ UserLastAuthentication =. Just now | isn't _UpsertCampusUserDummy plugin ]
user@(Entity userId userRec) <- upsertBy (UniqueAuthentication $ mk credsIdent) newUser userUpdate
user@(Entity userId userRec) <- upsertBy (UniqueAuthentication userIdent) newUser userUpdate
unless (validDisplayName userTitle userFirstName userSurname $ userDisplayName userRec) $
update userId [ UserDisplayName =. userDisplayName' ]
@ -3437,7 +3649,7 @@ upsertCampusUser ldapData Creds{..} = do
Right str <- return $ Text.decodeUtf8' v'
return str
termNames = nubBy ((==) `on` mk) $ do
termNames = nubBy ((==) `on` CI.mk) $ do
(k, v) <- ldapData
guard $ k == ldapUserFieldName
v' <- v
@ -3508,7 +3720,7 @@ upsertCampusUser ldapData Creds{..} = do
| otherwise -> do
$logDebugS "Campus" [st|Ignoring subterm #{tshow subterm}|]
assimilateSubTerms subterms unusedFeats
$logDebugS "Campus" [st|Terms for #{credsIdent}: #{tshow (sts, fs')}|]
$logDebugS "Campus" [st|Terms for #{userIdent}: #{tshow (sts, fs')}|]
(fs, studyFieldParentCandidates) <- runWriterT $ assimilateSubTerms sts fs'
let
@ -3614,8 +3826,6 @@ upsertCampusUser ldapData Creds{..} = do
return user
where
insertMaybe key val = get key >>= maybe (insert_ val) (\_ -> return ())
isDummy = credsPlugin == "dummy"
isPWHash = credsPlugin == "PWHash"
associateUserSchoolsByTerms :: UserId -> DB ()
associateUserSchoolsByTerms uid = do
@ -3691,18 +3901,18 @@ instance YesodAuth UniWorX where
setTitleI MsgLoginTitle
$(widgetFile "login")
authenticate Creds{..} = liftHandler . runDB $ do
authenticate creds@Creds{..} = liftHandler . runDB $ do
now <- liftIO getCurrentTime
let
userIdent = mk credsIdent
uAuth = UniqueAuthentication userIdent
uAuth = UniqueAuthentication $ CI.mk credsIdent
upsertMode = creds ^? _upsertCampusUserMode
isDummy = credsPlugin == "dummy"
isPWHash = credsPlugin == "PWHash"
isDummy = is (_Just . _UpsertCampusUserDummy) upsertMode
isOther = is (_Just . _UpsertCampusUserOther) upsertMode
excRecovery res
| isDummy || isPWHash
| isDummy || isOther
= do
case res of
UserError err -> addMessageI Error err
@ -3746,12 +3956,12 @@ instance YesodAuth UniWorX where
UniWorX{ appSettings' = AppSettings{ appUserDefaults = UserDefaultConf{..}, ..}, .. } <- getYesod
flip catches excHandlers $ case (,) <$> appLdapConf <*> appLdapPool of
Just (ldapConf, ldapPool) -> do
let userCreds = Creds credsPlugin (original userIdent) credsExtra
ldapData <- campusUser ldapConf ldapPool userCreds
$logDebugS "LDAP" $ "Successful LDAP lookup: " <> tshow ldapData
Authenticated . entityKey <$> upsertCampusUser ldapData userCreds
Nothing
Just (ldapConf, ldapPool)
| Just upsertMode' <- upsertMode -> do
ldapData <- campusUser ldapConf ldapPool Creds{..}
$logDebugS "LDAP" $ "Successful LDAP lookup: " <> tshow ldapData
Authenticated . entityKey <$> upsertCampusUser upsertMode' ldapData
_other
-> acceptExisting
authPlugins (UniWorX{ appSettings' = AppSettings{..}, appLdapPool }) = catMaybes

View File

@ -31,6 +31,7 @@ deriving instance Generic SubmissionR
deriving instance Generic MaterialR
deriving instance Generic TutorialR
deriving instance Generic ExamR
deriving instance Generic EExamR
deriving instance Generic CourseApplicationR
deriving instance Generic AllocationR
deriving instance Generic SchoolR

View File

@ -75,7 +75,7 @@ lecturerInvitationConfig = InvitationConfig{..}
where
toJunction jLecturerType = (JunctionLecturer{..}, ())
lFs = fslI MsgLecturerType & setTooltip MsgCourseLecturerRightsIdentical
invitationInsertHook _ _ _ _ = id
invitationInsertHook _ _ _ _ _ = id
invitationSuccessMsg (Entity _ Course{..}) (Entity _ Lecturer{..}) = do
MsgRenderer mr <- getMsgRenderer
return . SomeMessage $ MsgLecturerInvitationAccepted (mr lecturerType) courseShorthand

View File

@ -91,7 +91,7 @@ participantInvitationConfig = InvitationConfig{..}
studyFeatures <- wreq (studyFeaturesFieldFor Nothing False [] $ Just uid)
(fslI MsgCourseStudyFeature & setTooltip MsgCourseStudyFeatureTip) Nothing
return . fmap (, ()) $ JunctionParticipant <$> pure now <*> studyFeatures <*> pure Nothing
invitationInsertHook _ _ CourseParticipant{..} _ act = do
invitationInsertHook _ _ _ CourseParticipant{..} _ act = do
res <- act
audit $ TransactionCourseParticipantEdit courseParticipantCourse courseParticipantUser
return res

View File

@ -71,7 +71,7 @@ examCorrectorInvitationConfig = InvitationConfig{..}
return $ InvitationTokenConfig itAuthority Nothing Nothing Nothing
invitationRestriction _ _ = return Authorized
invitationForm _ _ _ = pure (JunctionExamCorrector, ())
invitationInsertHook _ _ _ _ = id
invitationInsertHook _ _ _ _ _ = id
invitationSuccessMsg (Entity _ Exam{..}) _ = return . SomeMessage $ MsgExamCorrectorInvitationAccepted examName
invitationUltDest (Entity _ Exam{..}) _ = do
Course{..} <- get404 examCourse

View File

@ -97,7 +97,7 @@ examRegistrationInvitationConfig = InvitationConfig{..}
fieldRes <- wreq (studyFeaturesFieldFor Nothing False [] $ Just uid) (fslI MsgCourseStudyFeature) Nothing
return $ (JunctionExamRegistration invDBExamRegistrationOccurrence now, ) . Just <$> fieldRes
(True , _ ) -> return $ pure (JunctionExamRegistration invDBExamRegistrationOccurrence now, Nothing)
invitationInsertHook (Entity eid Exam{..}) _ ExamRegistration{..} mField act = do
invitationInsertHook _ (Entity eid Exam{..}) _ ExamRegistration{..} mField act = do
whenIsJust mField $ \cpField -> do
insert_ $ CourseParticipant examCourse examRegistrationUser examRegistrationTime cpField Nothing
queueDBJob . JobQueueNotification $ NotificationCourseRegistered examRegistrationUser examCourse

View File

@ -8,6 +8,7 @@ import Import hiding ((<.), (.>))
import Handler.Utils
import Handler.Utils.Exam
import Handler.Utils.Users
import Handler.Utils.Csv
import Handler.ExamOffice.Exam (examCloseWidget)
@ -372,7 +373,7 @@ data ExamUserCsvAction
deriving (Eq, Ord, Read, Show, Generic, Typeable)
deriveJSON defaultOptions
{ constructorTagModifier = over Text.packed $ Text.intercalate "-" . map Text.toLower . drop 3 . dropEnd 1 . splitCamel
, fieldLabelModifier = camelToPathPiece' 3
, fieldLabelModifier = camelToPathPiece' 4
, sumEncoding = TaggedObject "action" "data"
} ''ExamUserCsvAction
@ -624,7 +625,7 @@ postEUsersR tid ssh csh examn = do
<*> preview (resultCourseNote . _entityVal . _courseUserNoteNote)
dbtCsvDecode = Just DBTCsvDecode
{ dbtCsvRowKey = \csv -> do
uid <- lift $ view _2 <$> guessUser csv
uid <- lift $ view _2 <$> guessUser' csv
fmap E.Value . MaybeT . getKeyBy $ UniqueExamRegistration eid uid
, dbtCsvComputeActions = \case
DBCsvDiffMissing{dbCsvOldKey}
@ -632,7 +633,7 @@ postEUsersR tid ssh csh examn = do
DBCsvDiffNew{dbCsvNewKey = Just _}
-> error "An UniqueExamRegistration could be found, but the ExamRegistrationKey is not among the existing keys"
DBCsvDiffNew{dbCsvNewKey = Nothing, dbCsvNew} -> do
(isPart, uid) <- lift $ guessUser dbCsvNew
(isPart, uid) <- lift $ guessUser' dbCsvNew
if
| isPart -> do
yieldM $ ExamUserCsvRegisterData <$> pure uid <*> lookupOccurrence dbCsvNew
@ -930,37 +931,17 @@ postEUsersR tid ssh csh examn = do
registeredUserName existing (E.Value -> registration) = nameWidget userDisplayName userSurname
where
Entity _ User{..} = view resultUser $ existing ! registration
guessUser :: ExamUserTableCsv -> DB (Bool, UserId)
guessUser ExamUserTableCsv{..} = $cachedHereBinary (csvEUserMatriculation, csvEUserName, csvEUserSurname) $ do
users <- E.select . E.from $ \user -> do
E.where_ . E.or $ catMaybes
[ (user E.^. UserMatrikelnummer E.==.) . E.val . Just <$> csvEUserMatriculation
, (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
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))
guessUser' :: ExamUserTableCsv -> DB (Bool, UserId)
guessUser' ExamUserTableCsv{..} = do
let criteria = Set.fromList $ catMaybes
[ GuessUserMatrikelnummer <$> csvEUserMatriculation
, GuessUserDisplayName <$> csvEUserName
, GuessUserSurname <$> csvEUserSurname
, GuessUserFirstName <$> csvEUserFirstName
]
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
pid <- maybe (throwM ExamUserCsvExceptionNoMatchingUser) return =<< guessUser criteria
(,) <$> existsBy (UniqueParticipant pid examCourse) <*> pure pid
lookupOccurrence :: ExamUserTableCsv -> DB (Maybe ExamOccurrenceId)
lookupOccurrence ExamUserTableCsv{..} = $cachedHereBinary (CI.foldedCase <$> csvEUserOccurrence) . for csvEUserOccurrence $ \occName -> do
@ -971,7 +952,7 @@ postEUsersR tid ssh csh examn = do
lookupStudyFeatures :: ExamUserTableCsv -> DB (Maybe StudyFeaturesId)
lookupStudyFeatures csv@ExamUserTableCsv{..} = do
uid <- view _2 <$> guessUser csv
uid <- view _2 <$> guessUser' csv
oldFeatures <- getBy $ UniqueParticipant uid examCourse
studyFeatures <- E.select . E.from $ \(studyFeatures `E.InnerJoin` studyDegree `E.InnerJoin` studyTerms) ->
E.distinctOnOrderBy [ E.asc (studyFeatures E.^. StudyFeaturesField)

View File

@ -6,3 +6,4 @@ import Handler.ExamOffice.Exams as Handler.ExamOffice
import Handler.ExamOffice.Fields as Handler.ExamOffice
import Handler.ExamOffice.Users as Handler.ExamOffice
import Handler.ExamOffice.Exam as Handler.ExamOffice
import Handler.ExamOffice.ExternalExam as Handler.ExamOffice

View File

@ -140,15 +140,15 @@ resultSynchronised :: Traversal' ExamUserTableData (UserDisplayName, UserSurname
resultSynchronised = _dbrOutput . _9 . traverse
data ExamUserTableCsv = ExamUserTableCsv
{ csvEUserSurname :: Text
, csvEUserFirstName :: Text
, csvEUserName :: Text
, csvEUserMatriculation :: Maybe Text
, csvEUserField :: Maybe Text
, csvEUserDegree :: Maybe Text
, csvEUserSemester :: Maybe Int
{ csvEUserSurname :: Text
, csvEUserFirstName :: Text
, csvEUserName :: Text
, csvEUserMatriculation :: Maybe Text
, csvEUserField :: Maybe Text
, csvEUserDegree :: Maybe Text
, csvEUserSemester :: Maybe Int
, csvEUserOccurrenceStart :: Maybe ZonedTime
, csvEUserExamResult :: ExamResultPassedGrade
, csvEUserExamResult :: ExamResultPassedGrade
}
deriving (Generic)
makeLenses_ ''ExamUserTableCsv
@ -396,7 +396,7 @@ postEGradesR tid ssh csh examn = do
dbtIdent = "exam-results"
dbtCsvEncode = Just DBTCsvEncode
{ dbtCsvExportForm = ExamUserCsvExportData
<$> apopt checkBoxField (fslI MsgExamUserMarkSynchronisedCsv) (Just True)
<$> apopt checkBoxField (fslI MsgExamUserMarkSynchronisedCsv & setTooltip MsgExamUserMarkSynchronisedCsvTip) (Just False)
, dbtCsvDoEncode = \ExamUserCsvExportData{..} -> C.mapM $ \(E.Value k, row) -> do
when csvEUserMarkSynchronised $ markSynced k
return $ ExamUserTableCsv
@ -437,6 +437,7 @@ postEGradesR tid ssh csh examn = do
whenIsJust usersResult join
closeWgt <- examCloseWidget (SomeRoute $ CExamR tid ssh csh examn EGradesR) eId
hasUsers <- hasReadAccessTo $ CExamR tid ssh csh examn EUsersR
siteLayoutMsg (prependCourseTitle tid ssh csh MsgExamOfficeExamUsersHeading) $ do
setTitleI $ prependCourseTitle tid ssh csh MsgExamOfficeExamUsersHeading

View File

@ -8,6 +8,7 @@ import Import
import Handler.Utils
import qualified Handler.Utils.ExamOffice.Exam as Exam
import qualified Handler.Utils.ExamOffice.ExternalExam as ExternalExam
import qualified Database.Esqueleto as E
import qualified Database.Esqueleto.Utils as E
@ -15,60 +16,81 @@ import qualified Database.Esqueleto.Utils as E
import qualified Colonnade
type ExamsTableExpr = E.SqlExpr (Entity Exam)
`E.InnerJoin` E.SqlExpr (Entity Course)
type ExamsTableExpr = ( E.SqlExpr (Maybe (Entity Exam))
`E.InnerJoin` E.SqlExpr (Maybe (Entity Course))
)
`E.FullOuterJoin` E.SqlExpr (Maybe (Entity ExternalExam))
type ExamsTableData = DBRow ( Entity Exam
, Entity Course
type ExamsTableData = DBRow ( Either (Entity ExternalExam) (Entity Exam, Entity Course)
, Natural, Natural
)
queryExam :: Getter ExamsTableExpr (E.SqlExpr (Entity Exam))
queryExam = to $(E.sqlIJproj 2 1)
queryExam :: Getter ExamsTableExpr (E.SqlExpr (Maybe (Entity Exam)))
queryExam = to $ $(E.sqlIJproj 2 1) . $(E.sqlFOJproj 2 1)
queryCourse :: Getter ExamsTableExpr (E.SqlExpr (Entity Course))
queryCourse = to $(E.sqlIJproj 2 2)
queryCourse :: Getter ExamsTableExpr (E.SqlExpr (Maybe (Entity Course)))
queryCourse = to $ $(E.sqlIJproj 2 2) . $(E.sqlFOJproj 2 1)
queryExternalExam :: Getter ExamsTableExpr (E.SqlExpr (Maybe (Entity ExternalExam)))
queryExternalExam = to $(E.sqlFOJproj 2 2)
querySynchronised :: E.SqlExpr (E.Value UserId) -> Getter ExamsTableExpr (E.SqlExpr (E.Value Natural))
querySynchronised office = to . runReader $ do
exam <- view queryExam
exam' <- view queryExam
externalExam' <- view queryExternalExam
let
synchronised = E.subSelectCount . E.from $ \examResult -> do
E.where_ $ examResult E.^. ExamResultExam E.==. exam E.^. ExamId
examSynchronised examId = E.subSelectCount . E.from $ \examResult -> do
E.where_ $ examResult E.^. ExamResultExam E.==. examId
E.where_ $ Exam.examOfficeExamResultAuth office examResult
E.where_ $ Exam.resultIsSynced office examResult
return synchronised
externalExamSynchronised externalExamId = E.subSelectCount . E.from $ \externalExamResult -> do
E.where_ $ externalExamResult E.^. ExternalExamResultExam E.==. externalExamId
E.where_ $ ExternalExam.examOfficeExternalExamResultAuth office externalExamResult
E.where_ $ ExternalExam.resultIsSynced office externalExamResult
return $ E.maybe (E.val 0) examSynchronised (exam' E.?. ExamId) E.+. E.maybe (E.val 0) externalExamSynchronised (externalExam' E.?. ExternalExamId)
queryResults :: E.SqlExpr (E.Value UserId) -> Getter ExamsTableExpr (E.SqlExpr (E.Value Natural))
queryResults office = to . runReader $ do
exam <- view queryExam
exam' <- view queryExam
externalExam' <- view queryExternalExam
let
results = E.subSelectCount . E.from $ \examResult -> do
E.where_ $ examResult E.^. ExamResultExam E.==. exam E.^. ExamId
results examId = E.subSelectCount . E.from $ \examResult -> do
E.where_ $ examResult E.^. ExamResultExam E.==. examId
E.where_ $ Exam.examOfficeExamResultAuth office examResult
return results
externalResults externalExamId = E.subSelectCount . E.from $ \externalExamResult -> do
E.where_ $ externalExamResult E.^. ExternalExamResultExam E.==. externalExamId
E.where_ $ ExternalExam.examOfficeExternalExamResultAuth office externalExamResult
return $ E.maybe (E.val 0) results (exam' E.?. ExamId) E.+. E.maybe (E.val 0) externalResults (externalExam' E.?. ExternalExamId)
queryIsSynced :: UTCTime -> E.SqlExpr (E.Value UserId) -> Getter ExamsTableExpr (E.SqlExpr (E.Value Bool))
queryIsSynced now office = to . runReader $ do
exam <- view queryExam
exam' <- view queryExam
externalExam' <- view queryExternalExam
let
synchronised = E.not_ . E.exists . E.from $ \examResult -> do
E.where_ $ examResult E.^. ExamResultExam E.==. exam E.^. ExamId
examSynchronised examId = E.not_ . E.exists . E.from $ \examResult -> do
E.where_ $ examResult E.^. ExamResultExam E.==. examId
E.where_ $ Exam.examOfficeExamResultAuth office examResult
E.where_ . E.not_ $ Exam.resultIsSynced office examResult
open = E.maybe E.true (E.>. E.val now) $ exam E.^. ExamClosed
return $ synchronised E.||. open
externalExamSynchronised externalExamId = E.not_ . E.exists . E.from $ \externalExamResult -> do
E.where_ $ externalExamResult E.^. ExternalExamResultExam E.==. externalExamId
E.where_ $ ExternalExam.examOfficeExternalExamResultAuth office externalExamResult
E.where_ . E.not_ $ ExternalExam.resultIsSynced office externalExamResult
open examClosed' = E.maybe E.true (E.>. E.val now) $ examClosed'
return $ E.maybe E.false examSynchronised (exam' E.?. ExamId) E.||. E.maybe E.false open (exam' E.?. ExamClosed) E.||. E.maybe E.false externalExamSynchronised (externalExam' E.?. ExternalExamId)
resultExam :: Lens' ExamsTableData (Entity Exam)
resultExam = _dbrOutput . _1
resultExam :: Traversal' ExamsTableData (Entity Exam)
resultExam = _dbrOutput . _1 . _Right . _1
resultCourse :: Lens' ExamsTableData (Entity Course)
resultCourse = _dbrOutput . _2
resultCourse :: Traversal' ExamsTableData (Entity Course)
resultCourse = _dbrOutput . _1 . _Right . _2
resultExternalExam :: Traversal' ExamsTableData (Entity ExternalExam)
resultExternalExam = _dbrOutput . _1 . _Left
resultSynchronised, resultResults :: Lens' ExamsTableData Natural
resultSynchronised = _dbrOutput . _3
resultResults = _dbrOutput . _4
resultSynchronised = _dbrOutput . _2
resultResults = _dbrOutput . _3
resultIsSynced :: Getter ExamsTableData Bool
resultIsSynced = to $ (>=) <$> view resultSynchronised <*> view resultResults
@ -90,6 +112,10 @@ getEOExamsR = do
courseLink :: Course -> SomeRoute UniWorX
courseLink Course{..}
= SomeRoute $ CourseR courseTerm courseSchool courseShorthand CShowR
externalExamLink :: ExternalExam -> SomeRoute UniWorX
externalExamLink ExternalExam{..}
= SomeRoute $ EExamR externalExamTerm externalExamSchool externalExamCourseName externalExamExamName EEGradesR
querySynchronised' = querySynchronised $ E.val uid
queryResults' = queryResults $ E.val uid
@ -100,34 +126,48 @@ getEOExamsR = do
dbtSQLQuery = runReaderT $ do
exam <- view queryExam
course <- view queryCourse
externalExam <- view queryExternalExam
synchronised <- view querySynchronised'
results <- view queryResults'
lift $ do
E.on $ exam E.^. ExamCourse E.==. course E.^. CourseId
E.on E.false
E.on $ exam E.?. ExamCourse E.==. course E.?. CourseId
E.where_ $ results E.>. E.val 0
E.where_ $ (E.not_ (E.isNothing $ exam E.?. ExamId) E.&&. E.not_ (E.isNothing $ course E.?. CourseId) E.&&. E.isNothing (externalExam E.?. ExternalExamId))
E.||. ( E.isNothing (exam E.?. ExamId) E.&&. E.isNothing (course E.?. CourseId) E.&&. E.not_ (E.isNothing $ externalExam E.?. ExternalExamId))
return (exam, course, synchronised, results)
dbtRowKey = views queryExam (E.^. ExamId)
return (exam, course, externalExam, synchronised, results)
dbtRowKey = views ($(multifocusG 2) queryExam queryExternalExam) (bimap (E.?. ExamId) (E.?. ExternalExamId))
dbtProj :: DBRow _ -> MaybeT (YesodDB UniWorX) ExamsTableData
dbtProj = runReaderT $ (asks . set _dbrOutput) <=< magnify _dbrOutput $ do
exam <- view $ _1 . _entityVal
course <- view $ _2 . _entityVal
exam <- view _1
course <- view _2
externalExam <- view _3
guard =<< hasReadAccessTo (urlRoute $ examLink course exam)
case (exam, course, externalExam) of
(Just exam', Just course', Nothing) -> do
guard =<< hasReadAccessTo (urlRoute $ examLink (entityVal course') (entityVal exam'))
(,,,)
<$> view _1 <*> view _2 <*> view (_3 . _Value) <*> view (_4 . _Value)
(,,)
<$> pure (Right (exam', course')) <*> view (_4 . _Value) <*> view (_5 . _Value)
(Nothing, Nothing, Just externalExam') -> do
guard =<< hasReadAccessTo (urlRoute $ externalExamLink (entityVal externalExam'))
(,,)
<$> pure (Left externalExam') <*> view (_4 . _Value) <*> view (_5 . _Value)
_other -> return $ error "Got exam & externalExam in same result"
colSynced = Colonnade.singleton (fromSortable . Sortable (Just "synced") $ i18nCell MsgExamSynchronised) $ \x -> flip runReader x $ do
Entity _ Exam{examClosed} <- view resultExam
mExam <- preview resultExam
if
| NTop examClosed > NTop (Just now)
| Just (Entity _ Exam{examClosed}) <- mExam
, NTop examClosed > NTop (Just now)
-> return . cell $ toWidget iconNew
| otherwise
-> do
@ -151,26 +191,28 @@ getEOExamsR = do
dbtColonnade :: Colonnade Sortable _ _
dbtColonnade = mconcat
[ colSynced
, anchorColonnade (views ($(multifocusG 2) (resultCourse . _entityVal) (resultExam . _entityVal)) (uncurry examLink))
$ colExamName (resultExam . _entityVal . _examName)
, colExamTime (resultExam . _entityVal . $(multifocusG 2) _examStart _examEnd)
, colExamFinishedOffice (resultExam . _entityVal . _examFinished)
, colExamClosed (resultExam . _entityVal . _examClosed)
, anchorColonnade (views (resultCourse . _entityVal) courseLink)
$ colCourseName (resultCourse . _entityVal . _courseName)
, colSchool (resultCourse . _entityVal . _courseSchool)
, colTermShort (resultCourse . _entityVal . _courseTerm)
, maybeAnchorColonnade ( runMaybeT $ mpreview ($(multifocusG 2) (pre $ resultCourse . _entityVal) (pre $ resultExam . _entityVal) . to (uncurry $ liftA2 examLink) . _Just)
<|> mpreviews (resultExternalExam . _entityVal) externalExamLink
)
$ emptyOpticColonnade (resultExam . _entityVal . _examName <> resultExternalExam . _entityVal . _externalExamExamName) colExamName
, emptyOpticColonnade (resultExam . _entityVal . $(multifocusG 2) _examStart _examEnd) colExamTime
, emptyOpticColonnade (resultExam . _entityVal . _examFinished) colExamFinishedOffice
, emptyOpticColonnade (resultExam . _entityVal . _examClosed) colExamClosed
, maybeAnchorColonnade (previews (resultCourse . _entityVal) courseLink)
$ emptyOpticColonnade (resultCourse . _entityVal . _courseName <> resultExternalExam . _entityVal . _externalExamCourseName) colCourseName
, emptyOpticColonnade (resultCourse . _entityVal . _courseSchool <> resultExternalExam . _entityVal . _externalExamSchool) colSchool
, emptyOpticColonnade (resultCourse . _entityVal . _courseTerm <> resultExternalExam . _entityVal . _externalExamTerm) colTermShort
]
dbtSorting = mconcat
[ singletonMap "synced" . SortColumn $ (E./.) <$> view querySynchronised' <*> view queryResults'
, singletonMap "is-synced" . SortColumn $ view queryIsSynced'
, sortExamName (queryExam . to (E.^. ExamName))
, sortExamTime (queryExam . $(multifocusG 2) (to (E.^. ExamStart)) (to (E.^. ExamEnd)))
, sortExamFinished (queryExam . to (E.^. ExamFinished))
, sortExamClosed (queryExam . to (E.^. ExamClosed))
, sortCourseName (queryCourse . to (E.^. CourseName))
, sortSchool (queryCourse . to (E.^. CourseSchool))
, sortTerm (queryCourse . to (E.^. CourseTerm))
, sortExamName (to $ E.unsafeCoalesce . sequence [views queryExam (E.?. ExamName), views queryExternalExam (E.?. ExternalExamExamName)])
, sortExamTime (queryExam . $(multifocusG 2) (to $ E.joinV . (E.?. ExamStart)) (to $ E.joinV . (E.?. ExamEnd)))
, sortExamFinished (queryExam . to (E.joinV . (E.?. ExamFinished)))
, sortExamClosed (queryExam . to (E.joinV . (E.?. ExamClosed)))
, sortCourseName (to $ E.unsafeCoalesce . sequence [views queryCourse (E.?. CourseName), views queryExternalExam (E.?. ExternalExamCourseName)])
, sortSchool (to $ E.unsafeCoalesce . sequence [views queryCourse (E.?. CourseSchool), views queryExternalExam (E.?. ExternalExamSchool)])
, sortTerm (to $ E.unsafeCoalesce . sequence [views queryCourse (E.?. CourseTerm), views queryExternalExam (E.?. ExternalExamTerm)])
]
dbtFilter = mconcat

View File

@ -0,0 +1,33 @@
module Handler.ExamOffice.ExternalExam
( getEEGradesR, postEEGradesR
) where
import Import
import Handler.Utils
import Handler.Utils.ExternalExam.Users
getEEGradesR, postEEGradesR :: TermId -> SchoolId -> CourseName -> ExamName -> Handler Html
getEEGradesR = postEEGradesR
postEEGradesR tid ssh coursen examn = do
(usersResult, table) <- runDB $ do
eExam <- getBy404 $ UniqueExternalExam tid ssh coursen examn
(usersResult, examUsersTable) <- makeExternalExamUsersTable EEUMGrades eExam
usersResult' <- formResultMaybe usersResult $ \case
(ExternalExamUserMarkSynchronisedData, selectedResults) -> do
forM_ selectedResults externalExamResultMarkSynchronised
return . Just $ do
addMessageI Success $ MsgExamUserMarkedSynchronised (length selectedResults)
redirect $ EExamR tid ssh coursen examn EEGradesR
return (usersResult', examUsersTable)
whenIsJust usersResult join
hasUsers <- hasReadAccessTo $ EExamR tid ssh coursen examn EEUsersR
siteLayoutMsg (MsgExternalExamGrades coursen examn) $ do
setTitleI MsgBreadcrumbExternalExamGrades
$(widgetFile "exam-office/externalExamGrades")

View File

@ -74,7 +74,7 @@ examOfficeUserInvitationConfig = InvitationConfig{..}
return InvitationTokenConfig{..}
invitationRestriction _ _ = return Authorized
invitationForm _ _ _ = pure (JunctionExamOfficeUser, ())
invitationInsertHook _ _ ExamOfficeUser{..} _ act = do
invitationInsertHook _ _ _ ExamOfficeUser{..} _ act = do
res <- act
audit $ TransactionExamOfficeUserAdd examOfficeUserOffice examOfficeUserUser
return res

View File

@ -0,0 +1,10 @@
module Handler.ExternalExam
( module Handler.ExternalExam
) where
import Handler.ExternalExam.List as Handler.ExternalExam
import Handler.ExternalExam.New as Handler.ExternalExam
import Handler.ExternalExam.Show as Handler.ExternalExam
import Handler.ExternalExam.Edit as Handler.ExternalExam
import Handler.ExternalExam.Users as Handler.ExternalExam
import Handler.ExternalExam.StaffInvite as Handler.ExternalExam

View File

@ -0,0 +1,98 @@
module Handler.ExternalExam.Edit
( getEEEditR, postEEEditR
) where
import Import
import Handler.Utils
import Handler.Utils.Invitations
import Handler.ExternalExam.Form
import Handler.ExternalExam.StaffInvite
import qualified Data.Set as Set
import qualified Data.Map as Map
import Jobs.Queue
getEEEditR, postEEEditR :: TermId -> SchoolId -> CourseName -> ExamName -> Handler Html
getEEEditR = postEEEditR
postEEEditR tid ssh coursen examn = do
(Entity eeId ExternalExam{..}, schools, staff) <- runDB $ do
eExam@(Entity eeId _) <- getBy404 $ UniqueExternalExam tid ssh coursen examn
schools <- setOf (folded . _entityVal . _externalExamOfficeSchoolSchool) <$> selectList [ ExternalExamOfficeSchoolExam ==. eeId ] []
actualStaff <- selectList [ ExternalExamStaffExam ==. eeId ] []
invitedStaff <- sourceInvitationsF @ExternalExamStaff eeId
let staff = setOf (folded . _entityVal . _externalExamStaffUser . re _Right) actualStaff
<> Set.mapMonotonic Left (Map.keysSet invitedStaff)
return (eExam, schools, staff)
let
template = ExternalExamForm
{ eefTerm = tid
, eefSchool = ssh
, eefCourseName = coursen
, eefExamName = examn
, eefDefaultTime = externalExamDefaultTime
, eefShowGrades = externalExamShowGrades
, eefOfficeSchools = schools
, eefStaff = staff
}
((examResult, examWidget'), examEnctype) <- runFormPost . externalExamForm $ Just template
formResult examResult $ \ExternalExamForm{..} -> do
replaceRes <- runDBJobs $ do
replaceRes <- replaceUnique eeId ExternalExam
{ externalExamTerm = eefTerm
, externalExamSchool = eefSchool
, externalExamCourseName = eefCourseName
, externalExamExamName = eefExamName
, externalExamDefaultTime = eefDefaultTime
, externalExamShowGrades = eefShowGrades
}
when (is _Nothing replaceRes) $ do
audit $ TransactionExternalExamEdit eeId
forM_ (eefStaff `setSymmDiff` staff) $ \change -> if
| change `Set.member` eefStaff -> case change of
Left invEmail -> do
audit $ TransactionExternalExamStaffInviteEdit eeId invEmail
sinkInvitationsF externalExamStaffInvitationConfig
[(invEmail, eeId, (InvDBDataExternalExamStaff, InvTokenDataExternalExamStaff))]
Right staffUid -> do
audit $ TransactionExternalExamStaffEdit eeId staffUid
insert_ $ ExternalExamStaff staffUid eeId
| otherwise -> case change of
Left invEmail -> do
audit $ TransactionExternalExamStaffInviteDelete eeId invEmail
deleteInvitation @ExternalExamStaff eeId invEmail
Right staffUid -> do
audit $ TransactionExternalExamStaffDelete eeId staffUid
deleteBy $ UniqueExternalExamStaff eeId staffUid
forM_ (eefOfficeSchools `setSymmDiff` schools) $ \change -> if
| change `Set.member` eefOfficeSchools -> do
audit $ TransactionExternalExamOfficeSchoolEdit eeId change
insert_ $ ExternalExamOfficeSchool change eeId
| otherwise -> do
audit $ TransactionExternalExamOfficeSchoolDelete eeId change
deleteBy $ UniqueExternalExamOfficeSchool eeId change
return replaceRes
case replaceRes of
Nothing -> do
addMessageI Success $ MsgExternalExamEdited eefCourseName eefExamName
redirect $ EExamR eefTerm eefSchool eefCourseName eefExamName EEShowR
Just _ ->
addMessageI Error $ MsgExternalExamExists eefCourseName eefExamName
let heading = MsgExternalExamEdit coursen examn
siteLayoutMsg heading $ do
setTitleI heading
wrapForm examWidget' def
{ formAction = Just . SomeRoute $ EExamR tid ssh coursen examn EEEditR
, formEncoding = examEnctype
}

View File

@ -0,0 +1,115 @@
module Handler.ExternalExam.Form
( ExternalExamForm(..)
, externalExamForm
) where
import Import
import Handler.Utils
import Handler.ExternalExam.StaffInvite ()
import qualified Data.Set as Set
import Data.Map ((!))
import qualified Control.Monad.State.Class as State
data ExternalExamForm = ExternalExamForm
{ eefTerm :: TermId
, eefSchool :: SchoolId
, eefCourseName :: CI Text
, eefExamName :: CI Text
, eefDefaultTime :: Maybe UTCTime
, eefShowGrades :: Bool
, eefOfficeSchools :: Set SchoolId
, eefStaff :: Set (Either UserEmail UserId)
}
makeLenses_ ''ExternalExamForm
externalExamForm :: Maybe ExternalExamForm -> Form ExternalExamForm
externalExamForm template = validateForm validateExternalExam $ \html -> do
uid <- requireAuthId
cRoute <- fromMaybe (error "tutorialForm called from 404-Handler") <$> getCurrentRoute
MsgRenderer mr <- getMsgRenderer
let termsField = case template of
Just template' -> termsSetField [eefTerm template']
_other -> termsAllowedField
(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
let oldSchool = eefSchool <$> template
return (lecturerSchools, adminSchools, oldSchool)
let userSchools = nub . maybe id (:) oldSchool $ lecturerSchools ++ adminSchools
flip (renderAForm FormStandard) html $ ExternalExamForm
<$> areq termsField (fslI MsgExternalExamSemester) (eefTerm <$> template)
<*> areq (schoolFieldFor userSchools) (fslI MsgExternalExamSchool) (eefSchool <$> template)
<*> areq (textField & cfStrip & cfCI) (fslI MsgExternalExamCourseName & setTooltip MsgExternalExamCourseNameTip & addPlaceholder (mr MsgExternalExamCourseNamePlaceholder)) (eefCourseName <$> template)
<*> areq (textField & cfStrip & cfCI) (fslI MsgExternalExamExamName & setTooltip MsgExternalExamExamNameTip & addPlaceholder (mr MsgExternalExamExamNamePlaceholder)) (eefExamName <$> template)
<*> aopt utcTimeField (fslI MsgExternalExamDefaultTime & setTooltip MsgExternalExamDefaultTimeTip & addPlaceholder (mr MsgExternalExamDefaultTimePlaceholder)) (eefDefaultTime <$> template)
<*> apopt checkBoxField (fslI MsgExternalExamShowGrades & setTooltip MsgExternalExamShowGradesTip) (eefShowGrades <$> template)
<*> (Set.fromList <$> officeSchoolForm cRoute (Set.toList . eefOfficeSchools <$> template))
<*> (Set.fromList <$> staffForm cRoute ((Set.toList . eefStaff <$> template) <|> pure (pure $ Right uid)))
where
officeSchoolForm cRoute = massInputAccumA miAdd miCell miButtonAction miLayout miIdent fSettings fRequired
where
miAdd mkUnique submitView csrf = do
(schoolRes, addView) <- mpopt schoolField ("" & addName (mkUnique "school")) Nothing
let schoolRes' = schoolRes <&> \newDat oldDat -> FormSuccess (guardOn (newDat `notElem` oldDat) newDat)
return (schoolRes', $(widgetFile "external-exam/schoolMassInput/add"))
miCell ssh = do
School{..} <- liftHandler . runDB $ getJust ssh
$(widgetFile "external-exam/schoolMassInput/cell")
miButtonAction :: forall p. PathPiece p => p -> Maybe (SomeRoute UniWorX)
miButtonAction = Just . SomeRoute . (cRoute :#:)
miLayout :: MassInputLayout ListLength SchoolId ()
miLayout lLength _ cellWdgts delButtons addWdgts = $(widgetFile "external-exam/schoolMassInput/layout")
miIdent :: Text
miIdent = "external-exams-school"
fSettings = fslI MsgExternalExamExamOfficeSchools & setTooltip (UniWorXMessages [SomeMessage MsgExternalExamExamOfficeSchoolsTip, SomeMessage MsgMassInputTip])
fRequired = False
staffForm cRoute = massInputAccumA miAdd miCell miButtonAction miLayout miIdent fSettings fRequired
where
miAdd mkUnique submitView csrf = do
MsgRenderer mr <- getMsgRenderer
(usersRes, addView) <- mpreq (multiUserField False Nothing) ("" & addName (mkUnique "email")) Nothing
let
usersRes' = usersRes <&> \newDat oldDat -> if
| existing <- newDat `Set.intersection` Set.fromList oldDat
, not $ Set.null existing
-> FormFailure [mr MsgExternalExamStaffAlreadyAdded]
| otherwise
-> FormSuccess $ Set.toList newDat
return (usersRes', $(widgetFile "external-exam/staffMassInput/add"))
miCell (Left email) = do
invWarnMsg <- messageI Warning MsgEmailInvitationWarning
$(widgetFile "external-exam/staffMassInput/cellInvitation")
miCell (Right userId) = do
User{..} <- liftHandler . runDB $ getJust userId
$(widgetFile "external-exam/staffMassInput/cellKnown")
miButtonAction :: forall p. PathPiece p => p -> Maybe (SomeRoute UniWorX)
miButtonAction = Just . SomeRoute . (cRoute :#:)
miLayout :: MassInputLayout ListLength (Either UserEmail UserId) ()
miLayout lLength _ cellWdgts delButtons addWdgts = $(widgetFile "external-exam/staffMassInput/layout")
miIdent :: Text
miIdent = "external-exams-staff"
fSettings = fslI MsgExternalExamStaff & setTooltip (UniWorXMessages [SomeMessage MsgExternalExamStaffTip, SomeMessage MsgMassInputTip])
fRequired = True
validateExternalExam :: (MonadThrow m, MonadHandler m, HandlerSite m ~ UniWorX) => FormValidator ExternalExamForm m ()
validateExternalExam = do
State.modify $ \eeForm -> eeForm & over _eefOfficeSchools (Set.delete $ eeForm ^. _eefSchool)
ExternalExamForm{..} <- State.get
isAdmin <- hasWriteAccessTo $ SchoolR eefSchool SchoolEditR
unless isAdmin $ do
uid <- requireAuthId
guardValidation MsgExternalExamUserMustBeStaff $ Right uid `Set.member` eefStaff
courseExists <- liftHandler . runDB . existsBy $ TermSchoolCourseName eefTerm eefSchool eefCourseName
guardValidation MsgExternalExamCourseExists $ not courseExists

View File

@ -0,0 +1,81 @@
module Handler.ExternalExam.List
( getEExamListR
) where
import Import
import Handler.Utils
import qualified Database.Esqueleto as E
import qualified Database.Esqueleto.Utils as E
import qualified Data.Map as Map
getEExamListR :: Handler Html
getEExamListR = do
mAuthId <- maybeAuthId
let
examDBTable = DBTable{..}
where
resultEExam = _dbrOutput . _1
resultSchool = _dbrOutput . _2
queryEExam = $(E.sqlIJproj 2 1)
querySchool = $(E.sqlIJproj 2 2)
dbtSQLQuery (eexam `E.InnerJoin` school) = do
E.on $ eexam E.^. ExternalExamSchool E.==. school E.^. SchoolId
let
isStaff
| Just authId <- mAuthId
= E.exists . E.from $ \eexamStaff ->
E.where_ $ eexamStaff E.^. ExternalExamStaffExam E.==. eexam E.^. ExternalExamId
E.&&. eexamStaff E.^. ExternalExamStaffUser E.==. E.val authId
| otherwise
= E.false
isStudent
| Just authId <- mAuthId
= E.exists . E.from $ \eexamResult ->
E.where_ $ eexamResult E.^. ExternalExamResultExam E.==. eexam E.^. ExternalExamId
E.&&. eexamResult E.^. ExternalExamResultUser E.==. E.val authId
| otherwise
= E.false
E.where_ $ isStaff E.||. isStudent
return (eexam, school)
dbtRowKey = queryEExam >>> (E.^. ExternalExamId)
dbtProj x@(view resultEExam -> Entity _ ExternalExam{..}) = do
guardM . hasReadAccessTo $ EExamR externalExamTerm externalExamSchool externalExamCourseName externalExamExamName EEShowR
return x
dbtColonnade = widgetColonnade $ mconcat
[ sortable (Just "term") (i18nCell MsgTerm) $ \(view resultEExam -> Entity _ ExternalExam{..}) -> i18nCell . ShortTermIdentifier $ unTermKey externalExamTerm
, sortable (Just "school") (i18nCell MsgSchool) $ \(view resultSchool -> Entity _ School{..}) -> i18nCell schoolName
, sortable (Just "course") (i18nCell MsgCourse) $ \(view resultEExam -> Entity _ ExternalExam{..}) -> i18nCell externalExamCourseName
, sortable (Just "name") (i18nCell MsgExamName) $ \(view resultEExam -> Entity _ ExternalExam{..}) -> anchorCell (EExamR externalExamTerm externalExamSchool externalExamCourseName externalExamExamName EEShowR) externalExamExamName
]
dbtSorting = Map.fromList
[ ("term", SortColumn $ queryEExam >>> (E.^. ExternalExamTerm))
, ("school", SortColumn $ querySchool >>> (E.^. SchoolName))
, ("course", SortColumn $ queryEExam >>> (E.^. ExternalExamCourseName))
, ("name", SortColumn $ queryEExam >>> (E.^. ExternalExamExamName))
]
dbtFilter = Map.empty
dbtFilterUI = const mempty
dbtStyle = def
dbtParams = def
dbtIdent :: Text
dbtIdent = "external-exams"
dbtCsvEncode = noCsvEncode
dbtCsvDecode = Nothing
examDBTableValidator = def
& defaultSorting [SortDescBy "term", SortAscBy "school", SortAscBy "course", SortAscBy "name"]
examTable <- runDB $ dbTableWidget' examDBTableValidator examDBTable
let heading = MsgMenuExternalExamList
siteLayoutMsg heading $ do
setTitleI heading
examTable

View File

@ -0,0 +1,72 @@
module Handler.ExternalExam.New
( getEExamNewR, postEExamNewR
) where
import Import
import Jobs.Queue
import Handler.Utils
import Handler.Utils.Invitations
import Handler.ExternalExam.StaffInvite
import Handler.ExternalExam.Form
import qualified Data.Set as Set
getEExamNewR, postEExamNewR :: Handler Html
getEExamNewR = postEExamNewR
postEExamNewR = do
((newExamResult, newExamWidget'), newExamEnctype) <- runFormPost $ externalExamForm Nothing
formResult newExamResult $ \ExternalExamForm{..} -> do
insertRes <- runDBJobs $ do
insertRes <- insertUnique ExternalExam
{ externalExamTerm = eefTerm
, externalExamSchool = eefSchool
, externalExamCourseName = eefCourseName
, externalExamExamName = eefExamName
, externalExamDefaultTime = eefDefaultTime
, externalExamShowGrades = eefShowGrades
}
whenIsJust insertRes $ \eeId -> do
audit $ TransactionExternalExamEdit eeId
let eefOfficeSchools' = do
externalExamOfficeSchoolSchool <- Set.toList eefOfficeSchools
guard $ externalExamOfficeSchoolSchool /= eefSchool
let externalExamOfficeSchoolExam = eeId
return ExternalExamOfficeSchool{..}
insertMany_ eefOfficeSchools'
forM_ eefOfficeSchools' $ \ExternalExamOfficeSchool{..} ->
audit $ TransactionExternalExamOfficeSchoolEdit eeId externalExamOfficeSchoolSchool
let (invites, adds) = partitionEithers $ Set.toList eefStaff
eefStaff' = do
externalExamStaffUser <- adds
let externalExamStaffExam = eeId
return ExternalExamStaff{..}
insertMany_ eefStaff'
forM_ eefStaff' $ \ExternalExamStaff{..} ->
audit $ TransactionExternalExamStaffEdit eeId externalExamStaffUser
sinkInvitationsF externalExamStaffInvitationConfig $ map (, eeId, (InvDBDataExternalExamStaff, InvTokenDataExternalExamStaff)) invites
forM_ invites $ \invEmail ->
audit $ TransactionExternalExamStaffInviteEdit eeId invEmail
return insertRes
case insertRes of
Nothing -> addMessageI Error $ MsgExternalExamExists eefCourseName eefExamName
Just _ -> do
addMessageI Success $ MsgExternalExamCreated eefCourseName eefExamName
redirect $ EExamR eefTerm eefSchool eefCourseName eefExamName EEShowR
let heading = MsgMenuExternalExamNew
siteLayoutMsg heading $ do
setTitleI heading
wrapForm newExamWidget' def
{ formAction = Just $ SomeRoute EExamNewR
, formEncoding = newExamEnctype
}

View File

@ -0,0 +1,49 @@
module Handler.ExternalExam.Show
( getEEShowR
) where
import Import
import Handler.Utils
import Handler.Utils.Invitations
import Handler.ExternalExam.StaffInvite ()
import qualified Data.CaseInsensitive as CI
import qualified Data.Map as Map
import qualified Database.Esqueleto as E
getEEShowR :: TermId -> SchoolId -> CourseName -> ExamName -> Handler Html
getEEShowR tid ssh coursen examn = do
mUid <- maybeAuthId
(Entity _ ExternalExam{..}, fmap entityVal -> mResult, School{..}, staff, addSchools) <- runDB $ do
exam@(Entity eeId ExternalExam{..}) <- getBy404 $ UniqueExternalExam tid ssh coursen examn
actualStaff <- E.select . E.from $ \(eeStaff `E.InnerJoin` user) -> do
E.on $ eeStaff E.^. ExternalExamStaffUser E.==. user E.^. UserId
E.where_ $ eeStaff E.^. ExternalExamStaffExam E.==. E.val eeId
E.orderBy [E.asc $ user E.^. UserDisplayName]
return user
maySeeInvites <- hasReadAccessTo $ EExamR tid ssh coursen examn EEGradesR
staffInvites <- if
| maySeeInvites -> sourceInvitationsF @ExternalExamStaff eeId
| otherwise -> return Map.empty
let staff = map Right actualStaff ++ map Left (Map.keys staffInvites)
addSchools <- E.select . E.from $ \(eeSchool `E.InnerJoin` school) -> do
E.on $ eeSchool E.^. ExternalExamOfficeSchoolSchool E.==. school E.^. SchoolId
E.where_ $ eeSchool E.^. ExternalExamOfficeSchoolExam E.==. E.val eeId
E.orderBy [E.asc $ school E.^. SchoolName]
return school
school <- getJust externalExamSchool
mResult <- fmap join . for mUid $ getBy . UniqueExternalExamResult eeId
return (exam, mResult, school, staff, addSchools)
let heading = CI.original examn
siteLayoutMsg heading $ do
setTitleI heading
$(widgetFile "external-exam-show")

View File

@ -0,0 +1,79 @@
{-# OPTIONS_GHC -fno-warn-orphans #-}
module Handler.ExternalExam.StaffInvite
( externalExamStaffInvitationConfig
, getEEStaffInviteR, postEEStaffInviteR
, InvitableJunction(..), InvitationDBData(..), InvitationTokenData(..)
) where
import Import
import Handler.Utils.Invitations
import Text.Hamlet (ihamlet)
import Data.Aeson hiding (Result(..))
instance IsInvitableJunction ExternalExamStaff where
type InvitationFor ExternalExamStaff = ExternalExam
data InvitableJunction ExternalExamStaff = JunctionExternalExamStaff
deriving (Eq, Ord, Read, Show, Generic, Typeable)
data InvitationDBData ExternalExamStaff = InvDBDataExternalExamStaff
deriving (Eq, Ord, Read, Show, Generic, Typeable)
data InvitationTokenData ExternalExamStaff = InvTokenDataExternalExamStaff
deriving (Eq, Ord, Read, Show, Generic, Typeable)
_InvitableJunction = iso
(\ExternalExamStaff{..} -> (externalExamStaffUser, externalExamStaffExam, JunctionExternalExamStaff))
(\(externalExamStaffUser, externalExamStaffExam, JunctionExternalExamStaff{}) -> ExternalExamStaff{..})
instance ToJSON (InvitableJunction ExternalExamStaff) where
toJSON = genericToJSON defaultOptions { constructorTagModifier = camelToPathPiece' 1 }
toEncoding = genericToEncoding defaultOptions { constructorTagModifier = camelToPathPiece' 1 }
instance FromJSON (InvitableJunction ExternalExamStaff) where
parseJSON = genericParseJSON defaultOptions { constructorTagModifier = camelToPathPiece' 1 }
instance ToJSON (InvitationDBData ExternalExamStaff) where
toJSON = genericToJSON defaultOptions { constructorTagModifier = camelToPathPiece' 3 }
toEncoding = genericToEncoding defaultOptions { constructorTagModifier = camelToPathPiece' 3 }
instance FromJSON (InvitationDBData ExternalExamStaff) where
parseJSON = genericParseJSON defaultOptions { constructorTagModifier = camelToPathPiece' 3 }
instance ToJSON (InvitationTokenData ExternalExamStaff) where
toJSON = genericToJSON defaultOptions { constructorTagModifier = camelToPathPiece' 3 }
toEncoding = genericToEncoding defaultOptions { constructorTagModifier = camelToPathPiece' 3 }
instance FromJSON (InvitationTokenData ExternalExamStaff) where
parseJSON = genericParseJSON defaultOptions { constructorTagModifier = camelToPathPiece' 3 }
externalExamStaffInvitationConfig :: InvitationConfig ExternalExamStaff
externalExamStaffInvitationConfig = InvitationConfig{..}
where
invitationRoute (Entity _ ExternalExam{..}) _ = return $ EExamR externalExamTerm externalExamSchool externalExamCourseName externalExamExamName EEStaffInviteR
invitationResolveFor _ = do
cRoute <- getCurrentRoute
case cRoute of
Just (EExamR tid ssh coursen examn EEStaffInviteR) ->
getKeyBy404 $ UniqueExternalExam tid ssh coursen examn
_other -> error "externalExamStaffInvitationConfig called from unsupported route"
invitationSubject (Entity _ ExternalExam{..}) _ = return . SomeMessage $ MsgMailSubjectExternalExamStaffInvitation externalExamCourseName externalExamExamName
invitationHeading (Entity _ ExternalExam{..}) _ = return . SomeMessage $ MsgExternalExamStaffInviteHeading externalExamCourseName externalExamExamName
invitationExplanation _ _ = return [ihamlet|_{SomeMessage MsgExternalExamStaffInviteExplanation}|]
invitationTokenConfig _ _ = do
itAuthority <- Right <$> liftHandler requireAuthId
return $ InvitationTokenConfig itAuthority Nothing (Just Nothing) Nothing
invitationRestriction _ _ = return Authorized
invitationForm _ (InvDBDataExternalExamStaff, _) _ = pure (JunctionExternalExamStaff, ())
invitationInsertHook invEmail _ _ ExternalExamStaff{..} _ act = do
res <- act
audit $ TransactionExternalExamStaffInviteDelete externalExamStaffExam invEmail
audit $ TransactionExternalExamStaffEdit externalExamStaffExam externalExamStaffUser
return res
invitationSuccessMsg (Entity _ ExternalExam{..}) (Entity _ ExternalExamStaff{})
= return . SomeMessage $ MsgExternalExamStaffInvitationAccepted externalExamCourseName externalExamExamName
invitationUltDest (Entity _ ExternalExam{..}) _ = return . SomeRoute $ EExamR externalExamTerm externalExamSchool externalExamCourseName externalExamExamName EEShowR
getEEStaffInviteR, postEEStaffInviteR :: TermId -> SchoolId -> CourseName -> ExamName -> Handler Html
getEEStaffInviteR = postEEStaffInviteR
postEEStaffInviteR = invitationR externalExamStaffInvitationConfig

View File

@ -0,0 +1,18 @@
module Handler.ExternalExam.Users
( getEEUsersR, postEEUsersR
) where
import Import
import Handler.Utils.ExternalExam.Users
getEEUsersR, postEEUsersR :: TermId -> SchoolId -> CourseName -> ExamName -> Handler Html
getEEUsersR = postEEUsersR
postEEUsersR tid ssh coursen examn = do
(_, table) <- runDB $ do
eExam <- getBy404 $ UniqueExternalExam tid ssh coursen examn
makeExternalExamUsersTable EEUMUsers eExam
siteLayoutMsg (MsgExternalExamUsers coursen examn) $ do
setTitleI MsgBreadcrumbExternalExamUsers
table

View File

@ -0,0 +1,79 @@
{-# OPTIONS_GHC -fno-warn-redundant-constraints #-}
module Handler.Participants
( getParticipantsListR
, getParticipantsR
) where
import Import
import qualified Database.Esqueleto as E
import qualified Database.Esqueleto.Utils as E
import qualified Data.Set as Set
import Handler.Utils.Csv
import Handler.Utils.ContentDisposition
import qualified Data.Csv as Csv
import qualified Data.Conduit.List as C
data ParticipantEntry = ParticipantEntry
{ peCourse :: CourseName
, peEmail :: UserEmail
} deriving (Eq, Ord, Read, Show, Generic, Typeable)
instance ToNamedRecord ParticipantEntry where
toNamedRecord ParticipantEntry{..} = Csv.namedRecord
[ "course" Csv..= peCourse
, "email" Csv..= peEmail
]
instance DefaultOrdered ParticipantEntry where
headerOrder _ = Csv.header ["course", "email"]
getParticipantsListR :: Handler Html
getParticipantsListR = do
schoolTerms'' <- runDB . E.select . E.from $ \(school `E.InnerJoin` term) -> do
E.on E.true
E.where_ . E.exists . E.from $ \(course `E.InnerJoin` participant) -> do
E.on $ course E.^. CourseId E.==. participant E.^. CourseParticipantCourse
E.where_ $ course E.^. CourseTerm E.==. term E.^. TermId
E.&&. course E.^. CourseSchool E.==. school E.^. SchoolId
return (school E.^. SchoolId, term E.^. TermId)
schoolTerms' <- flip filterM schoolTerms'' $ \(E.Value ssh, E.Value tid) ->
hasReadAccessTo $ ParticipantsR tid ssh
let schoolTerms :: Set (SchoolId, TermId)
schoolTerms = setOf (folded . $(multifocusG 2) (_1 . _Value) (_2 . _Value)) schoolTerms'
siteLayoutMsg MsgMenuParticipantsList $ do
setTitleI MsgMenuParticipantsList
let schools :: Set SchoolId
schools = Set.map (view _1) schoolTerms
terms :: Set TermId
terms = Set.map (view _2) schoolTerms
$(widgetFile "participants-list")
getParticipantsR :: TermId -> SchoolId -> Handler TypedContent
getParticipantsR tid ssh = do
csvName <- timestampCsv <*> fmap ((flip addExtension `on` unpack) extensionCsv) (getMessageRender <*> pure (MsgParticipantsCsvName tid ssh))
setContentDisposition' $ Just csvName
respondDefaultOrderedCsvDB $ E.selectSource partQuery .| C.map toParticipantEntry
where
partQuery = E.from $ \(course `E.InnerJoin` participant `E.InnerJoin` user) -> do
E.on $ user E.^. UserId E.==. participant E.^. CourseParticipantUser
E.on $ course E.^. CourseId E.==. participant E.^. CourseParticipantCourse
E.where_ $ course E.^. CourseTerm E.==. E.val tid
E.&&. course E.^. CourseSchool E.==. E.val ssh
return (course E.^. CourseName, user E.^. UserEmail)
toParticipantEntry (E.Value peCourse, E.Value peEmail) = ParticipantEntry{..}

View File

@ -892,7 +892,7 @@ correctorInvitationConfig = InvitationConfig{..}
return $ InvitationTokenConfig itAuthority Nothing Nothing Nothing
invitationRestriction _ _ = return Authorized
invitationForm _ (InvDBDataSheetCorrector cLoad cState, _) _ = pure $ (JunctionSheetCorrector cLoad cState, ())
invitationInsertHook _ _ _ _ = id
invitationInsertHook _ _ _ _ _ = id
invitationSuccessMsg (Entity _ Sheet{..}) _ = return . SomeMessage $ MsgCorrectorInvitationAccepted sheetName
invitationUltDest (Entity _ Sheet{..}) _ = do
Course{..} <- get404 sheetCourse

View File

@ -114,7 +114,7 @@ submissionUserInvitationConfig = InvitationConfig{..}
return InvitationTokenConfig{..}
invitationRestriction _ _ = return Authorized
invitationForm _ _ _ = pure (JunctionSubmissionUser, ())
invitationInsertHook _ _ _ _ = id
invitationInsertHook _ _ _ _ _ = id
invitationSuccessMsg (Entity _ Submission{..}) _ = do
Sheet{..} <- getJust submissionSheet
return . SomeMessage $ MsgSubmissionUserInvitationAccepted sheetName

View File

@ -68,7 +68,7 @@ tutorInvitationConfig = InvitationConfig{..}
return $ InvitationTokenConfig itAuthority Nothing Nothing Nothing
invitationRestriction _ _ = return Authorized
invitationForm _ _ _ = pure (JunctionTutor, ())
invitationInsertHook _ _ _ _ = id
invitationInsertHook _ _ _ _ _ = id
invitationSuccessMsg (Entity _ Tutorial{..}) _ = return . SomeMessage $ MsgCorrectorInvitationAccepted tutorialName
invitationUltDest (Entity _ Tutorial{..}) _ = do
Course{..} <- get404 tutorialCourse

View File

@ -579,7 +579,7 @@ functionInvitationConfig = InvitationConfig{..}
return InvitationTokenConfig{..}
invitationRestriction _ _ = return Authorized
invitationForm _ (_, InvTokenDataUserFunction{..}) _ = pure $ (JunctionUserFunction invTokenUserFunctionFunction, ())
invitationInsertHook _ _ _ _ = id
invitationInsertHook _ _ _ _ _ = id
invitationSuccessMsg (Entity _ School{..}) (Entity _ UserFunction{..}) = do
MsgRenderer mr <- getMsgRenderer
return . SomeMessage . MsgSchoolFunctionInvitationAccepted schoolName $ mr userFunctionFunction

View File

@ -0,0 +1,61 @@
module Handler.Utils.ExamOffice.ExternalExam
( resultIsSynced
, examOfficeExternalExamResultAuth
) where
import Import.NoFoundation
import qualified Database.Esqueleto as E
resultIsSynced :: E.SqlExpr (E.Value UserId) -- ^ office
-> E.SqlExpr (Entity ExternalExamResult)
-> E.SqlExpr (E.Value Bool)
resultIsSynced authId eexamResult = (hasSchool E.&&. allSchools) E.||. (E.not_ hasSchool E.&&. anySync)
where
anySync = E.exists . E.from $ \synced ->
E.where_ $ synced E.^. ExamOfficeExternalResultSyncedResult E.==. eexamResult E.^. ExternalExamResultId
E.&&. synced E.^. ExamOfficeExternalResultSyncedTime E.>=. eexamResult E.^. ExternalExamResultLastChanged
hasSchool = E.exists . E.from $ \userFunction ->
E.where_ $ userFunction E.^. UserFunctionUser E.==. authId
E.&&. userFunction E.^. UserFunctionFunction E.==. E.val SchoolExamOffice
allSchools = E.not_ . E.exists . E.from $ \userFunction -> do
E.where_ $ userFunction E.^. UserFunctionUser E.==. authId
E.&&. userFunction E.^. UserFunctionFunction E.==. E.val SchoolExamOffice
E.where_ . E.not_ . E.exists . E.from $ \synced ->
E.where_ $ synced E.^. ExamOfficeExternalResultSyncedSchool E.==. E.just (userFunction E.^. UserFunctionSchool)
E.&&. synced E.^. ExamOfficeExternalResultSyncedResult E.==. eexamResult E.^. ExternalExamResultId
E.&&. synced E.^. ExamOfficeExternalResultSyncedTime E.>=. eexamResult E.^. ExternalExamResultLastChanged
examOfficeExternalExamResultAuth :: E.SqlExpr (E.Value UserId) -- ^ office
-> E.SqlExpr (Entity ExternalExamResult)
-> E.SqlExpr (E.Value Bool)
examOfficeExternalExamResultAuth authId eexamResult = authByUser E.||. authByField E.||. authBySchool E.||. authByExtraSchool
where
authByField = E.exists . E.from $ \(examOfficeField `E.InnerJoin` studyFeatures) -> do
E.on $ studyFeatures E.^. StudyFeaturesField E.==. examOfficeField E.^. ExamOfficeFieldField
E.where_ $ studyFeatures E.^. StudyFeaturesUser E.==. eexamResult E.^. ExternalExamResultUser
E.&&. examOfficeField E.^. ExamOfficeFieldOffice E.==. authId
E.&&. examOfficeField E.^. ExamOfficeFieldField E.==. studyFeatures E.^. StudyFeaturesField
E.where_ $ examOfficeField E.^. ExamOfficeFieldForced
E.||. E.exists (E.from $ \userFunction ->
E.where_ $ userFunction E.^. UserFunctionUser E.==. authId
E.&&. userFunction E.^. UserFunctionFunction E.==. E.val SchoolExamOffice
)
authByUser = E.exists . E.from $ \examOfficeUser ->
E.where_ $ examOfficeUser E.^. ExamOfficeUserOffice E.==. authId
E.&&. examOfficeUser E.^. ExamOfficeUserUser E.==. eexamResult E.^. ExternalExamResultUser
authBySchool = E.exists . E.from $ \(userFunction `E.InnerJoin` eexam) -> do
E.on $ userFunction E.^. UserFunctionFunction E.==. E.val SchoolExamOffice
E.&&. userFunction E.^. UserFunctionSchool E.==. eexam E.^. ExternalExamSchool
E.where_ $ eexam E.^. ExternalExamId E.==. eexamResult E.^. ExternalExamResultExam
E.where_ $ userFunction E.^. UserFunctionUser E.==. authId
authByExtraSchool = E.exists . E.from $ \(userFunction `E.InnerJoin` eexamSchool) -> do
E.on $ userFunction E.^. UserFunctionFunction E.==. E.val SchoolExamOffice
E.&&. userFunction E.^. UserFunctionSchool E.==. eexamSchool E.^. ExternalExamOfficeSchoolSchool
E.where_ $ eexamSchool E.^. ExternalExamOfficeSchoolExam E.==. eexamResult E.^. ExternalExamResultExam
E.where_ $ userFunction E.^. UserFunctionUser E.==. authId

View File

@ -0,0 +1,476 @@
{-# OPTIONS_GHC -fno-warn-redundant-constraints #-}
module Handler.Utils.ExternalExam.Users where
import Import hiding ((.:))
import Handler.Utils
import Handler.Utils.Csv
import Handler.Utils.Users
import qualified Data.Map as Map
import qualified Data.Set as Set
import qualified Colonnade
import qualified Database.Esqueleto as E
import qualified Database.Esqueleto.Utils as E
import Data.Csv ((.:))
import qualified Data.Csv as Csv
import qualified Handler.Utils.ExamOffice.ExternalExam as ExternalExam
import qualified Data.Text as Text
import qualified Data.Text.Lens as Text
import qualified Data.Conduit.List as C
data ExternalExamUserMode = EEUMUsers | EEUMGrades
deriving (Eq, Ord, Read, Show, Bounded, Enum, Generic, Typeable)
instance Universe ExternalExamUserMode
instance Finite ExternalExamUserMode
nullaryPathPiece ''ExternalExamUserMode $ camelToPathPiece' 1
type ExternalExamUserTableExpr = E.SqlExpr (Entity ExternalExamResult)
`E.InnerJoin` E.SqlExpr (Entity User)
type ExternalExamUserTableData = DBRow ( Entity ExternalExamResult
, Entity User
, Bool
, [(UserDisplayName, UserSurname, UTCTime, Set SchoolShorthand)]
)
queryUser :: Getter ExternalExamUserTableExpr (E.SqlExpr (Entity User))
queryUser = to $(E.sqlIJproj 2 2)
queryResult :: Getter ExternalExamUserTableExpr (E.SqlExpr (Entity ExternalExamResult))
queryResult = to $(E.sqlIJproj 2 1)
queryIsSynced :: E.SqlExpr (E.Value UserId) -> Getter ExternalExamUserTableExpr (E.SqlExpr (E.Value Bool))
queryIsSynced authId = to $ ExternalExam.resultIsSynced authId <$> view queryResult
resultUser :: Lens' ExternalExamUserTableData (Entity User)
resultUser = _dbrOutput . _2
resultResult :: Lens' ExternalExamUserTableData (Entity ExternalExamResult)
resultResult = _dbrOutput . _1
resultIsSynced :: Lens' ExternalExamUserTableData Bool
resultIsSynced = _dbrOutput . _3
resultSynchronised :: Traversal' ExternalExamUserTableData (UserDisplayName, UserSurname, UTCTime, Set SchoolShorthand)
resultSynchronised = _dbrOutput . _4 . traverse
data ExternalExamUserTableCsv = ExternalExamUserTableCsv
{ csvEUserSurname :: Maybe Text
, csvEUserFirstName :: Maybe Text
, csvEUserName :: Maybe Text
, csvEUserMatriculation :: Maybe Text
, csvEUserOccurrenceStart :: ZonedTime
, csvEUserExamResult :: ExamResultPassedGrade
} deriving (Generic)
makeLenses_ ''ExternalExamUserTableCsv
externalExamUserTableCsvOptions :: Csv.Options
externalExamUserTableCsvOptions = Csv.defaultOptions { Csv.fieldLabelModifier = camelToPathPiece' 3 }
instance ToNamedRecord ExternalExamUserTableCsv where
toNamedRecord = Csv.genericToNamedRecord externalExamUserTableCsvOptions
instance DefaultOrdered ExternalExamUserTableCsv where
headerOrder = Csv.genericHeaderOrder externalExamUserTableCsvOptions
instance FromNamedRecord ExternalExamUserTableCsv where
parseNamedRecord csv
= ExternalExamUserTableCsv
<$> csv .:?? "surname"
<*> csv .:?? "first-name"
<*> csv .:?? "name"
<*> csv .:?? "matriculation"
<*> csv .: "occurrence-start"
<*> csv .: "exam-result"
instance CsvColumnsExplained ExternalExamUserTableCsv where
csvColumnsExplanations = genericCsvColumnsExplanations externalExamUserTableCsvOptions $ Map.fromList
[ ('csvEUserSurname , MsgCsvColumnExamUserSurname )
, ('csvEUserFirstName , MsgCsvColumnExamUserFirstName )
, ('csvEUserName , MsgCsvColumnExamUserName )
, ('csvEUserMatriculation , MsgCsvColumnExamUserMatriculation )
, ('csvEUserOccurrenceStart , MsgCsvColumnExamOfficeExamUserOccurrenceStart )
, ('csvEUserExamResult , MsgCsvColumnExamUserResult )
]
data ExternalExamUserAction
= ExternalExamUserMarkSynchronised
deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic, Typeable)
instance Universe ExternalExamUserAction
instance Finite ExternalExamUserAction
nullaryPathPiece ''ExternalExamUserAction $ camelToPathPiece' 3
embedRenderMessage ''UniWorX ''ExternalExamUserAction id
data ExternalExamUserActionData
= ExternalExamUserMarkSynchronisedData
newtype ExternalExamUserCsvExportDataGrades = ExternalExamUserCsvExportDataGrades
{ csvEEUserMarkSynchronised :: Bool
} deriving (Eq, Ord, Read, Show, Generic, Typeable)
data ExamUserCsvException
= ExamUserCsvExceptionNoMatchingUser
deriving (Show, Generic, Typeable)
instance Exception ExamUserCsvException
embedRenderMessage ''UniWorX ''ExamUserCsvException id
data ExternalExamUserCsvActionClass
= ExternalExamUserCsvRegister
| ExternalExamUserCsvDeregister
| ExternalExamUserCsvSetTime
| ExternalExamUserCsvSetResult
deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic, Typeable)
embedRenderMessage ''UniWorX ''ExternalExamUserCsvActionClass id
data ExternalExamUserCsvAction
= ExternalExamUserCsvRegisterData
{ externalExamUserCsvActUser :: UserId
, externalExamUserCsvActTime :: UTCTime
, externalExamUserCsvActResult :: ExamResultPassedGrade
}
| ExternalExamUserCsvSetTimeData
{ externalExamUserCsvActRegistration :: ExternalExamResultId
, externalExamUserCsvActTime :: UTCTime
}
| ExternalExamUserCsvSetResultData
{ externalExamUserCsvActRegistration :: ExternalExamResultId
, externalExamUserCsvActResult :: ExamResultPassedGrade
}
| ExternalExamUserCsvDeregisterData
{ externalExamUserCsvActRegistration :: ExternalExamResultId
}
deriving (Eq, Ord, Read, Show, Generic, Typeable)
deriveJSON defaultOptions
{ constructorTagModifier = over Text.packed $ Text.intercalate "-" . map Text.toLower . drop 4 . dropEnd 1 . splitCamel
, fieldLabelModifier = camelToPathPiece' 5
, sumEncoding = TaggedObject "action" "data"
} ''ExternalExamUserCsvAction
makeExternalExamUsersTable :: ExternalExamUserMode
-> Entity ExternalExam
-> DB (FormResult (ExternalExamUserActionData, Set ExternalExamResultId), Widget)
makeExternalExamUsersTable mode (Entity eeId ExternalExam{..}) = do
let tid = externalExamTerm
ssh = externalExamSchool
coursen = externalExamCourseName
examn = externalExamExamName
uid <- requireAuthId
csvName <- getMessageRender <*> pure (MsgExternalExamUserCsvName tid ssh coursen examn)
isLecturer <- hasReadAccessTo $ EExamR tid ssh coursen examn EEUsersR
currentRoute <- fromMaybe (error "makeExternalExamUsersTable called from 404-handler") <$> getCurrentRoute
let
resultView :: ExamResultGrade -> ExamResultPassedGrade
resultView = fmap $ bool (Left . view passingGrade) Right externalExamShowGrades
dbtSQLQuery = runReaderT $ do
result <- view queryResult
user <- view queryUser
isSynced <- view . queryIsSynced $ E.val uid
lift $ do
E.on $ result E.^. ExternalExamResultUser E.==. user E.^. UserId
E.where_ $ result E.^. ExternalExamResultExam E.==. E.val eeId
unless (isLecturer || mode == EEUMUsers) $
E.where_ $ ExternalExam.examOfficeExternalExamResultAuth (E.val uid) result
return (result, user, isSynced)
dbtRowKey = views queryResult (E.^. ExternalExamResultId)
dbtProj :: DBRow _ -> MaybeT (YesodDB UniWorX) ExternalExamUserTableData
dbtProj = runReaderT $ (asks . set _dbrOutput) <=< magnify _dbrOutput $
(,,,)
<$> view _1 <*> view _2 <*> view (_3 . _Value)
<*> getSynchronised
where
getSynchronised :: ReaderT _ (MaybeT (YesodDB UniWorX)) [(UserDisplayName, UserSurname, UTCTime, Set SchoolShorthand)]
getSynchronised = do
resId <- view $ _1 . _entityKey
syncs <- lift . lift . E.select . E.from $ \(examOfficeExternalResultSynced `E.InnerJoin` user) -> do
E.on $ examOfficeExternalResultSynced E.^. ExamOfficeExternalResultSyncedOffice E.==. user E.^. UserId
E.where_ $ examOfficeExternalResultSynced E.^. ExamOfficeExternalResultSyncedResult E.==. E.val resId
return ( examOfficeExternalResultSynced E.^. ExamOfficeExternalResultSyncedOffice
, ( user E.^. UserDisplayName
, user E.^. UserSurname
, examOfficeExternalResultSynced E.^. ExamOfficeExternalResultSyncedTime
, examOfficeExternalResultSynced E.^. ExamOfficeExternalResultSyncedSchool
)
)
let syncs' = Map.fromListWith
(\(dn, sn, t, sshs) (_, _, _, sshs') -> (dn, sn, t, Set.union sshs sshs'))
[ ((officeId, t), (dn, sn, t, maybe Set.empty Set.singleton ssh'))
| (E.Value officeId, (E.Value dn, E.Value sn, E.Value t, fmap unSchoolKey . E.unValue -> ssh')) <- syncs
]
return $ Map.elems syncs'
colSynced = Colonnade.singleton (fromSortable . Sortable (Just "is-synced") $ i18nCell MsgExamUserSynchronised) $ \x -> cell . flip runReaderT x $ do
syncs <- asks $ sortOn (Down . view _3) . toListOf resultSynchronised
lastChange <- view $ resultResult . _entityVal . _externalExamResultLastChanged
user <- view $ resultUser . _entityVal
isSynced <- view resultIsSynced
let
hasSyncs = has folded syncs
syncs' = [ Right sync | sync@(_, _, t, _) <- syncs, t > lastChange]
++ [ Left lastChange ]
++ [ Right sync | sync@(_, _, t, _) <- syncs, t <= lastChange]
syncIcon :: Widget
syncIcon
| not isSynced
, not hasSyncs
= mempty
| not isSynced
= toWidget iconNotOK
| otherwise
= toWidget iconOK
syncsModal :: Widget
syncsModal = $(widgetFile "exam-office/exam-result-synced")
lift $ bool id (flip modal $ Right syncsModal) hasSyncs syncIcon
dbtColonnade :: Colonnade Sortable _ _
dbtColonnade = mconcat
[ case mode of
EEUMGrades -> mconcat
[ dbSelect (applying _2) id $ return . view (resultResult . _entityKey)
, colSynced
]
_other -> mempty
, colUserDisplayName (resultUser . _entityVal . $(multifocusL 2) _userDisplayName _userSurname)
, colUserMatriculation (resultUser . _entityVal . _userMatrikelnummer)
, Colonnade.singleton (fromSortable . Sortable (Just "occurrence-start") $ i18nCell MsgExamTime) $ \x -> cell . flip runReaderT x $ do
t <- view $ resultResult . _entityVal . _externalExamResultTime
lift $ formatTimeW SelFormatDateTime t
, colExamResult externalExamShowGrades (resultResult . _entityVal . _externalExamResultResult)
]
dbtSorting = mconcat
[ sortUserName' (queryUser . to ((,) <$> (E.^. UserDisplayName) <*> (E.^. UserSurname)))
, sortUserMatriculation (queryUser . to (E.^. UserMatrikelnummer))
, sortOccurrenceStart (queryResult . to (E.^. ExternalExamResultTime))
, maybeOpticSortColumn (sortExamResult externalExamShowGrades) (queryResult . to (E.^. ExternalExamResultResult))
, singletonMap "is-synced" . SortColumn $ view (queryIsSynced $ E.val uid)
]
dbtFilter = mconcat
[ fltrUserName' (queryUser . to (E.^. UserDisplayName))
, fltrUserMatriculation (queryUser . to (E.^. UserMatrikelnummer))
, fltrExamResultPoints externalExamShowGrades (queryResult . to (E.^. ExternalExamResultResult))
, singletonMap "is-synced" . FilterColumn $ E.mkExactFilter (view . queryIsSynced $ E.val uid)
]
dbtFilterUI = mconcat
[ fltrUserNameUI'
, fltrUserMatriculationUI
, fltrExamResultPointsUI externalExamShowGrades
, case mode of
EEUMGrades ->
\mPrev -> prismAForm (singletonFilter "is-synced" . maybePrism _PathPiece) mPrev $ aopt (boolField . Just $ SomeMessage MsgBoolIrrelevant) (fslI MsgExamUserSynchronised)
_other -> mempty
]
dbtStyle = def { dbsFilterLayout = defaultDBSFilterLayout }
dbtParams = DBParamsForm
{ dbParamsFormMethod = POST
, dbParamsFormAction = Just $ SomeRoute currentRoute
, dbParamsFormAttrs = []
, dbParamsFormSubmit = case mode of
EEUMGrades -> FormSubmit
_other -> FormNoSubmit
, dbParamsFormAdditional = case mode of
EEUMGrades -> \csrf -> do
let
actionMap :: Map ExternalExamUserAction (AForm Handler ExternalExamUserActionData)
actionMap = Map.fromList
[ ( ExternalExamUserMarkSynchronised
, pure ExternalExamUserMarkSynchronisedData
)
]
(res, formWgt) <- multiActionM actionMap (fslI MsgAction) Nothing csrf
let formRes = (, mempty) . First . Just <$> res
return (formRes, formWgt)
_other -> \csrf -> return (FormMissing, toWidget csrf)
, dbParamsFormEvaluate = liftHandler . runFormPost
, dbParamsFormResult = id
, dbParamsFormIdent = def
}
dbtIdent = mode
dbtCsvEncode = case mode of
EEUMGrades -> Just DBTCsvEncode
{ dbtCsvExportForm = ExternalExamUserCsvExportDataGrades
<$> apopt checkBoxField (fslI MsgExamUserMarkSynchronisedCsv & setTooltip MsgExamUserMarkSynchronisedCsvTip) (Just False)
, dbtCsvDoEncode = \ExternalExamUserCsvExportDataGrades{..} -> C.mapM $ \(E.Value k, row) -> do
when csvEEUserMarkSynchronised $ externalExamResultMarkSynchronised k
return $ encodeCsv' row
, dbtCsvName = unpack csvName
, dbtCsvNoExportData = Nothing
, dbtCsvHeader = const . return $ headerOrder (error "headerOrder" :: ExternalExamUserTableCsv)
}
EEUMUsers -> simpleCsvEncode csvName encodeCsv'
where
encodeCsv' :: ExternalExamUserTableData -> ExternalExamUserTableCsv
encodeCsv' row = ExternalExamUserTableCsv
{ csvEUserSurname = row ^? resultUser . _entityVal . _userSurname
, csvEUserFirstName = row ^? resultUser . _entityVal . _userFirstName
, csvEUserName = row ^? resultUser . _entityVal . _userDisplayName
, csvEUserMatriculation = row ^? resultUser . _entityVal . _userMatrikelnummer . _Just
, csvEUserOccurrenceStart = row ^. resultResult . _entityVal . _externalExamResultTime . to utcToZonedTime
, csvEUserExamResult = row ^. resultResult . _entityVal . _externalExamResultResult . to (fmap $ bool (Left . view passingGrade) Right externalExamShowGrades)
}
dbtCsvDecode
| mode == EEUMUsers = Just DBTCsvDecode
{ dbtCsvRowKey = \csv -> do
pid <- lift $ guessUser' csv
fmap E.Value . MaybeT . getKeyBy $ UniqueExternalExamResult eeId pid
, dbtCsvComputeActions = \case
DBCsvDiffMissing{dbCsvOldKey}
-> yield . ExternalExamUserCsvDeregisterData $ E.unValue dbCsvOldKey
DBCsvDiffNew{dbCsvNewKey = Just _}
-> error "An UniqueExternalExamResult could be found, but the ExternalExamResultKey is not among the existing keys"
DBCsvDiffNew{dbCsvNewKey = Nothing, dbCsvNew} -> do
pid <- lift $ guessUser' dbCsvNew
let ExternalExamUserTableCsv{..} = dbCsvNew
yield $ ExternalExamUserCsvRegisterData pid (zonedTimeToUTC csvEUserOccurrenceStart) csvEUserExamResult
DBCsvDiffExisting{..} -> do
let ExternalExamUserTableCsv{..} = dbCsvNew
when (zonedTimeToUTC csvEUserOccurrenceStart /= dbCsvOld ^. resultResult . _entityVal . _externalExamResultTime) $
yield $ ExternalExamUserCsvSetTimeData (E.unValue dbCsvOldKey) (zonedTimeToUTC csvEUserOccurrenceStart)
when (csvEUserExamResult /= dbCsvOld ^. resultResult . _entityVal . _externalExamResultResult . to resultView) $
yield $ ExternalExamUserCsvSetResultData (E.unValue dbCsvOldKey) csvEUserExamResult
, dbtCsvClassifyAction = \case
ExternalExamUserCsvRegisterData{} -> ExternalExamUserCsvRegister
ExternalExamUserCsvSetTimeData{} -> ExternalExamUserCsvSetTime
ExternalExamUserCsvSetResultData{} -> ExternalExamUserCsvSetResult
ExternalExamUserCsvDeregisterData{} -> ExternalExamUserCsvDeregister
, dbtCsvCoarsenActionClass = \case
ExternalExamUserCsvRegister -> DBCsvActionNew
ExternalExamUserCsvDeregister -> DBCsvActionMissing
_other -> DBCsvActionExisting
, dbtCsvExecuteActions = do
C.mapM_ $ \case
ExternalExamUserCsvRegisterData{..} -> do
now <- liftIO getCurrentTime
let res' = either (review passingGrade) id <$> externalExamUserCsvActResult
insert_ ExternalExamResult
{ externalExamResultExam = eeId
, externalExamResultUser = externalExamUserCsvActUser
, externalExamResultTime = externalExamUserCsvActTime
, externalExamResultResult = res'
, externalExamResultLastChanged = now
}
audit $ TransactionExternalExamResultEdit eeId externalExamUserCsvActUser
ExternalExamUserCsvSetTimeData{..} -> do
now <- liftIO getCurrentTime
ExternalExamResult{..} <- updateGet externalExamUserCsvActRegistration
[ ExternalExamResultTime =. externalExamUserCsvActTime
, ExternalExamResultLastChanged =. now
]
audit $ TransactionExternalExamResultEdit eeId externalExamResultUser
ExternalExamUserCsvSetResultData{..} -> do
now <- liftIO getCurrentTime
let res' = either (review passingGrade) id <$> externalExamUserCsvActResult
ExternalExamResult{..} <- updateGet externalExamUserCsvActRegistration
[ ExternalExamResultResult =. res'
, ExternalExamResultLastChanged =. now
]
audit $ TransactionExternalExamResultEdit eeId externalExamResultUser
ExternalExamUserCsvDeregisterData{..} -> do
ExternalExamResult{..} <- getJust externalExamUserCsvActRegistration
delete externalExamUserCsvActRegistration
audit $ TransactionExternalExamResultDelete eeId externalExamResultUser
return $ EExamR tid ssh coursen examn EEUsersR
, dbtCsvRenderKey = \(registeredUserName -> registeredUserName') -> \case
ExternalExamUserCsvRegisterData{..} -> do
User{..} <- liftHandler . runDB $ getJust externalExamUserCsvActUser
[whamlet|
$newline never
^{nameWidget userDisplayName userSurname}
, ^{formatTimeW SelFormatDateTime externalExamUserCsvActTime}
, _{externalExamUserCsvActResult}
|]
ExternalExamUserCsvSetTimeData{..} ->
[whamlet|
$newline never
^{registeredUserName' externalExamUserCsvActRegistration}
, ^{formatTimeW SelFormatDateTime externalExamUserCsvActTime}
|]
ExternalExamUserCsvSetResultData{..} ->
[whamlet|
$newline never
^{registeredUserName' externalExamUserCsvActRegistration}
, _{externalExamUserCsvActResult}
|]
ExternalExamUserCsvDeregisterData{..} ->
registeredUserName' externalExamUserCsvActRegistration
, dbtCsvRenderActionClass = i18n
, dbtCsvRenderException = ap getMessageRender . pure :: ExamUserCsvException -> DB Text
}
| otherwise = Nothing
where
registeredUserName :: Map (E.Value ExternalExamResultId) ExternalExamUserTableData -> ExternalExamResultId -> Widget
registeredUserName existing (E.Value -> registration) = nameWidget userDisplayName userSurname
where
Entity _ User{..} = view resultUser $ existing Map.! registration
guessUser' :: ExternalExamUserTableCsv -> DB UserId
guessUser' ExternalExamUserTableCsv{..} = do
let criteria = Set.fromList $ catMaybes
[ GuessUserMatrikelnummer <$> csvEUserMatriculation
, GuessUserDisplayName <$> csvEUserName
, GuessUserSurname <$> csvEUserSurname
, GuessUserFirstName <$> csvEUserFirstName
]
maybe (throwM ExamUserCsvExceptionNoMatchingUser) return =<< guessUser criteria
externalExamUsersDBTableValidator = def
& defaultSorting (bool id (SortAscBy "is-synced" :) (mode == EEUMGrades) [SortAscBy "user-name"])
& defaultPagesize PagesizeAll
postprocess :: FormResult (First ExternalExamUserActionData, DBFormResult ExternalExamResultId Bool ExternalExamUserTableData) -> FormResult (ExternalExamUserActionData, Set ExternalExamResultId)
postprocess inp = do
(First (Just act), regMap) <- inp
let regSet = Map.keysSet . Map.filter id $ getDBFormResult (const False) regMap
return (act, regSet)
over _1 postprocess <$> dbTable externalExamUsersDBTableValidator DBTable{..}
externalExamResultMarkSynchronised :: ExternalExamResultId -> DB ()
externalExamResultMarkSynchronised resId = do
uid <- requireAuthId
now <- liftIO getCurrentTime
userFunctions <- selectList [ UserFunctionUser ==. uid, UserFunctionFunction ==. SchoolExamOffice ] []
if
| null userFunctions ->
insert_ ExamOfficeExternalResultSynced
{ examOfficeExternalResultSyncedOffice = uid
, examOfficeExternalResultSyncedResult = resId
, examOfficeExternalResultSyncedTime = now
, examOfficeExternalResultSyncedSchool = Nothing
}
| otherwise ->
insertMany_ [ ExamOfficeExternalResultSynced
{ examOfficeExternalResultSyncedOffice = uid
, examOfficeExternalResultSyncedResult = resId
, examOfficeExternalResultSyncedTime = now
, examOfficeExternalResultSyncedSchool = Just userFunctionSchool
}
| Entity _ UserFunction{..} <- userFunctions
]

View File

@ -134,7 +134,7 @@ data InvitationConfig junction = forall formCtx. InvitationConfig
-- ^ Additional restrictions to check before allowing an user to redeem an invitation token
, invitationForm :: Entity (InvitationFor junction) -> InvitationData junction -> Key User -> AForm (YesodDB UniWorX) (InvitableJunction junction, formCtx)
-- ^ Assimilate the additional data entered by the redeeming user
, invitationInsertHook :: forall a. Entity (InvitationFor junction) -> InvitationData junction -> junction -> formCtx -> (YesodJobDB UniWorX a -> YesodJobDB UniWorX a)
, invitationInsertHook :: forall a. UserEmail -> Entity (InvitationFor junction) -> InvitationData junction -> junction -> formCtx -> (YesodJobDB UniWorX a -> YesodJobDB UniWorX a)
-- ^ Perform additional actions before or after insertion of the junction into the database
, invitationSuccessMsg :: Entity (InvitationFor junction) -> Entity junction -> DB (SomeMessage UniWorX)
-- ^ What to tell the redeeming user after accepting the invitation
@ -402,7 +402,7 @@ invitationR' InvitationConfig{..} = liftHandler $ do
return . Just $ SomeRoute HomeR
Just (jData, formCtx) -> do
let junction = review _InvitableJunction (invitee, fid, jData)
mResult <- invitationInsertHook fEnt iData junction formCtx $ insertUniqueEntity junction
mResult <- invitationInsertHook itEmail fEnt iData junction formCtx $ insertUniqueEntity junction
case mResult of
Nothing -> invalidArgsI [MsgInvitationCollision]
Just res -> do

View File

@ -840,6 +840,31 @@ anchorColonnadeM mkUrl = imapColonnade anchorColonnade'
anchorColonnade' inp (view dbCell -> (attrs, act)) = review dbCell . (attrs,) $
view (dbCell . _2) . anchorCellM (mkUrl inp) =<< act
maybeAnchorColonnade :: forall h r' m a url.
( HasRoute UniWorX url
, IsDBTable m a
, HandlerSite m ~ UniWorX
)
=> (r' -> Maybe url)
-> Colonnade h r' (DBCell m a)
-> Colonnade h r' (DBCell m a)
maybeAnchorColonnade = maybeAnchorColonnadeM . (hoistMaybe .)
maybeAnchorColonnadeM :: forall h r' m a url.
( HasRoute UniWorX url
, IsDBTable m a
, HandlerSite m ~ UniWorX
)
=> (r' -> MaybeT (WidgetFor UniWorX) url)
-> Colonnade h r' (DBCell m a)
-> Colonnade h r' (DBCell m a)
maybeAnchorColonnadeM mkUrl = imapColonnade anchorColonnade'
where
anchorColonnade' :: r' -> DBCell m a -> DBCell m a
anchorColonnade' inp (view dbCell -> (attrs, act)) = review dbCell . (attrs,) $
view (dbCell . _2) . maybeAnchorCellM (mkUrl inp) =<< act
emptyOpticColonnade :: forall h r' focus c.
( Monoid c
)

View File

@ -33,6 +33,7 @@ module Handler.Utils.Table.Pagination
, cell, textCell, stringCell, i18nCell
, anchorCell, anchorCell', anchorCellM, anchorCellM'
, linkEitherCell, linkEitherCellM, linkEitherCellM'
, maybeAnchorCellM, maybeAnchorCellM', maybeLinkEitherCellM'
, cellTooltip
, listCell
, formCell, DBFormResult(..), getDBFormResult
@ -93,6 +94,8 @@ import Data.Ratio ((%))
import Data.List (elemIndex)
import Data.Maybe (fromJust)
import Data.Aeson (Options(..), SumEncoding(..), defaultOptions)
import Data.Aeson.Text
import Data.Aeson.TH (deriveJSON)
@ -1300,6 +1303,12 @@ anchorCellM routeM widget = anchorCellM' routeM id (const widget)
anchorCellM' :: (HasRoute UniWorX url, ToWidget UniWorX wgt, IsDBTable m a) => WidgetFor UniWorX x -> (x -> url) -> (x -> wgt) -> DBCell m a
anchorCellM' xM x2route x2widget = linkEitherCellM' xM x2route (x2widget, x2widget)
maybeAnchorCellM :: (HasRoute UniWorX url, ToWidget UniWorX wgt, IsDBTable m a) => MaybeT (WidgetFor UniWorX) url -> wgt -> DBCell m a
maybeAnchorCellM routeM widget = maybeAnchorCellM' routeM id (const widget)
maybeAnchorCellM' :: (HasRoute UniWorX url, ToWidget UniWorX wgt, IsDBTable m a) => MaybeT (WidgetFor UniWorX) x -> (x -> url) -> (Maybe x -> wgt) -> DBCell m a
maybeAnchorCellM' xM x2route x2widget = maybeLinkEitherCellM' xM x2route (x2widget . Just, x2widget)
-- | Variant of `anchorCell` that displays different widgets depending whether the route is authorized for current user
linkEitherCell :: (HasRoute UniWorX url, ToWidget UniWorX wgt, ToWidget UniWorX wgt', IsDBTable m a) => url -> (wgt, wgt') -> DBCell m a
linkEitherCell = linkEitherCellM . return
@ -1314,17 +1323,31 @@ linkEitherCellM' :: forall m url wgt wgt' a x.
, IsDBTable m a
)
=> WidgetFor UniWorX x -> (x -> url) -> (x -> wgt, x -> wgt') -> DBCell m a
linkEitherCellM' xM x2route (x2widgetAuth,x2widgetUnauth) = cell $ do
x <- xM
let route = x2route x
widget, widgetUnauth :: Widget
widget = toWidget $ x2widgetAuth x
widgetUnauth = toWidget $ x2widgetUnauth x
authResult <- liftHandler $ isAuthorized (urlRoute route) False
linkUrl <- toTextUrl route
case authResult of
Authorized -> $(widgetFile "table/cell/link") -- show allowed link
_otherwise -> widgetUnauth -- show alternative widget
linkEitherCellM' xM x2route (x2widgetAuth,x2widgetUnauth) = maybeLinkEitherCellM' (lift xM) x2route (x2widgetAuth, x2widgetUnauth . fromJust)
maybeLinkEitherCellM' :: forall m url wgt wgt' a x.
( HasRoute UniWorX url
, ToWidget UniWorX wgt
, ToWidget UniWorX wgt'
, IsDBTable m a
)
=> MaybeT (WidgetFor UniWorX) x -> (x -> url) -> (x -> wgt, Maybe x -> wgt') -> DBCell m a
maybeLinkEitherCellM' xM x2route (x2widgetAuth,x2widgetUnauth) = cell $ do
x' <- runMaybeT xM
case x' of
Just x -> do
let route = x2route x
widget, widgetUnauth :: Widget
widget = toWidget $ x2widgetAuth x
widgetUnauth = toWidget . x2widgetUnauth $ Just x
authResult <- liftHandler $ isAuthorized (urlRoute route) False
linkUrl <- toTextUrl route
case authResult of
Authorized -> $(widgetFile "table/cell/link") -- show allowed link
_otherwise -> widgetUnauth -- show alternative widget
_otherwise -> do
toWidget $ x2widgetUnauth Nothing

View File

@ -2,9 +2,12 @@ module Handler.Utils.Users
( computeUserAuthenticationDigest
, Digest, SHA3_256
, constEq
, GuessUserInfo(..)
, guessUser
) where
import Import
import Auth.LDAP (campusUserMatr')
import Crypto.Hash (Digest, SHA3_256, hashlazy)
@ -12,6 +15,74 @@ import Data.ByteArray (constEq)
import qualified Data.Aeson as JSON
import qualified Data.Set as Set
import qualified Data.CaseInsensitive as CI
import qualified Database.Esqueleto as E
import qualified Database.Esqueleto.Utils as E
computeUserAuthenticationDigest :: AuthenticationMode -> Digest SHA3_256
computeUserAuthenticationDigest = hashlazy . JSON.encode
data GuessUserInfo
= GuessUserMatrikelnummer { guessUserMatrikelnummer :: UserMatriculation }
| GuessUserDisplayName { guessUserDisplayName :: UserDisplayName }
| GuessUserSurname { guessUserSurname :: UserSurname }
| GuessUserFirstName { guessUserFirstName :: UserFirstName }
deriving (Eq, Ord, Read, Show, Generic, Typeable)
instance Binary GuessUserInfo
makeLenses_ ''GuessUserInfo
guessUser :: Set GuessUserInfo -> DB (Maybe UserId)
guessUser (Set.toList -> criteria) = $cachedHereBinary criteria $ go False
where
toSql user = \case
GuessUserMatrikelnummer userMatriculation' -> user E.^. UserMatrikelnummer E.==. E.val (Just userMatriculation')
GuessUserDisplayName userDisplayName' -> user E.^. UserDisplayName `E.hasInfix` E.val userDisplayName'
GuessUserSurname userSurname' -> user E.^. UserSurname `E.hasInfix` E.val userSurname'
GuessUserFirstName userFirstName' -> user E.^. UserFirstName `E.hasInfix` E.val userFirstName'
go didLdap = do
let retrieveUsers = E.select . E.from $ \user -> do
E.where_ . E.and $ map (toSql user) criteria
return user
users <- retrieveUsers
let users' = sortBy (flip closeness) users
matchesMatriculation :: Entity User -> Maybe Bool
matchesMatriculation = preview $ _entityVal . _userMatrikelnummer . to (\userMatr -> all ((== userMatr) . Just) $ criteria ^.. folded . _guessUserMatrikelnummer)
closeness :: Entity User -> Entity User -> Ordering
closeness = mconcat $ concat
[ pure $ comparing (fmap Down . matchesMatriculation)
, (criteria ^.. folded . _guessUserSurname) <&> \surn -> comparing (preview $ _entityVal . _userSurname . to CI.mk . only (CI.mk surn))
, (criteria ^.. folded . _guessUserFirstName) <&> \firstn -> comparing (preview $ _entityVal . _userFirstName . to CI.mk . only (CI.mk firstn))
, (criteria ^.. folded . _guessUserDisplayName) <&> \dispn -> comparing (preview $ _entityVal . _userDisplayName . to CI.mk . only (CI.mk dispn))
]
doLdap userMatr = do
app <- getYesod
let ldap = (,) <$> app ^. _appLdapConf <*> app ^. _appLdapPool
fmap (fmap entityKey . join) . for ldap $ \(ldapConf, ldapPool) -> do
ldapData <- campusUserMatr' ldapConf ldapPool userMatr
for ldapData $ upsertCampusUser UpsertCampusUser
case users' of
x@(Entity pid _) : xs
| [] <- xs
, fromMaybe False (matchesMatriculation x) || didLdap
-> return $ Just pid
| x' : _ <- xs
, fromMaybe False (matchesMatriculation x) || didLdap
, GT <- x `closeness` x'
-> return $ Just pid
| not didLdap
, userMatr : userMatrs' <- criteria ^.. folded . _guessUserMatrikelnummer
, all (== userMatr) userMatrs'
-> doLdap userMatr >>= maybe (go True) (return . Just)
_other
-> return Nothing

View File

@ -307,6 +307,27 @@ determineCrontab = execWriterT $ do
runConduit $ transPipe lift (selectSource [] []) .| C.mapM_ examJobs
let
externalExamJobs (Entity nExternalExam ExternalExam{..}) = do
newestResult <- lift . E.select . E.from $ \externalExamResult -> do
E.where_ $ externalExamResult E.^. ExternalExamResultExam E.==. E.val nExternalExam
return . E.max_ $ externalExamResult E.^. ExternalExamResultLastChanged
case newestResult of
[E.Value (Just lastChange)] ->
tell $ HashMap.singleton
(JobCtlQueue $ JobQueueNotification NotificationExamOfficeExternalExamResults{..})
Cron
{ cronInitial = CronTimestamp . utcToLocalTimeTZ appTZ $ addUTCTime appNotificationCollateDelay lastChange
, cronRepeat = CronRepeatOnChange
, cronRateLimit = nominalDay
, cronNotAfter = Left appNotificationExpiration
}
_other -> return ()
runConduit $ transPipe lift (selectSource [] []) .| C.mapM_ externalExamJobs
let
allocationJobs (Entity nAllocation Allocation{..}) = do
whenIsJust allocationStaffRegisterFrom $ \staffRegisterFrom ->

View File

@ -16,6 +16,7 @@ import Jobs.Queue
import qualified Data.Set as Set
import Handler.Utils.ExamOffice.Exam
import Handler.Utils.ExamOffice.ExternalExam
dispatchJobQueueNotification :: Notification -> Handler ()
@ -198,6 +199,12 @@ determineNotificationCandidates NotificationExamOfficeExamResultsChanged{..} =
E.where_ $ examResult E.^. ExamResultId `E.in_` E.valList (Set.toList nExamResults)
E.where_ $ examOfficeExamResultAuth (user E.^. UserId) examResult
return user
determineNotificationCandidates NotificationExamOfficeExternalExamResults{..} =
E.select . E.from $ \user -> do
E.where_ . E.exists . E.from $ \externalExamResult -> do
E.where_ $ externalExamResult E.^. ExternalExamResultExam E.==. E.val nExternalExam
E.where_ $ examOfficeExternalExamResultAuth (user E.^. UserId) externalExamResult
return user
determineNotificationCandidates notif@NotificationAllocationResults{..} = do
lastExec <- fmap (fmap $ cronLastExecTime . entityVal) . getBy . UniqueCronLastExec . toJSON $ JobQueueNotification notif
E.select . E.from $ \user -> do
@ -261,6 +268,7 @@ classifyNotification NotificationAllocationOutdatedRatings{} = return NTAll
classifyNotification NotificationAllocationUnratedApplications{} = return NTAllocationUnratedApplications
classifyNotification NotificationExamOfficeExamResults{} = return NTExamOfficeExamResults
classifyNotification NotificationExamOfficeExamResultsChanged{} = return NTExamOfficeExamResultsChanged
classifyNotification NotificationExamOfficeExternalExamResults{} = return NTExamOfficeExamResults
classifyNotification NotificationAllocationResults{} = return NTAllocationResults
classifyNotification NotificationCourseRegistered{} = return NTCourseRegistered
classifyNotification NotificationSubmissionEdited{} = return NTSubmissionEdited

View File

@ -3,6 +3,7 @@
module Jobs.Handler.SendNotification.ExamOffice
( dispatchNotificationExamOfficeExamResults
, dispatchNotificationExamOfficeExamResultsChanged
, dispatchNotificationExamOfficeExternalExamResults
) where
import Import
@ -61,3 +62,22 @@ dispatchNotificationExamOfficeExamResultsChanged nExamResults jRecipient = do
addAlternatives $
providePreferredAlternative ($(ihamletFile "templates/mail/examOffice/examResultsChanged.hamlet") :: HtmlUrlI18n UniWorXMessage (Route UniWorX))
dispatchNotificationExamOfficeExternalExamResults :: ExternalExamId -> UserId -> Handler ()
dispatchNotificationExamOfficeExternalExamResults nExternalExam jRecipient = userMailT jRecipient $ do
ExternalExam{..} <- liftHandler . runDB $ getJust nExternalExam
replaceMailHeader "Auto-Submitted" $ Just "auto-generated"
setSubjectI $ MsgMailSubjectExamOfficeExternalExamResults externalExamCourseName externalExamExamName
MsgRenderer mr <- getMailMsgRenderer
let termDesc = mr . ShortTermIdentifier $ unTermKey externalExamTerm
tid = externalExamTerm
ssh = externalExamSchool
coursen = externalExamCourseName
examn = externalExamExamName
editNotifications <- mkEditNotifications jRecipient
addAlternatives $
providePreferredAlternative ($(ihamletFile "templates/mail/examOffice/externalExamResults.hamlet") :: HtmlUrlI18n UniWorXMessage (Route UniWorX))

View File

@ -6,7 +6,6 @@ module Jobs.Handler.SynchroniseLdap
import Import
import qualified Data.Conduit.List as C
import qualified Data.CaseInsensitive as CI
import Auth.LDAP
@ -48,11 +47,7 @@ dispatchJobSynchroniseLdapUser jUser = do
$logInfoS "SynchroniseLdap" [st|Synchronising #{userIdent}|]
ldapAttrs <- MaybeT $ campusUser' ldapConf ldapPool user
void . lift $ upsertCampusUser ldapAttrs Creds
{ credsIdent = CI.original userIdent
, credsPlugin = "dummy"
, credsExtra = []
}
void . lift $ upsertCampusUser UpsertCampusUser ldapAttrs
Nothing ->
throwM SynchroniseLdapNoLdap
where

View File

@ -89,6 +89,7 @@ data Notification = NotificationSubmissionRated { nSubmission :: SubmissionId }
| NotificationAllocationOutdatedRatings { nAllocation :: AllocationId }
| NotificationExamOfficeExamResults { nExam :: ExamId }
| NotificationExamOfficeExamResultsChanged { nExamResults :: Set ExamResultId }
| NotificationExamOfficeExternalExamResults { nExternalExam :: ExternalExamId }
| NotificationAllocationResults { nAllocation :: AllocationId }
| NotificationCourseRegistered { nUser :: UserId, nCourse :: CourseId }
| NotificationSubmissionEdited { nInitiator :: UserId, nSubmission :: SubmissionId }

View File

@ -51,6 +51,7 @@ data AuthTag -- sortiert nach gewünschter Reihenfolge auf /authpreds, d.h. Prä
| AuthTutor
| AuthTutorControl
| AuthExamOffice
| AuthEvaluation
| AuthAllocationRegistered
| AuthCourseRegistered
| AuthTutorialRegistered

View File

@ -8,7 +8,7 @@ import ClassyPrelude.Yesod hiding (foldlM, Proxy, handle, catch)
-- import Data.Double.Conversion.Text -- faster implementation for textPercent?
import qualified Data.Foldable as Fold
import Data.Foldable as Utils (foldlM, foldrM)
import Data.Monoid (Sum(..))
import Data.Monoid (First, Sum(..))
import Data.Proxy
import Data.CaseInsensitive (CI)
@ -381,6 +381,9 @@ setProduct :: Set a -> Set b -> Set (a, b)
-- ^ Depends on the valid internal structure of the given sets
setProduct (Set.toAscList -> as) (Set.toAscList -> bs) = Set.fromDistinctAscList $ (,) <$> as <*> bs
setPartitionEithers :: (Ord a, Ord b) => Set (Either a b) -> (Set a, Set b)
setPartitionEithers = (,) <$> setMapMaybe (preview _Left) <*> setMapMaybe (preview _Right)
----------
-- Maps --
----------
@ -985,3 +988,13 @@ unstableSortOn = unstableSortBy . comparing
unstableSort :: (MonadRandom m, Ord a) => [a] -> m [a]
unstableSort = unstableSortBy compare
----------
-- Lens --
----------
mpreview :: (MonadPlus m, MonadReader s m) => Getting (First a) s a -> m a
mpreview = hoistMaybe <=< preview
mpreviews :: (MonadPlus m, MonadReader s m) => Getting (First b) s a -> (a -> b) -> m b
mpreviews a f = hoistMaybe =<< previews a f

View File

@ -49,6 +49,9 @@ _nullable = prism' toNullable fromNullable
_SchoolId :: Iso' SchoolId SchoolShorthand
_SchoolId = iso unSchoolKey SchoolKey
_TermId :: Iso' TermId TermIdentifier
_TermId = iso unTermKey TermKey
_StudyTermsId :: Iso' StudyTermsId StudyTermsKey
_StudyTermsId = iso unStudyTermsKey StudyTermsKey'
@ -221,6 +224,11 @@ makeLenses_ ''Tutorial
makeLenses_ ''SessionFile
makeLenses_ ''ExternalExam
makeLenses_ ''ExternalExamOfficeSchool
makeLenses_ ''ExternalExamStaff
makeLenses_ ''ExternalExamResult
-- makeClassy_ ''Load

View File

@ -87,7 +87,6 @@ multifocusL = multifocusOptic
(\s a -> [t|Lens' $(s) $(a)|])
(\doGet doSet -> [e|lens $(doGet) $(doSet)|])
multifocusOptic :: _ -> _ -> _ -> _ -> Natural -> ExpQ
multifocusOptic _ _ _ _ 0 = [e|united|]
multifocusOptic doClone _ _ _ 1 = doClone

View File

@ -2,4 +2,8 @@ $newline never
<section>
^{closeWgt}
<section>
$if hasUsers
<div .notification .notification-info .fa-question .notification--broad>
<div .notification__content>
_{MsgExamGradesExplanation}
^{examUsersTable}

View File

@ -0,0 +1,6 @@
$newline never
$if hasUsers
<div .notification .notification-info .fa-question .notification--broad>
<div .notification__content>
_{MsgExamGradesExplanation}
^{table}

View File

@ -0,0 +1,62 @@
$newline never
$maybe ExternalExamResult{externalExamResultResult} <- mResult
<section>
<h2>
_{MsgExamResult}
<p .result>
$case externalExamResultResult
$of ExamAttended grade
$if externalExamShowGrades
_{grade}
$else
$if view (passingGrade . _Wrapped) grade
_{MsgExamPassed}
$else
_{MsgExamNotPassed}
$of ExamNoShow
_{MsgExamNoShow}
$of ExamVoided
_{MsgExamVoided}
<section>
<dl .deflist>
<dt .deflist__dt>_{MsgTerm}
<dd .deflist__dd>
_{unTermKey externalExamTerm}
<dt .deflist__dt>_{MsgCourseSchool}
<dd .deflist__dd>
#{schoolName}
<dt .deflist__dt>_{MsgCourseName}
<dd .deflist__dd>
#{externalExamCourseName}
<dt .deflist__dt>_{MsgExamName}
<dd .deflist__dd>
#{externalExamExamName}
$maybe examTime <- fmap externalExamResultTime mResult <|> externalExamDefaultTime
<dt .deflist__dt>
_{MsgExamTime}
<dd .deflist__dd>
^{formatTimeW SelFormatDateTime examTime}
$if not (null addSchools)
<dt .deflist__dt>
_{MsgExternalExamExamOfficeSchools}
<dd .deflist__dd>
<ul>
$forall Entity _ School{schoolName} <- addSchools
<li>
#{schoolName}
$if not (null staff)
<dt .deflist__dt>
_{MsgExternalExamStaff}
<dd .deflist__dd>
<ul>
$forall s <- staff
$case s
$of Right (Entity _ User{userDisplayName, userDisplayEmail, userSurname})
<li>
^{nameEmailWidget userDisplayEmail userDisplayName userSurname}
$of Left email
<li style="font-family: monospace">
#{email}

View File

@ -0,0 +1,6 @@
$newline never
<td>
#{csrf}
^{fvInput addView}
<td>
^{fvInput submitView}

View File

@ -0,0 +1,3 @@
$newline never
<td>
#{schoolName}

View File

@ -0,0 +1,11 @@
$newline never
<table>
<tbody>
$forall coord <- review liveCoords lLength
<tr .massinput__cell>
^{cellWdgts ! coord}
<td>
^{fvInput (delButtons ! coord)}
<tfoot>
<tr .massinput__cell.massinput__cell--add>
^{addWdgts ! (0, 0)}

View File

@ -0,0 +1,6 @@
$newline never
<td colspan=2>
#{csrf}
^{fvInput addView}
<td>
^{fvInput submitView}

View File

@ -0,0 +1,6 @@
$newline never
<td>
<span style="font-family: monospace">
#{email}
<td>
^{messageTooltip invWarnMsg}

View File

@ -0,0 +1,3 @@
$newline never
<td colspan=2>
^{nameEmailWidget userEmail userDisplayName userSurname}

View File

@ -0,0 +1,11 @@
$newline never
<table>
<tbody>
$forall coord <- review liveCoords lLength
<tr .massinput__cell>
^{cellWdgts ! coord}
<td>
^{fvInput (delButtons ! coord)}
<tfoot>
<tr .massinput__cell.massinput__cell--add>
^{addWdgts ! (0, 0)}

View File

@ -1,5 +1,16 @@
$newline never
<dl .deflist>
<dt .deflist__dt>
^{formatGregorianW 2020 01 17}
<dd .deflist__dd>
<ul>
<li>
Eintragung von Ergebnissen für extern (nicht in Uni2work #
verwaltete) Klausuren zur Übermittlung an Prüfungsbeauftragte
<li>
Export von Listen von Kursteilnehmern zur Durchführung von #
Kursumfragen
<dt .deflist__dt>
^{formatGregorianW 2019 12 05}
<dd .deflist__dd>

View File

@ -1,5 +1,15 @@
$newline never
<dl .deflist>
<dt .deflist__dt>
^{formatGregorianW 2020 01 17}
<dd .deflist__dd>
<ul>
<li>
Support for uploading results of external exams (not managed #
within Uni2work).
<li>
Export of lists of course participants
<dt .deflist__dt>
^{formatGregorianW 2019 12 05}
<dd .deflist__dd>

View File

@ -10,37 +10,52 @@ $newline never
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.
Die Überschrift darf daher nicht verändert oder entfernt werden.<br />
Das verwendete Separator-Zeichen (Komma, Semikolon, Tabulator, #
...) wird beim Import automatisch erkannt.<br />
Beim Import wird stets die selbe Zeichenkodierung erwartet, wie #
sie auch für den CSV-Export eingestellt ist.
<dt .deflist__dt>Änderungen
<dd .deflist__dd>
Einige Zellen können durch den Import verändert werden.<br />
Nicht-änderbare Zellen werden ignoriert, falls diese verändert wurden.
Bei den meisten Zellen wird durch den Import der Stand der #
Datenbank dem Inhalt der Zelle angepasst (z.B. ein #
Klausurergebnis).<br />
Bei Zellen, wo dies nicht möglich ist (z.B. die #
Maximalpunktezahl einer Teilaufgabe), werden etwaige #
Unterschiede zum Stand der Datenbank ignoriert.
<dt .deflist__dt>Vorschau
<dd .deflist__dd>
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.
Es wird eine Vorschau angezeigt, bevor irgendetwas tatsächlich #
geändert wird.<br />
In der Vorschau kann dann auch eine beliebige Teilmenge der #
Änderungen zur Anwendung ausgewählt werden.
<dt .deflist__dt>Leere Zellen
<dd .deflist__dd>
Löschbare Zellen werden durch leere Zellen gelöscht oder auf eindeutige Werte gesetzt.
Löschbare Zellen werden durch leere Zellen gelöscht oder auf #
eindeutige Werte gesetzt.
<dt .deflist__dt>Konsistenz
<dd .deflist__dd>
<p>
Es werden nur konsistente Änderungen akzeptiert!
<p>
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.
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.<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.
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, sofern 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>

View File

@ -10,11 +10,20 @@ $newline never
the column will be treated as if every cell contained within it was #
empty).<br />
Columns are identified based on their heading. #
Thus column headings may not be modified or removed.
Thus column headings may not be modified or removed.<br />
The separator character (comma, semicolon, tabulator, ...) is #
detected automatically during import.<br />
Imported files are expected to use the character encoding, that #
is configured for CSV-export.
<dt .deflist__dt>Edits
<dd .deflist__dd>
Some cells can be changed when importing.<br />
Cells that cannot be changed are ignored, if they were changed.
For most cells, importing changes the current state of the #
database to reflect the content of the imported cell (i.e. exam #
results).<br />
For some cells this is not possible (i.e. the maximum number of #
points of an exam part). #
In that case all differences between the current state of the #
database and the content of the imported cell are ignored.
<dt .deflist__dt>Preview
<dd .deflist__dd>
Before any edits are applied a preview is shown of what would be done.<br />

View File

@ -0,0 +1,18 @@
$newline never
\<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<style>
h1 {
font-size: 1.25em;
font-variant: small-caps;
font-weight: normal;
}
<body>
<h1>
_{MsgMailExamOfficeExternalExamResultsIntro coursen termDesc externalExamExamName}
<p>
<a href=@{EExamR tid ssh coursen examn EEGradesR}>
#{externalExamExamName}
^{editNotifications}

View File

@ -0,0 +1,17 @@
$newline never
<div .scrolltable .scrolltable--bordered>
<table .table .table--striped .table--hover>
<thead>
<tr .table__row .table__row--head>
<th .table__th>_{MsgTerm}
$forall school <- schools
<th .table__th>#{unSchoolKey school}
<tbody>
$forall term <- terms
<tr .table__row>
<th .table__th>_{ShortTermIdentifier (unTermKey term)}
$forall school <- schools
<td .table__td>
$if Set.member (school, term) schoolTerms
<a href=@{ParticipantsR term school}>
#{iconFileCSV}

View File

@ -1,7 +1,10 @@
$newline never
<div .breadcrumbs__container>
<ul .breadcrumbs__list.list--inline>
$forall bc <- parents
$forall (bcRoute, bcTitle, hasAccess) <- parents
<li .breadcrumbs__item>
<a .breadcrumbs__link href="@{fst bc}">#{snd bc}
$if hasAccess
<a .breadcrumbs__link href="@{bcRoute}">#{bcTitle}
$else
<span .breadcrumbs__link>#{bcTitle}
<li .breadcrumbs__last-item>#{title}

View File

@ -0,0 +1,63 @@
.breadcrumbs__container {
position: relative;
color: var(--color-lightwhite);
padding: 4px 13px;
background-color: var(--color-dark);
line-height: 30px;
}
@media (min-width: 426px) {
.breadcrumbs__container {
padding: 7px 20px;
}
}
@media (min-width: 769px) {
.breadcrumbs__container {
padding: 7px 40px;
}
}
a.breadcrumbs__link {
color: var(--color-lightwhite);
&:hover {
color: var(--color-white);
}
}
.breadcrumbs__item {
padding-right: 14px;
position: relative;
line-height: 28px;
opacity: 0.8;
z-index: 1;
margin-right: 10px;
&:hover {
opacity: 1;
}
&::after {
content: '';
position: absolute;
top: 11px;
right: 0;
width: 7px;
height: 7px;
border-style: solid;
border-width: 0;
border-bottom-width: 1px;
border-right-width: 1px;
border-color: var(--color-white);
transform: rotate(-45deg);
z-index: 10;
opacity: 1;
}
}
.breadcrumbs__last-item {
line-height: 28px;
vertical-align: bottom;
font-weight: 600;
}

View File

@ -70,6 +70,10 @@ instance Arbitrary ExamOfficeR where
arbitrary = genericArbitrary
shrink = genericShrink
instance Arbitrary EExamR where
arbitrary = genericArbitrary
shrink = genericShrink
instance Arbitrary CourseNewsR where
arbitrary = genericArbitrary
shrink = genericShrink

View File

@ -2,9 +2,10 @@ const webpack = require('webpack');
const path = require('path');
const tmp = require('tmp');
tmp.setGracefulCleanup();
const fs = require('fs');
const fs = require('fs-extra');
const glob = require('glob');
const { execSync } = require('child_process');
const request = require('request-promise');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
@ -17,244 +18,316 @@ const HashOutput = require('webpack-plugin-hash-output');
const postcssPresetEnv = require('postcss-preset-env');
const RemovePlugin = require('remove-files-webpack-plugin');
const RealFaviconPlugin = require('real-favicon-webpack-plugin');
const crypto = require('crypto');
const webpackVersion = require('webpack/package.json').version.split('.').slice(0, 2).join('.');
const packageVersion = require('./package.json').version;
module.exports = {
module: {
rules: [
{
loader: 'babel-loader',
async function webpackConfig() {
let faviconApiVersion = undefined;
options: {
plugins: ['syntax-dynamic-import'],
try {
const faviconApiChangelog = await request({
method: 'GET',
uri: 'https://realfavicongenerator.net/api/versions',
headers: {
'Accept': '*/*'
},
json: true
});
faviconApiVersion = faviconApiChangelog.filter(vObj => vObj.relevance.automated_update).slice(-1)[0].version;
} catch(e) {
console.error(e);
}
return {
module: {
rules: [
{
loader: 'babel-loader',
presets: [
[
'@babel/preset-env',
{
modules: false,
targets: {
edge: "17",
firefox: "50",
chrome: "60",
safari: "11.1",
ie: "11",
},
useBuiltIns: "usage",
corejs: 3
}
options: {
plugins: ['syntax-dynamic-import'],
presets: [
[
'@babel/preset-env',
{
modules: false,
targets: {
edge: "17",
firefox: "50",
chrome: "60",
safari: "11.1",
ie: "11",
},
useBuiltIns: "usage",
corejs: 3
}
]
]
]
},
test: /\.js$/i,
exclude: /node_modules/,
},
{
test: /\.css$/i,
use: [ MiniCssExtractPlugin.loader,
{ loader: 'css-loader', options: { sourceMap: true }},
{ loader: 'postcss-loader', options: {
sourceMap: true,
plugins: () => [ postcssPresetEnv ]
}},
{ loader: 'resolve-url-loader', options: { sourceMap: true }}
]
},
{
test: /\.s(c|a)ss$/i,
use: [ MiniCssExtractPlugin.loader,
{ loader: 'css-loader', options: { sourceMap: true }},
{ loader: 'postcss-loader', options: {
sourceMap: true,
plugins: () => [ postcssPresetEnv ]
}},
{ loader: 'resolve-url-loader', options: { sourceMap: true }},
{ loader: 'sass-loader', options: { implementation: require('sass'), sourceMap: true }}
]
},
{
test: /\.(woff(2)?|ttf|eot|svg)(\?.*)?$/i,
use: [
{
loader: 'file-loader',
options: {
name: '[contenthash].[ext]',
esModule: false
}
}
]
}
]
},
entry: {
main: [ path.resolve(__dirname, 'frontend/src', 'polyfill.js'),
path.resolve(__dirname, 'frontend/src', 'main.js')
]
},
plugins: [
new HashOutput({
validateOutput: true,
validateOutputRegex: /static\/wp-[^\/]\//
}),
new MiniCssExtractPlugin({
// Options similar to the same options in webpackOptions.output
// all options are optional
filename: '[chunkhash].css',
chunkFilename: '[chunkhash].css',
ignoreOrder: false, // Enable to remove warnings about conflicting order
}),
new webpack.NamedChunksPlugin((chunk) => {
if (chunk.name) {
return chunk.name;
}
let modules = chunk.modules || [chunk.entryModule];
return modules.map(m => path.relative(m.context, m.request)).join("_");
}),
new webpack.NamedModulesPlugin(),
new ManifestPlugin({
fileName: path.resolve(__dirname, 'config', 'webpack.yml'),
publicPath: `wp-${webpackVersion}/`,
generate: (seed, files, entrypoints) => Object.keys(entrypoints).reduce((acc, fs) => ({...acc, [fs]: files.filter(file => entrypoints[fs].filter(basename => !(/\.map$/.test(basename))).some(basename => file.path.endsWith(basename))).filter(file => file.isInitial).map(file => file.path)}), {}),
serialize: yaml.safeDump
}),
new CleanWebpackPlugin({
cleanOnceBeforeBuildPatterns: [ path.resolve(__dirname, 'static'),
path.resolve(__dirname, 'well-known'),
]
}),
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
new CopyPlugin([
{ from: 'assets/lmu/sigillum.svg', to: path.resolve(__dirname, 'static', 'img/lmu/sigillum.svg') },
]),
new webpack.DefinePlugin({
VERSION: JSON.stringify(packageVersion)
}),
...(() => {
const faviconJson = require('./config/favicon.json');
const langs = new Set();
function findLangs(json) {
if (json && json._i18n) {
Object.keys(json).forEach(key => {
if (key !== '_i18n') {
langs.add(key);
}
})
} else if (Array.isArray(json)) {
json.forEach(elem => findLangs(elem));
} else if (typeof json === 'object') {
Object.keys(json).forEach(key => findLangs(json[key]));
}
}
findLangs(faviconJson);
function selectLang(lang, json) {
if (json && json._i18n) {
return json[lang];
} else if (Array.isArray(json)) {
return json.map(elem => selectLang(lang, elem));
} else if (typeof json === 'object') {
return Object.fromEntries(Object.entries(json).map(([k, v]) => [k, selectLang(lang, v)]));
} else {
return json;
}
}
const langJsons = {};
Array.from(langs).forEach(lang => {
langJsons[lang] = selectLang(lang, faviconJson);
});
return Array.from(langs).map(lang => {
const tmpobj = tmp.fileSync({ dir: ".", postfix: ".json" });
fs.writeSync(tmpobj.fd, JSON.stringify(langJsons[lang]));
fs.close(tmpobj.fd);
return [
new RealFaviconPlugin({
faviconJson: `./${tmpobj.name}`,
outputPath: path.resolve(__dirname, 'well-known', lang),
inject: false
}),
new CopyPlugin([
{ from: 'config/robots.txt', to: path.resolve(__dirname, 'well-known', lang, 'robots.txt') },
])
];
}).flat(1);
})(),
{ apply: compiler => compiler.hooks.afterEmit.tap('AfterEmitPlugin', compilation => {
const imgFiles = glob.sync(path.resolve(__dirname, 'well-known', '**', '*.@(png)'));
const imgFilesArgs = Array.from(imgFiles).join(" ");
execSync(`exiftool -overwrite_original -all= ${imgFilesArgs}`, { stdio: 'inherit' });
})
}
],
output: {
chunkFilename: '[chunkhash].js',
filename: '[chunkhash].js',
path: path.resolve(__dirname, 'static', `wp-${webpackVersion}`),
publicPath: `/static/res/wp-${webpackVersion}/`,
hashFunction: 'shake256',
hashDigestLength: 36
},
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
cache: true,
parallel: true,
sourceMap: true
}),
new OptimizeCSSAssetsPlugin({
cssProcessorOptions: {
map: {
inline: false
}
}
})
],
runtimeChunk: 'single',
splitChunks: {
chunks: 'all',
maxInitialRequests: Infinity,
maxAsyncRequests: Infinity,
minSize: 0,
minChunks: 1,
cacheGroups: {
vendor: {
test(module, chunk) {
return module.context.match(/[\\/]node_modules[\\/]/);
},
name(module, chunks, cacheGroupKey) {
const moduleFileName = module.identifier().split('/').reduceRight(item => item);
const allChunksNames = chunks.map((item) => item.name).join('~');
const packageName = module.context.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/)[1];
return `${cacheGroupKey}-${packageName}-${allChunksNames}-${moduleFileName}`;
},
priority: -10
test: /\.js$/i,
exclude: /node_modules/,
},
default: {
priority: -20,
minChunks: 1
{
test: /\.css$/i,
use: [ MiniCssExtractPlugin.loader,
{ loader: 'css-loader', options: { sourceMap: true }},
{ loader: 'postcss-loader', options: {
sourceMap: true,
plugins: () => [ postcssPresetEnv ]
}},
{ loader: 'resolve-url-loader', options: { sourceMap: true }}
]
},
{
test: /\.s(c|a)ss$/i,
use: [ MiniCssExtractPlugin.loader,
{ loader: 'css-loader', options: { sourceMap: true }},
{ loader: 'postcss-loader', options: {
sourceMap: true,
plugins: () => [ postcssPresetEnv ]
}},
{ loader: 'resolve-url-loader', options: { sourceMap: true }},
{ loader: 'sass-loader', options: { implementation: require('sass'), sourceMap: true }}
]
},
{
test: /\.(woff(2)?|ttf|eot|svg)(\?.*)?$/i,
use: [
{
loader: 'file-loader',
options: {
name: '[contenthash].[ext]',
esModule: false
}
}
]
}
}
]
},
moduleIds: 'hashed'
},
mode: 'production',
entry: {
main: [ path.resolve(__dirname, 'frontend/src', 'polyfill.js'),
path.resolve(__dirname, 'frontend/src', 'main.js')
]
},
recordsPath: path.join(__dirname, 'records.json'),
plugins: [
new HashOutput({
validateOutput: true,
validateOutputRegex: /static\/wp-[^\/]\//
}),
new MiniCssExtractPlugin({
// Options similar to the same options in webpackOptions.output
// all options are optional
filename: '[chunkhash].css',
chunkFilename: '[chunkhash].css',
ignoreOrder: false, // Enable to remove warnings about conflicting order
}),
new webpack.NamedChunksPlugin((chunk) => {
if (chunk.name) {
return chunk.name;
}
let modules = chunk.modules || [chunk.entryModule];
return modules.map(m => path.relative(m.context, m.request)).join("_");
}),
new webpack.NamedModulesPlugin(),
new ManifestPlugin({
fileName: path.resolve(__dirname, 'config', 'webpack.yml'),
publicPath: `wp-${webpackVersion}/`,
generate: (seed, files, entrypoints) => Object.keys(entrypoints).reduce((acc, fs) => ({...acc, [fs]: files.filter(file => entrypoints[fs].filter(basename => !(/\.map$/.test(basename))).some(basename => file.path.endsWith(basename))).filter(file => file.isInitial).map(file => file.path)}), {}),
serialize: yaml.safeDump
}),
new CleanWebpackPlugin({
cleanOnceBeforeBuildPatterns: [ path.resolve(__dirname, 'static'),
path.resolve(__dirname, 'well-known'),
]
}),
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
new CopyPlugin([
{ from: 'assets/lmu/sigillum.svg', to: path.resolve(__dirname, 'static', 'img/lmu/sigillum.svg') },
]),
new webpack.DefinePlugin({
VERSION: JSON.stringify(packageVersion)
}),
...(() => {
const faviconJson = require('./config/favicon.json');
const langs = new Set();
function findLangs(json) {
if (json && json._i18n) {
Object.keys(json).forEach(key => {
if (key !== '_i18n') {
langs.add(key);
}
})
} else if (Array.isArray(json)) {
json.forEach(elem => findLangs(elem));
} else if (typeof json === 'object') {
Object.keys(json).forEach(key => findLangs(json[key]));
}
}
findLangs(faviconJson);
performance: {
assetFilter: (assetFilename) => !(/\.(map|svg|ttf|eot)$/.test(assetFilename))
},
function selectLang(lang, json) {
if (json && json._i18n) {
return json[lang];
} else if (Array.isArray(json)) {
return json.map(elem => selectLang(lang, elem));
} else if (typeof json === 'object') {
return Object.fromEntries(Object.entries(json).map(([k, v]) => [k, selectLang(lang, v)]));
} else {
return json;
}
}
devtool: 'source-map'
};
const langJsons = {};
Array.from(langs).forEach(lang => {
langJsons[lang] = selectLang(lang, faviconJson);
});
const cacheHash = crypto.createHash('sha256');
cacheHash.update(JSON.stringify(langJsons));
const cacheFiles = new Set([
...(Array.from(langs).map(lang => path.resolve(__dirname, langJsons[lang].masterPicture))),
path.resolve(__dirname, 'config/robots.txt')
]);
for (const cacheFile of cacheFiles) {
cacheHash.update(fs.readFileSync(cacheFile));
}
const cacheDigest = cacheHash.copy().digest('hex');
let cachedVersion = undefined;
const versionFile = path.resolve(__dirname, '.well-known-cache', `${cacheDigest}.version`);
try {
if (fs.existsSync(versionFile)) {
cachedVersion = fs.readFileSync(versionFile, 'utf8');
}
} catch (e) {
console.error(e);
}
if (faviconApiVersion) {
cacheHash.update(faviconApiVersion);
}
const versionDigest = cacheHash.digest('hex');
return Array.from(langs).map(lang => {
const faviconConfig = { versioning: { param_name: 'v', param_value: versionDigest.substr(0,10) }, ...langJsons[lang] };
const cacheDirectory = path.resolve(__dirname, '.well-known-cache', `${cacheDigest}-${lang}`);
if (fs.existsSync(cacheDirectory) && (!faviconApiVersion || faviconApiVersion === cachedVersion)) {
console.log(`Using cached well-known from ${cacheDirectory} for ${lang}`);
return [
new CopyPlugin([
{ from: cacheDirectory, to: path.resolve(__dirname, 'well-known', lang) }
])
];
} else {
const tmpobj = tmp.fileSync({ dir: ".", postfix: ".json" });
fs.writeSync(tmpobj.fd, JSON.stringify(faviconConfig));
fs.close(tmpobj.fd);
return [
new RealFaviconPlugin({
faviconJson: `./${tmpobj.name}`,
outputPath: path.resolve(__dirname, 'well-known', lang),
inject: false
}),
new CopyPlugin([
{ from: 'config/robots.txt', to: path.resolve(__dirname, 'well-known', lang, 'robots.txt') },
]),
{ apply: compiler => compiler.hooks.afterEmit.tap('AfterEmitPlugin', compilation => {
const imgFiles = glob.sync(path.resolve(__dirname, 'well-known', lang, '*.@(png)'));
const imgFilesArgs = Array.from(imgFiles).join(" ");
execSync(`exiftool -overwrite_original -all= ${imgFilesArgs}`, { stdio: 'inherit' });
})
},
{ apply: compiler => compiler.hooks.afterEmit.tap('AfterEmitPlugin', compilation => {
fs.ensureDirSync(__dirname, '.well-known-cache');
fs.copySync(path.resolve(__dirname, 'well-known', lang), cacheDirectory);
if (!fs.existsSync(versionFile)) {
fs.writeFileSync(versionFile, faviconApiVersion, { encoding: 'utf8' });
}
})
}
];
}
}).flat(1);
})()
],
output: {
chunkFilename: '[chunkhash].js',
filename: '[chunkhash].js',
path: path.resolve(__dirname, 'static', `wp-${webpackVersion}`),
publicPath: `/static/res/wp-${webpackVersion}/`,
hashFunction: 'shake256',
hashDigestLength: 36
},
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
cache: true,
parallel: true,
sourceMap: true
}),
new OptimizeCSSAssetsPlugin({
cssProcessorOptions: {
map: {
inline: false
}
}
})
],
runtimeChunk: 'single',
splitChunks: {
chunks: 'all',
maxInitialRequests: Infinity,
maxAsyncRequests: Infinity,
minSize: 0,
minChunks: 1,
cacheGroups: {
vendor: {
test(module, chunk) {
return module.context.match(/[\\/]node_modules[\\/]/);
},
name(module, chunks, cacheGroupKey) {
const moduleFileName = module.identifier().split('/').reduceRight(item => item);
const allChunksNames = chunks.map((item) => item.name).join('~');
const packageName = module.context.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/)[1];
return `${cacheGroupKey}-${packageName}-${allChunksNames}-${moduleFileName}`;
},
priority: -10
},
default: {
priority: -20,
minChunks: 1
}
}
},
moduleIds: 'hashed'
},
mode: 'production',
recordsPath: path.join(__dirname, 'records.json'),
performance: {
assetFilter: (assetFilename) => !(/\.(map|svg|ttf|eot)$/.test(assetFilename))
},
devtool: 'source-map'
};
}
module.exports = webpackConfig;