diff --git a/.gitignore b/.gitignore index fdaf213a9..96cae5157 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules/ assets/icons assets/favicons bin/ +assets/fonts/ *.hi *.o *.sqlite3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f9f1deaa..5e06eb05d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,881 +2,6 @@ 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. -## [v27.4.59-test-h0.0.18](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-h0.0.17...v27.4.59-test-h0.0.18) (2025-02-19) - -## [v27.4.59-test-h0.0.17](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-g0.0.17...v27.4.59-test-h0.0.17) (2025-02-19) - -## [v27.4.59-test-g0.0.17](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-f0.0.17...v27.4.59-test-g0.0.17) (2025-02-18) - -## [v27.4.59-test-f0.0.17](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-g0.0.16...v27.4.59-test-f0.0.17) (2025-02-17) - -## [v27.4.59-test-g0.0.16](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-f0.0.16...v27.4.59-test-g0.0.16) (2025-02-16) - -## [v27.4.59-test-f0.0.16](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-f0.0.15...v27.4.59-test-f0.0.16) (2025-02-16) - -## [v27.4.59-test-f0.0.15](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-e0.0.15...v27.4.59-test-f0.0.15) (2025-02-15) - -## [v27.4.59-test-e0.0.15](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-f0.0.14...v27.4.59-test-e0.0.15) (2025-02-14) - -## [v27.4.59-test-f0.0.14](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-e0.0.14...v27.4.59-test-f0.0.14) (2025-02-14) - -## [v27.4.59-test-e0.0.14](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-e0.0.13...v27.4.59-test-e0.0.14) (2025-02-13) - -## [v27.4.59-test-e0.0.13](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-e0.0.12...v27.4.59-test-e0.0.13) (2025-02-12) - -## [v27.4.59-test-e0.0.12](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-d0.0.12...v27.4.59-test-e0.0.12) (2025-02-12) - -## [v27.4.59-test-d0.0.12](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-d0.0.11...v27.4.59-test-d0.0.12) (2025-02-11) - -## [v27.4.59-test-d0.0.11](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-c0.0.11...v27.4.59-test-d0.0.11) (2025-02-11) - -## [v27.4.59-test-c0.0.11](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-b0.0.11...v27.4.59-test-c0.0.11) (2025-02-11) - -## [v27.4.59-test-b0.0.11](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-c0.0.10...v27.4.59-test-b0.0.11) (2025-02-11) - -## [v27.4.59-test-c0.0.10](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-b0.0.10...v27.4.59-test-c0.0.10) (2025-02-11) - -## [v27.4.59-test-b0.0.10](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-a0.0.10...v27.4.59-test-b0.0.10) (2025-02-11) - -## [v27.4.59-test-a0.0.10](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-a0.0.9...v27.4.59-test-a0.0.10) (2025-02-11) - -## [v27.4.59-test-a0.0.9](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-a0.0.8...v27.4.59-test-a0.0.9) (2025-02-10) - -## [v27.4.59-test-a0.0.8](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-a0.0.7...v27.4.59-test-a0.0.8) (2025-02-10) - -## [v27.4.59-test-a0.0.7](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-a0.0.6...v27.4.59-test-a0.0.7) (2025-02-10) - -## [v27.4.59-test-a0.0.6](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-a0.0.5...v27.4.59-test-a0.0.6) (2025-02-08) - -## [v27.4.59-test-a0.0.5](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-a0.0.4...v27.4.59-test-a0.0.5) (2025-02-07) - -## [v27.4.59-test-a0.0.4](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-a0.0.3...v27.4.59-test-a0.0.4) (2025-02-07) - -## [v27.4.59-test-a0.0.3](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-a0.0.2...v27.4.59-test-a0.0.3) (2025-02-06) - -## [v27.4.59-test-a0.0.2](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-a0.0.1...v27.4.59-test-a0.0.2) (2025-02-05) - -## [v27.4.59-test-a0.0.1](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-a0.0.0...v27.4.59-test-a0.0.1) (2025-02-05) - -### Bug Fixes - -* **ghci:** ghci works now as expected ([c3117db](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive/commit/c3117dbdcd1de9ef9f0751afa45018e2ebce2c42)) - -## [v27.4.59-test-a0.0.0](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59...v27.4.59-test-a0.0.0) (2024-10-25) - -### Features - -* **util script:** Util script for renaming of files added. ([caf8fec](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive/commit/caf8fec5acb94df16293bf9aa0cdab766f8829e8)) -* **frontend:** load icons from svg files ([22781e1](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive/commit/22781e1565e890cf6c5b40973146b0334cb667aa)) - -### Bug Fixes - -* **stack.yaml:** move to uniworx.de gitlab ([55484e6](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive/commit/55484e631b786ea3710d322282019baf5292c243)) -* **utils/renamer:** Mehr outputs nur im verbose-Fall. ([ac30cb9](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive/commit/ac30cb9e6712d0ee3f204da4863d1e2509af8a76)) -* **utils:** Verboseparameter -v hinzugefuegt; rekursives makedir; genauere Meldungen. ([1806d9f](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive/commit/1806d9f01fc4a0746d2f9df42ef1ee6827c7fa09)) -* **Dockerfile:** change rights of source dir to env user ([e7a8183](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive/commit/e7a8183656ae419cfee2942543045c6fa6a9caa3)) -* **Makefile:** add missing dependency on well-known for backend-builds ([a09dc59](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive/commit/a09dc59f260843f8815c382576bb5254d21104bf)) -* **frontend:** fixed icon colour in table headers ([4c4571d](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive/commit/4c4571d2d0879e89f2572eba6015d34a7f4794c8)) -* **doc:** minor haddock problems ([d4f8a6c](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive/commit/d4f8a6c77b2a4a4540935f7f0beca0d0605508c8)) - -## [28.1.1](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/t28.1.0...t28.1.1) (2024-04-22) - -## [28.1.0](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/t28.0.10...t28.1.0) (2024-04-18) - - -### Features - -* **middleware:** allow Cross Origin Resource Sharing (CORS) ([e1a25cd](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/e1a25cdd311c3d606404f7f94549875a2a15e2b3)) - - -### Bug Fixes - -* **auth:** use appsettings for azure tenant id; refactor azure lookup url methods ([7a510b3](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/7a510b315d62131e5fb47da8c1398144c57d4587)) - -## [28.0.10](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/t28.0.9...t28.0.10) (2024-04-18) - -## [28.0.9](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/t28.0.8...t28.0.9) (2024-04-08) - -## [28.0.8](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/t28.0.0...t28.0.8) (2024-04-06) - -## [28.0.7](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/d28.0.10...d28.0.7) (2024-03-22) - -## [28.0.6](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/d28.0.7...d28.0.6) (2024-01-13) - -## [28.0.5](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/d28.0.4...d28.0.5) (2024-01-10) - -## [28.0.4](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/d28.0.3...d28.0.4) (2024-01-09) - -## [28.0.3](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/d28.0.2...d28.0.3) (2024-01-07) - -## [28.0.2](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/d28.0.1...d28.0.2) (2024-01-07) - -## [28.0.1](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/d28.0.0...d28.0.1) (2024-01-07) - -## 28.0.0 (2024-01-06) -## [28.0.0](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/t27.4.49...t28.0.0) (2024-03-21) - - -### ⚠ BREAKING CHANGES - -* remove applications and allocations -* **auth:** additional authorisation caching -* **jobs:** Job offloading -* **migration:** ManualMigration -* **html-field:** StoredMarkup -* **course:** AccessPredicates now take continuation -* **workflows:** digests now json encode via base64 - -Also improve efficiency of marking workflow files as referenced -* **files:** files now chunked -* split foundation -* **db:** transactions need to be retryable, now -* **dry-run:** runDBRead -* **course-participants:** CourseParticipantState -* **system-messages:** names of cookies & configuration changed -* **allocations:** influence of grades on allocation priority now -relative when priorities are ordinal -* major version bumps -* markdown based HTML input -* **exams:** ExamResult now contains ExamResultPassedGrade -* major navigation refactor -* **hide-columns:** StorageManager version numbers -* **frontend:** Major frontend refactor -* **sub-study-fields:** superStudyField -* Bumped esqueleto -* yesod >=1.6 -* **exams:** examPartName no longer required -* **exams:** Introduces ExamPartNumbers -* **users:** Remove UserLecturer and UserAdmin -* **courses:** auditing for course registrations and deregistrations, more -tightly couple exam results, exam registration, and course registration (delete -them together now) -* **csv:** CsvColumnsExplained now required -* **exams:** examOccurrenceName -* **exams:** examStart and examPublishOccurrenceAssignments now optional -* **exams:** E.isInfixOf and E.hasInfix -* **standard-version:** Start of new versioning schema - -### Features - -* add user-system-function ([abc37ac](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/abc37aca9c2aa5eafe7eea9333886b43189d5591)) -* **add-users:** add page-action to add users from TUsersR ([9c62c9e](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/9c62c9ee8ca365ceea206c696d3596e46927391c)) -* **add-users:** connect confirmation form with handler ([c013ae9](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/c013ae9efcd7df812b26ae32ce04ba3da4e6aef4)) -* **add-users:** correctly add users and reroute ([fecc752](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/fecc752d6ce3aa952efabdac2ff17064f4091050)) -* **add-users:** more page-actions for convenience ([a882a3c](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/a882a3c0d03ffc57902cbec779621fb58617ed3b)) -* additional exam functions on show page ([214e895](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/214e8951e49bada7081b35cdf4a570eba3890f87)) -* additional general purpose caching tier (memcachedLocal) ([939ab37](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/939ab37588bb71b14b8a9f3ab58d7440f598faf9)) -* admin interface to issue tokens ([738ab7b](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/738ab7b738bf2264d74023aa90fa23461b21ac2c)) -* admin-crontab-r ([460c133](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/460c133aac316fb9317c5f08823e04c22eb63fe9)) -* **admin-crontab:** export as json ([bbd4916](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/bbd4916f3a556ce4c05eb3b2b5268c9c072fdfdd)) -* **admin-test:** download test ([daaeb09](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/daaeb09de82ec5758cba564cb4bc5db7948f5476)) -* **admin-users:** allow adding users ([67f1201](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/67f120120f616377c8b5ab34b63941475195fcac)) -* **admin-workflows:** allow uploading graph spec as file ([48208c9](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/48208c9105ba49feb784ea3143b610ed5b11b517)) -* admins can efficiently generate many tokens for random users ([600bbe5](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/600bbe5d7e9051e4a4eac540b01ff358666ebc9c)) -* **alert-icons:** add custom icons for alerts ([bc67500](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/bc675006d8c72a2994be832236036447ebe1efc1)) -* **alerticons:** allow alerts to have custom icons ([d70a958](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d70a9585f093c0701adf724ffe84cbaf3f1a592d)) -* **alerts js:** support custom icons in Alerts HTTP-Header ([8833cb5](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/8833cb5090738c351b8a47af558dfcb91040cf77)) -* **allocation-list:** show numbers of avail. and applied-to courses ([a3f236c](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/a3f236cb5f174c82924255f438861b8bdb320f8b)) -* **allocations:** add application form(s) ([ef625cd](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/ef625cd901bddc1e770bdae82ffb420b434087c1)) -* **allocations:** add courses to allocations ([14a9a45](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/14a9a4567491253cb220c51a458e029e6d75e00a)) -* **allocations:** add info page for allocations ([689b85a](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/689b85ad0868087d5f5163f9020ba035a557fe82)) -* **allocations:** add registration form ([c5b18fc](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/c5b18fcfcf3b970039d2d72eab6d6f7e646d72ef)) -* **allocations:** additional info and explanation for participants ([38949cf](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/38949cfe0d3957c261b79b448a7fd17c20af1d25)) -* **allocations:** admin-interface registrations ([5e38f03](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/5e38f03a85992964d4c9cf5100c4c8a4a8762aaf)), closes [#677](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/677) -* **allocations:** allocation-course-accept-substitutes ([8abcd65](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/8abcd65edf2a1bf5b6de62103af7427fa7ed7db3)) -* **allocations:** allow additional notifications ([cc20559](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/cc205596ae55826dcefe51ebaef3227ad72bb3d8)) -* **allocations:** allow changing course capacity during allocation ([83e1c94](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/83e1c9418a0461baebd6da8e0d835738d611f188)) -* **allocations:** auxilliaries for allocation-algo ([47bfd8d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/47bfd8d4ea3e5ef9f5270c08c70346cd29aa44aa)) -* **allocations:** compute & accept allocations ([20ef95c](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/20ef95c142e0026b9a8bbe63fb60209b285509c6)) -* **allocations:** create & edit, list & download matching logs ([5320a4f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/5320a4fe98f26576e6b72a1411107f410333009a)) -* **allocations:** create model for allocations ([82e3bf9](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/82e3bf95c49a024f3afdbca4ce4c2744dda3303f)) -* **allocations:** csv-export new-assigned ([a4114a7](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/a4114a79f1bfd968bb9d300f0c39400a8904ee7c)) -* **allocations:** delete allocation-users ([6a1a64a](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/6a1a64a6113fcae3a654e472fabdf5a3f622f549)) -* **allocations:** display new allocations in user table ([bb20062](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/bb20062d9f8fd7169b7dd6f7d14e82d244af83b2)) -* **allocations:** display number of ratings and vetos to admins ([6da8ad3](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/6da8ad348182185f773ab08a10ff59a6a1e89b85)) -* **allocations:** display participant counts to admins ([b79bac7](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/b79bac777c6d349a626ea4efa6c43141b7f669d0)) -* **allocations:** edit allocation-user and their applications ([4daf33a](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/4daf33a1a0c2c621e295aef50aae9e2dc5d5a7e8)) -* **allocations:** explanations & introduce grade-ordinal-proportion ([ee2e504](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/ee2e504ffad276fbb0adc189175d29adb3e31e03)) -* **allocations:** fingerprints & ordinal ratings ([60603cb](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/60603cb6ec43738bdbd98b5a2620366b20bf98bf)) -* **allocations:** highlight app's of users without alloc'-user ([300c378](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/300c3787867622a7fe3580b63cbbfba86a8b21f3)) -* **allocations:** implement application interface ([4dcc82a](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/4dcc82a7709ae4e145a666d170286e3f9f939d41)) -* **allocations:** improve accept ui and logging ([3422fd7](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/3422fd70a73bf005622f4e9b94caa503eb92f553)) -* **allocations:** improve acceptance display ([cf03277](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/cf0327787417e025e21f96f824893f85e6fdad57)) -* **allocations:** improve display ([26f8f39](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/26f8f392a96893bc3c97f1c2212a9c71f0a610f7)) -* **allocations:** include study features in users table ([7f7d2c7](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/7f7d2c795767fd6fac1fa4a10a304e3e3d2280c3)) -* **allocations:** link allocations from home ([c759364](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/c759364ab1d43a4e796cd92a494cafb939dd2568)) -* **allocations:** merge notifications ([9e9e53e](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/9e9e53e76abb471309494e36fc23b5a8ec4a09a8)) -* **allocations:** notification about finished allocation ([9323220](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/93232201f2b62b61ed6f543d84f6373a13bd1ca5)) -* **allocations:** notifications ([6d52ed5](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/6d52ed5c4caba9e164cc30e2affada85c7ddcf7f)) -* **allocations:** notify about new courses ([18921e0](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/18921e06d1deeb41d705eabacc2d348bac76197f)) -* **allocations:** prevent course (de)registrations ([94a1208](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/94a120808dad0bea3b74ecb17f17e7daad5cb3f1)) -* **allocations:** properly save allocation-relevant course-deregs ([7a759b1](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/7a759b192fff62bc8e7608f58f861f4c2e313534)) -* **allocations:** prototype assignment-algorithm ([0fcf48c](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/0fcf48ce666b4828a33592e234ad2265d7f22952)) -* **allocations:** refine model for allocations ([069eb1e](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/069eb1e0b7a1285ae925f95ae1be71befe65fc12)) -* **allocations:** serve archive of all application files by course ([5e393c5](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/5e393c53c6a702a88e053e585c1d38cb5fea15bf)) -* **allocations:** set up routes ([c2df01c](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/c2df01c2f710eef3ec8ba8bd0a745f393169832c)) -* **allocations:** show & export priority ([7462e03](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/7462e03e7073e73d298ee98cb2403c7221c2ea6a)) -* **allocations:** show bounds on assignments due to allocation ([91b249e](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/91b249e58ba4d839bf3c9324548c4f44caa4be7b)) -* **allocations:** show more information ([b7c54df](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/b7c54df9132b3759b3957dec4f09f6250b2f4623)) -* **allocations:** show staff descriptions ([b359468](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/b35946859309fbb526043194c8620c5fc0844809)) -* **allocations:** show table of all allocations ([d621e61](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d621e61b11b1ddb85ba3c2611a24b0c28fe841c2)) -* **allocations:** show table of course applications ([f5da3be](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/f5da3bebba8d5ef6ee9b665c73eba0ea24dd50dd)) -* **allocations:** switch to csprng ([3ea7371](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/3ea7371465194decf072bf038c6d05b4790b6520)) -* **allocations:** table of allocation users ([2735d46](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/2735d465eb6b91b5c6373171f9f93f751be120e9)) -* **allocations:** tooltips listing courses in users table ([6bca64c](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/6bca64cf5f50f5c0ce7f43575930902e46dd1b1d)) -* **allocations:** ui for adding applicants ([7b7f11e](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/7b7f11e72853e11717c671d434397c707eff3b7f)) -* **allocations:** upload of priorities ([a590f45](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/a590f45cc150dfac5d786963dbec351ff53a5b63)) -* allow editing of course applications outside of allocation ([e816a30](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/e816a30b353f6451f48c97cc9a315f9b3aebb3a5)) -* allow examFinished before examEnd ([21bbb92](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/21bbb92d4c5bca8e175d2be97515e14f67ad696b)) -* allow separating user generated content into separate domain ([707b41d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/707b41d4ec9fa92238eaeb4e77f32d8bd8052c46)) -* **applicants:** disclose applicant emails & allow communication ([6711173](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/67111736875f22e636b8abdb48c869ad06909875)) -* **applications-list:** add warning regarding features of study ([cdbe12c](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/cdbe12c7268ee923cf0ed8c5609584a0f5193dc0)) -* **async-table:** history api ([c348b7c](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/c348b7cb035b2c48a8d85e1fe394116bba45f36e)), closes [#426](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/426) -* **async-table:** no submit on locked inputs ([22b3780](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/22b3780efdabdc572d6c5a08b282bb65448bf3f1)) -* **audit:** automatic transaction log truncation ([248482b](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/248482b1bb791023fa7226e303a143996192d9c8)) -* **audit:** introduce id-based format ([f602b79](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/f602b79e7a72c2dced293d5218a4f7bea98c610c)) -* **audit:** take IP from header ([fb027de](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/fb027dee588d709662556874ba22af44af2183bd)) -* **authorisation:** cookie-active-auth-tags ([0d372c6](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/0d372c636a735b4003448ab2518f6354b08ca042)) -* **auth:** record student ldap role ([50455e6](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/50455e68a1e89e16a5905b976a09d265afa08bba)) -* **auth:** user independent authorisation caching ([63f0d3c](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/63f0d3c37ad4a02a5cbdf76398d4a9c74a0a0b59)) -* automatically sync system functions from ldap ([297ff4f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/297ff4f02591339dda7f3270cc9cd332e18febb7)) -* **avs:** add extraction functions for avs datatypes and tests ([f8afca0](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/f8afca0598d03073e4200ba9c7946eec2b509d04)) -* **avs:** add page-action and form handler for registering avs participants ([747d619](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/747d6198c4efdafab009012dc46ed65b02303a38)) -* **avs:** add SetRampDrivingLicence and InfoRampDrivingLicence to AVS interface ([a1272e3](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/a1272e38b72d146b881492341a86e1fc544ab0ff)) -* **avs:** disable certificate validation for avs api ([66dd1a8](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/66dd1a8b70468a51aca0eba82369833acc8dcb3d)) -* **avs:** register course participants for day groups per default ([64d3ceb](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/64d3ceb56d4a7ce09d7760c7452f48e12b182070)) -* better explain behaviour of submittorForm ([b973495](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/b9734953cfb0a63922e52b828c3b47cd32d2834e)) -* **bot-mitigations:** only logged in table sorting ([fb6ae08](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/fb6ae089c63174edc1d84512ea35378ab8cd0e0e)) -* bump changelog ([3bd7520](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/3bd75200874c812afc425d9652318b57336c31fb)) -* bump changelog ([bc674af](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/bc674af936550d518e1d427ca86205a3a1e8c5d5)) -* **caching:** aggressively cache nav items ([b9b0909](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/b9b090992fd83fa4db1c408727d4c8c582b447e4)) -* **caching:** introduce cache prewarming ([8d1f216](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/8d1f216b5b6ee2a59c3fb80f5dd4a701d9dad5ef)) -* **changelog:** bump ([3d1636f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/3d1636ffe8b1b24b5f6e4a679bb1fbcdf9de5fa6)) -* **changelog:** bump ([a684b90](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/a684b90e5ec0545c571b72cab6336dd07f1e42aa)) -* **changelog:** implement changelog like faq ([d9d353f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d9d353fcb7652c46a15016b5d2f400162c8271ef)) -* **changelog:** prettify date formatting ([2b3aef7](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/2b3aef7a490ca2fbabd474069d3406ebb9403e4b)) -* **communication:** send test emails ([d90da85](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d90da85df353da63794eeb95b79b87f485bb6908)) -* **config:** improve configurability of VerpMode ([a7c3fe7](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/a7c3fe76f23f9bd5ba15adcb8a469faf53ad3769)) -* **copyright:** add english translation ([dbb0a57](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/dbb0a5708613d350843247b7ed55a96992db9772)) -* **correction-interface:** wire up ECorrectR ([d8801a3](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d8801a3435088648cc490f71ec08eba91c4a68c3)) -* **correction-interface:** wire up ECorrectR ([df66c9b](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/df66c9b58dd1ea6119d428470d2d089edd70e2d0)) -* **correction:** allow lecturers to set corrector ([f74581c](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/f74581c35648788a39d46c5a72acda4f2c2fa7b9)), closes [#414](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/414) -* **corrections assignment:** add convenience to table header ([56c2fcc](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/56c2fccb84ff71163ccc22291cf42c0cea88b2de)) -* **corrections-grade:** additional column for sheetType ([4cb2d4f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/4cb2d4f07ddc0d873601ae43636a76adfd5a481a)) -* **corrections-grade:** basic filter UI with pseudonyms ([d03fd4b](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d03fd4bee6d645624c30297907c91bf4697aa2f8)) -* **corrections-grade:** sorting by sheetType ([702fb1d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/702fb1dfdb0bc9d9124d824efef56620b6666d33)) -* **corrections-grade:** working additional filters ([c4eb2c0](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/c4eb2c0f04adc6c7193b602b6bfa570c1ba3483b)) -* **corrections-r:** allow csv exporting one line per submittor ([7aadb66](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/7aadb6662bc8db76436f8d41ded7156acb98418e)) -* **corrections-r:** authorship statement state ([51522ef](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/51522efc7c9915115e0d8791320a03e35d2933c8)) -* **corrections-r:** csv export ([2a6248e](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/2a6248e3d5d4f4de5f1c7d6c6bcf092dc9873a2e)), closes [#705](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/705) -* **corrections-r:** filter/sort by pseudonym ([153af8c](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/153af8c6b4042430bb4bc120fa5c24a5d114e4c1)) -* **corrections-r:** json export ([fe8e4bb](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/fe8e4bbd4f6a8b1b1c54808ebc96ee675a078648)) -* **corrections:** added missing titles; small message fixes ([018082e](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/018082ec4a8d1fb90fb5fa874a9b971f6162716b)) -* **corrections:** better highlight corrected files ([46ce477](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/46ce477235fa488986728a7f42ec6a402cd01a98)), closes [#602](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/602) -* **corrections:** non-anonymous download w/ registered groups ([9032f80](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/9032f80f59532a0d26a1d67de50765dbb1c2e0e0)) -* **corrections:** override rating_done & documentation ([bbbfa94](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/bbbfa946e1e2bd3cce6d30cffc672460ec5125bf)), closes [#525](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/525) [#274](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/274) -* **corrections:** submission filter ([38dbfe7](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/38dbfe73b25ef9cce15b4fdd7a529bb2a3abd9c7)) -* **corrector-assignment:** show load/submission percentages ([228cd50](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/228cd507498a74f92978c2c9082d91348e68c564)) -* **course admin:** application restore ([cb4ed8d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/cb4ed8d9887e521f47689c118baf439846cd4514)) -* **course admin:** done ([15689c5](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/15689c597ef407583b01dabc9f7631e9dc90b009)) -* **course admin:** no new-line ([0a6a174](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/0a6a1749d351e626383e513293af280f78552009)) -* course applications study features ([44eeffc](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/44eeffcc70a8b4c119e1a88a9ef01c687fe2e10a)) -* **course enrolement:** show proper icons in alerts ([b2b3895](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/b2b3895aa97d19580987d4b7f845798d6603c44a)) -* **course material:** auto vorschläge für materialtype ([decdda3](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/decdda359d16cce429a7e7a07d4674840e5fe6af)) -* **course material:** first two filters ([90e4a62](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/90e4a620f0c1671ff332db1910c176e58ccbac06)) -* **course material:** materialDescription in progress ([89e9887](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/89e9887fe1112cbc21517e4b501ead33f5a969ba)) -* **course material:** materialdescription search implemented ([3a9622d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/3a9622dfb8474d9f3764f5870197e317a96d9de3)) -* **course material:** merge-request suggestions ([dc5fc3f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/dc5fc3f710363f0644c43866505e32095b41ce92)) -* **course material:** runDB für cid nur einmal ([c09acbb](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/c09acbbf8a7b95176b3d52449b3b9d26e315ccd6)) -* **course material:** small empty-bug fixed ([d8b1f97](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d8b1f9788c74ea5d7dc4f1f45432649d9601106a)) -* **course-applications:** automatic acceptance of direct applicants ([620950d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/620950df83e3dc4d1f0050af4bb207d25883800e)) -* **course-applications:** csv transport ([cf0ec1a](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/cf0ec1aec4267b99cf549b8ae5a0cd1762c45884)) -* **course-comm:** recipient categories for sheets and exams ([2fd060d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/2fd060d55b7f027fa731dcb5b7d706e71d9ba413)) -* **course-communication:** one recipient group per tutorial ([99f23f2](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/99f23f25582429e276f513f58677fe6676657ac3)), closes [#428](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/428) -* **course-edit:** warn about long shorthands ([80cb16a](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/80cb16a40f49564ad98395e4e0d16f405103a9d2)) -* **course-events:** add HideColumns for course events ([1138f9e](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/1138f9e32712eb879f76792a68a870e7a48c4b90)) -* **course-events:** add optional note to course events ([6ad8f2e](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/6ad8f2ee290d5b9166b07ba3be329483a769e097)) -* **course-events:** course event note text -> html ([c8904d1](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/c8904d10b679e77bd7d4adaf80cd37bb3288275f)) -* **course-events:** hide note column if there are no notes to display ([1ac7f4e](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/1ac7f4e8811d9272a10aa28420b4e3fb0c976009)) -* **course-events:** show notes in course events table ([b2c4125](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/b2c4125ca3f76e65aa4eb4390e6d1e7013d1eb0d)) -* **course-list:** filter by allocation ([de39686](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/de39686d89f6ec410bc50eaca058082dc727547d)), closes [#715](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/715) -* course-participant-lists ([88dd5a9](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/88dd5a90b91be365878f9424a3ab304c3ae7c339)) -* **course-participants:** course-deregister-no-show ([bf64eaf](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/bf64eafd0877a686e3129f1da3ac352fda90e5d0)), closes [#499](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/499) -* **course-participants:** csv export exercise sheets ([06f47c5](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/06f47c59b434f1d97afc8afbcb1d47737a85c1d5)) -* **course-participants:** csv export first name/surname separately ([1036926](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/1036926470792bf3409ba3a224886d48b7e1d314)) -* **course-participants:** introduce CourseParticipantState ([d5b65a1](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d5b65a1b06c6fdad2df537e270cba06e967f7ef7)), closes [#499](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/499) [#371](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/371) -* **course-participants:** show exercise sheets (first cornice) ([26cc8e4](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/26cc8e4b53261c64e2f23ab17a8f6ddcbb6fcd63)) -* **course-registration:** allow independent course application ([a00698e](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/a00698e99e915fd9771d236ed5796bdfcad5b5c7)) -* **course-show:** show "not registered" ([96e1a30](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/96e1a30eb64c8f18094ab5f3149bb675e9b8f14c)) -* **course-show:** show allocation name ([3c80235](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/3c8023569bb3d8ce91ab6626b5a93763532a0d10)) -* **course-teaser-css:** removed description label ([a25efb3](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/a25efb3be4743f8667b106d4b00399a33d1ef605)) -* **course-teaser-filter:** filter for lecturers ([e96e17f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/e96e17ff9f6701263e189fac6c6718e0a7ea0971)) -* **course-teaser-filter:** working filters for semester and institute ([3b419b3](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/3b419b336670f94a2f5f024ded4abdb9c7d977fa)) -* **course-teaser:** checkbox field for open registration filter ([e4f150d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/e4f150d0d536c36294abe6df27ba56a7ca5c16a9)) -* **course-teaser:** display sorting "pills" for course teasers ([d964e1f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d964e1f705bb2fd9f9f4a220c6871f829b7b6ca3)) -* **course-teaser:** filter by open registration ([c2c12b9](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/c2c12b9643e81efd5f16e18e4fca4dcbd581db11)) -* **course-teaser:** final version of course-teaser for course list ([66b97d6](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/66b97d6729c4feb94b0be50da43fcd3aa6c8c488)) -* **course-teaser:** hide lecturer entry if empty ([f7fb3c1](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/f7fb3c12198e29b653f93e9d9ef2f6fce23b1b85)) -* **course-teaser:** incomplete course teaser for course list ([9a97925](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/9a9792578d98453ba0928d504ebbffd9ae7ccfda)) -* **course-teaser:** moved course teaser functionality to util ([c99a3c7](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/c99a3c7009fd44f15248b8b71abe07b5ba763c64)) -* **course-teaser:** no display of chevron without description ([5c88c13](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/5c88c13cf835425a7ecbd9a0ed054b1ed5c67a12)) -* **course-teaser:** no page reload on sorting ([68b8d24](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/68b8d2468175509533fc86ab4458268baa30ecb3)) -* **course-teaser:** only true lecturers without assistants ([7926f29](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/7926f29da1565ce27600115d0030246cb1bf4ba0)) -* **course-teaser:** redirecting to course/ ([aa20389](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/aa20389e05bbf683e0b998444f4b52af3e52327a)) -* **course-teaser:** reintroduced courseId and course-teaser.julius ([3b6e700](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/3b6e700531205b8b8a3e9b319c4f7a9c6868bc95)) -* **course-teaser:** show openCourses also to logged in users ([8cca548](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/8cca54897036e5959d8c06497f5a28bbbea888ed)) -* **course-teaser:** unpolished version of course-teaser for course list ([ea5d54b](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/ea5d54b2135820820aa24adab2807cb1bd03b8ec)) -* **course-teaser:** working link to course pages ([8a49979](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/8a49979ecc26a54d3c2aff7056136f920abe13d8)) -* **course-user:** authorisation checks ([d15792c](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d15792cd7da8ce8038472af1afa721d4e99349dc)) -* **course-user:** i18n ([da629a8](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/da629a81d28aeec37d53ef9a21ed959c73dd7e67)) -* **course-user:** major improvements ([ced6ef2](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/ced6ef287451cc59ef32f5454fc23e5cbf0f70eb)), closes [#126](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/126) -* **course-users-table:** json export ([6f291b2](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/6f291b2e6893554193732b059758794fe2b7fa51)) -* **course-users:** allow for exam registration on CUsersR ([b8acc9b](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/b8acc9b5da701bd0d17d9578ee2c9d4e835fc2d7)) -* **course-users:** allow registering tutorial users manually ([d507d9b](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d507d9bbdea035d2d83050e5a0bbf61a73ae3161)) -* **course-users:** exams in dbtable and csv ([c23becc](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/c23becceb1ef994a39204ab3df083f1bbc857c27)) -* **course-users:** filter by exam registrations ([1d7d0ab](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/1d7d0ab55422670d53425061961faa27829fba7c)) -* **course-users:** fuse avs register form with CAddUserR ([4a00907](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/4a00907bdad26208329080ad87fe52daa33775ff)) -* **course-users:** include tutorial in csv-export ([1d5ddd1](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/1d5ddd102ce49c83ab0b45cf2be590f76ca0f0d0)) -* **course-users:** match filter titles with column titles ([ecd7bec](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/ecd7bec9aa36591854ee42e3feba3d8186f872b7)) -* **course-users:** register avs-upserted users ([cba73bf](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/cba73bf2ca6825e9d4d00e51440354aba4cf57f0)) -* **course-users:** register exam action with optional occurrence ([34ad1df](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/34ad1dfae2d265c2dd63325b2c742777a58eb699)) -* **course-users:** set new tutorials to Schulung ([69de448](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/69de44893c9e37a809cde350d404f60a14e5052b)) -* **course-visibility:** account for visibility in routes ([cb0bf15](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/cb0bf151212fd4ecba0eda7b1ef69a640fd6d35b)) -* **course-visibility:** account for visibility on AllocationListR ([4185742](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/4185742f380f7625cac2bbc8df5952157ec0ba63)) -* **course-visibility:** account for visibility on AShowR ([df7a784](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/df7a784a9daebbfb5ca61606cd38352662824131)) -* **course-visibility:** account for visibility on TShowR ([0ff07a5](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/0ff07a5fad5504bff5adfdce278a6256f6bc8711)) -* **course-visibility:** add invisible icon to CShowR title ([6c0adde](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/6c0adde5db117e6ad12167ebbb05a948e5c857c9)) -* **course-visibility:** add visibleFrom,visibleTo ([222d566](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/222d566bdaa84382b24299d6e9179eb2ebb09564)) -* **course-visibility:** allow access for exam correctors ([dfa70ee](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/dfa70ee7fea2020065699e2d4f0608195a1a0228)) -* **course-visibility:** display icon in course list for lecturers ([17dbccf](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/17dbccf2a343cf1571ef0aaf07d6064bf3a2a216)) -* **course-visibility:** error on visibleFrom > visibleTo ([9494019](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/94940196949014d6a99cb183514e855c37575fa5)) -* **course-visibility:** hide invisible courses from favourites + icon ([d86fed7](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d86fed7a32badfe75ef124145e1c59086771c164)) -* **course-visibility:** more precise description on CShowR ([6fbb2ea](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/6fbb2eabf1c90d3ab4e321773506ff8aebbb761d)) -* **course-visibility:** no invisible courses in course list ([24f1289](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/24f12896e084e9180800a0080077d90005801642)) -* **course-visibility:** now as default visibleFrom for new courses ([7bdf8ca](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/7bdf8cac8865743752401ee561355df466407223)) -* **course-visibility:** redirect to NewsR after deregister (WIP!) ([183aa8d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/183aa8d2227091054f8409bd81f40031a8c2066d)) -* **course-visibility:** reorder course form ([7af82bc](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/7af82bcb67d0ec6ae33aa82067b3d1f4de0d74de)) -* **course-visibility:** rework visibility check for ZA courses ([a16eb1a](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/a16eb1ab91747e19f61fd1dfaa5ae7db5800bad0)) -* **course-visibility:** warn on deregister from invisible course ([16ad72d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/16ad72d87638a195afbfbc390f23182dc705f7fd)) -* **course-visibility:** warn on invisibility during registration ([23aca1c](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/23aca1caa43f24b38aa22d3cac3f2289b1cbe8f3)) -* **course:** additional crosslinking ([5eaba78](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/5eaba7830f052fac42a0ce387619a09cefea48f8)) -* **course:** allow csv-export of all features-of-study ([e60f1b2](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/e60f1b2bfcc7c914d202c30e9a5f155d28fae20a)) -* **course:** associate qualifications with courses ([ffaaf9c](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/ffaaf9c86d5caa7eaec2d2bcd06bc6963310a7eb)) -* **course:** csv export of course participants ([9a28dc8](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/9a28dc851cd4d8023ca3638b325b6b9575974a89)) -* **course:** introduce CourseNews ([aa93b75](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/aa93b75e00cb28d3092c0ee4b538e21e543eb7d4)) -* **courses:** add NotificationCourseRegistered ([3750da8](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/3750da81dc27fb104d4ad3765921af2e08915c4d)) -* **courses:** course events ([fa7f771](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/fa7f7712f77a9e801a2c9fa040b1d97d0ae1f8bd)) -* **course:** show direct registration dates ([8f284ac](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/8f284acde8229144f54d63464168b7b82f87c69e)) -* **courses:** rework couse registration ([79d4ae2](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/79d4ae20ee7aaebf9b55005730383fe46ff84e12)) -* **course:** warning if re-registration is not possible ([4451cee](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/4451ceedf7bde0da7f3bb4c0818b79d7c5df1cbd)), closes [#646](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/646) -* **crontab:** cronjob for pruning expired invitations ([a9c5276](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/a9c527621ec17287b119617573e22ca918d20d9d)) -* **csv import:** add explanation text ([6d0a4c1](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/6d0a4c156bd8c792a9a01f05f6a41fe631da5517)) -* **csv-export:** .xlsx ([5c51394](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/5c513946c15ed215f6958be1c7a435f03314f115)) -* **csv-import:** automagically determine csv delimiters ([3555322](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/3555322f2a14ebdaeeab557d7c885e1179f0a90a)) -* **csv:** add column explanations ([c8dca94](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/c8dca945cfac12453bcc74bdfea321d7b4cb3053)) -* **csv:** allow customisation of csv-export-options ([95ceedd](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/95ceeddc83ff79dad6f2dc494015e85f2996c40d)) -* **csv:** don't limit number of exported rows ([e62d7a3](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/e62d7a34e68b79ae52450dcd9e1c5814933d33d1)) -* **csv:** encoding ([81415e1](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/81415e1afb02126a40bb1979ad4c039fd9cccb58)) -* **csv:** export example data & improve zoned-time parsing ([49d9ab9](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/49d9ab9dba70423431e6e68892825c8e29309739)) -* **csv:** finish implementing csv import ([e35fed6](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/e35fed6b85fd5734834e4a4590276c6d3df34f83)) -* **csv:** implement csv import ([996bc2a](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/996bc2ac27bf8fccadcd5d30876dbd3263963cc1)) -* **csv:** introduce csv export ([631bbef](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/631bbef0b8d202e3127de602ad1b5f30b896cd0a)) -* **data-protection:** data protection statement contd ([c3c533f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/c3c533f6a89eeb387abbd4ca17b56c0024c64b5d)) -* **data-prot:** extend info on data saved ([2599e86](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/2599e86a59ccb58d0f18295475fd14fe95fe83f3)) -* **datepicker:** add option to change the position of the datepicker ([85f46ef](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/85f46ef23075104346aba33861f8c170757a3c09)) -* **datepicker:** also parse manual input in internal format ([8a3ac72](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/8a3ac72cbeffd467d3a64832621dae7666005a6f)) -* **datepicker:** close datepicker on click outside ([88a6b85](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/88a6b85a7e83896beefa477b244b44c348a012f6)) -* **datepicker:** close datepicker on escape keydown ([0e5707a](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/0e5707ac9fe69e80240b22c4f727170766cb7bea)) -* **datepicker:** currently broken version using tail.datetime instead ([4282554](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/4282554d82d36c20f4887fdba8396a708dd794e9)) -* **datepicker:** define instance collection singleton ([f5636b8](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/f5636b81d157f1b5df2bc4053aaf0efc3d1ba47a)) -* **datepicker:** display datepicker on the right ([cbb7e95](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/cbb7e952769ef5208103766f4b32217819deed8e)) -* **datepicker:** do not replace value if input is no valid date ([ecab0ac](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/ecab0ac93ca28233457297e285f1a431232f8ace)) -* **datepicker:** format according to input type; position datepicker ([db345ee](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/db345eed55a049b540f30802e77db33705349945)) -* **datepicker:** format any dates before submission ([1eccb0e](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/1eccb0ee4aba99a4ea05c15e6a898e44cc75b207)) -* **datepicker:** format time on submit ([9f8749c](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/9f8749c4cee73b43eb58d34eb18051f3b1753d31)) -* **datepicker:** formatting dates for mass-inputs ([b9fd4d7](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/b9fd4d7d285eb3779cff79de69dd3ebf7481c51a)) -* **datepicker:** helper functions and updated tail.datetime fork ([2512d69](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/2512d69e675c31192c6de96f14d07306e82ff64c)) -* **datepicker:** more sane datetime config ([5a44263](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/5a4426300abe83de223f6180039ced92722d2fb7)) -* **datepicker:** new approach stub for formatting dates in formdata ([9ea7b2e](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/9ea7b2e3f7db4111acf914354968824161ddd86c)) -* **datepicker:** only update datepicker date if date is valid ([d857af3](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d857af3812f3810628c4837a8c7a74e8cd4bd1a9)) -* **datepicker:** switch to tail.datetime fork to fix time selection ([863971f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/863971fbde19a39bd7fc2a16457f03463a41fdb0)) -* **datepicker:** update dependencies ([427ffbf](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/427ffbf0d8acecc71446dd0250b2f6fc3ff20da3)) -* **db:** automatic retry of database transactions upon system error ([e7a5162](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/e7a5162ec9560a20eaffdc24f143ab765f3f0238)) -* **db:** optionally disable some db connection pooling ([35ac503](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/35ac503bf971ace21c49646aa15e8b94b7a3e823)) -* **db:** provide our own implementation of connection pooling ([50fdcb4](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/50fdcb4540e6bfbc8da9ed10ed06d6f6ce443cf9)) -* **dbtable:** add support for Cornice ([fdeb251](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/fdeb2514c0faae19604b578358806aeb4677a95e)) -* **dbtable:** extra representations ([2c0fc63](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/2c0fc63be1de02e8acffbc6a9c5ee83b061c5825)) -* **db:** track source of database accesses ([23ff9d9](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/23ff9d9222d7a75b2931827a6cc0335aafe753a1)) -* **default-layout:** save handler ident to main content ([ba846be](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/ba846be5aa99278ca00b64150165ccb825eb9ba0)) -* demand authorship statements ([34b3e6a](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/34b3e6ae21b38a5b8389deade5deeb77b0981ead)) -* **development:** add commitlint to ensure proper commit msgs ([dd528c1](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/dd528c13aa28ecf882ee311aa2a22389b24c9585)) -* **development:** add standard-version for automatic changelog generation ([c495ef5](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/c495ef577231ac0db6b8c467cf4b0a69c3cf961c)) -* **docker:** wrap within tini ([c86f36b](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/c86f36b2f67a014352b043397c32b6d8ca1642fe)) -* document CourseEvents ([db224cf](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/db224cf58e6025c89174bfa1eab09e03fbec2f07)) -* don't redirect monitoring routes & crontab tokens ([3a106d1](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/3a106d1ee5998ddeea852b3b0398c2f330664a63)) -* **dry-run:** implement dry-run ([002775e](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/002775e19296bd75cf016146e594df2f6101948b)) -* **eecorrectr:** add handlers and navigation ([be2eb3c](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/be2eb3c38d6539056456978b37c47c049d1cd683)) -* **eecorrectr:** basic handler structure (WIP) ([de02895](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/de02895ed0ddc7ed119b76fded1d3eaec24448ba)) -* **eecorrectr:** more appropriate error messages ([3b4c7fe](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/3b4c7fed3658cf3abe731fe45f24fe9c18b52b54)) -* **eeusers:** fix form & finish implementation ([7d3e9a3](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/7d3e9a3de359a2775e2a2941cd807dd51dc07f0f)) -* **eeusersr:** audit external exam result delete ([baa3fd8](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/baa3fd82e1642e5527b94cf71bc6e96dc0f81455)) -* **eeusersr:** audit external exam result result and occurrence edits ([ed3f761](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/ed3f761b24f98527ca0b26b09db7a75f1c98142e)) -* **eeusersr:** audit external exam result result edit ([0d54757](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/0d54757d16bebadaeceeb0947df65f15d86ac3e0)) -* **eeusersr:** more on actions, TODO audit ([d4b784a](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d4b784afba450019d34a7c70eff7992dbad3b9bf)) -* **eeusersr:** stubs for new actions ([4d48730](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/4d48730abd7b1c4d4d9faab08734289fc6d6afb8)) -* ensure cached study feature relevance is up to date ([8798f54](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/8798f547a60a7fa7c0849e20e1b0e9d012ac9312)) -* **errors:** redirect errors back to ApprootDefault ([fbf21d7](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/fbf21d7313d7c2795c171b85a621ad2235eb68c9)) -* exam auto-occurrence nudging ([a91fd7f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/a91fd7fd6387e82331d881ec32e830fd59634d9d)) -* **exam users:** course notes ([1e756be](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/1e756be7784101c7be80395efea988785a0b0d1d)) -* **exam-correct:** accept grades besides exam part results ([be187ae](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/be187ae90727eba0966de59a3b6945080515b3db)) -* **exam-correct:** add basic interface stub ([623becf](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/623becf59751f988411cb0fa39654d4fac214d0b)) -* **exam-correct:** add basic interface stub ([cb7c9ac](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/cb7c9ac6dad5e248e9cf0748a871947369cd39af)) -* **exam-correct:** add hasMore to no-op reponse ([e941083](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/e941083a44ae817f8ad301afdcf57ebdb9a1742d)) -* **exam-correct:** add sortable style and date column ([87bda16](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/87bda1607e23fd44ac32279b9f36088554cdf56f)) -* **exam-correct:** add sortable style and date column ([9fa4245](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/9fa4245607a1e31e915c67a3840dad291a9d284b)) -* **exam-correct:** display backend error messages ([6fc0262](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/6fc0262d2dbf9e6a72b0db94c0a46cfb9ffeba15)) -* **exam-correct:** display more info ([ef52f02](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/ef52f02d78d8dfc75c48c1ad65b329d68c3361a5)) -* **exam-correct:** examResult interface, no styling or functionality ([970076e](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/970076e7307d18424837957133e0086aa78bbafb)) -* **exam-correct:** explanation & length restriction ([1bf19a7](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/1bf19a76bd6c03ff23ab417fcfc0e86596dc896f)) -* **exam-correct:** general improvement ([23044b2](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/23044b28dbcfdc82d5f1e934ab18fba034d60e4a)) -* **exam-correct:** hide result grade select ([edacc20](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/edacc2016d6bbd5c25d62f13d8609b41ee5814ac)) -* **exam-correct:** limit number of matching users (BE) ([d4d27f8](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d4d27f8ef63bf5374eab9e3a8e1d1bea6e2e5b3e)) -* **exam-correct:** more on frontend name resolving ([905d445](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/905d445479f846edf724452832e6e9659c07fbbf)) -* **exam-correct:** more on frontend name resolving ([daf9eee](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/daf9eee1d3203da5cd7893431a3358a12e41941a)) -* **exam-correct:** more stub ([cbe6495](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/cbe6495609dd762f86d13638684496a1822b048a)) -* **exam-correct:** more stub ([6727dff](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/6727dff2ef5de6cf3d4c302165c1c04302d41b68)) -* **exam-correct:** overwrite request cells from response ([c8edbb3](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/c8edbb395b1dbcd704864e640bce1354d1774c40)) -* **exam-correct:** persist results and more ([a7cc24b](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/a7cc24be90cb13985d0faf8c8464c921054f0345)) -* **exam-correct:** persist results and more ([53ff629](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/53ff6298e2a5f12b2453dd6d9e2bedec6494ecda)) -* **exam-correct:** postECorrectR stub ([5f9a176](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/5f9a176bc68116757237b499da26bb34e8aae32b)) -* **exam-correct:** postECorrectR stub ([a525cab](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/a525cab356427514c49348929a362a52fd6edc8b)) -* **exam-correct:** request refactor and handling of sent uuids ([f06ca00](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/f06ca00d752ba0034055f8031e17d1f56594e007)) -* **exam-correct:** request refactor and handling of sent uuids ([4a36a01](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/4a36a010f4bc0ea326f263c0ee77132843f03c73)) -* **exam-correct:** resend option on ambiguous entries (TODO refactor) ([512f4d9](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/512f4d907095087688774bc6adf8619cda3b9142)) -* **exam-correct:** resend option on ambiguous entries (TODO refactor) ([e252be2](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/e252be2fef3732f04b333a909860df985b786fd5)) -* **exam-correct:** return user lookup result even for failure ([8e41820](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/8e41820c9dbe812530a6a5844c0f4baa0aa73027)) -* **exam-correct:** server date handling in frontend and refactor ([77e39be](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/77e39be56c68af9bbf0fdce98508e4d10401dd06)) -* **exam-correct:** server date handling in frontend and refactor ([d8a080d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d8a080d74d4d8ebe78c864daedcd0d4b0b6114d4)) -* **exam-correct:** setup basic session storage manager, add util stub ([9cb64f2](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/9cb64f2a8f5c86d5b5ef5ad65fd7f3c9f0ef7a13)) -* **exam-correct:** setup basic session storage manager, add util stub ([9a79156](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/9a791562b6809b54b6d45699566e0f369e14c627)) -* **exam-correct:** single runDB in POST handler; more response handling ([4cb62f8](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/4cb62f8f91505c40364b51339072545173f1a569)) -* **exam-correct:** single runDB in POST handler; more response handling ([6837c44](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/6837c44b7f40bd4544e75a91a70f6722dc3d5ea7)) -* **exam-correct:** status icons (wip) ([3cc6814](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/3cc6814ff5e337d24841fc25797ca92e38c5bf3a)) -* **exam-correct:** status icons (wip) ([eefff9f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/eefff9f719aa44a544f3eea25ba29b987d085365)) -* **exam-correct:** stub ([90359c8](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/90359c83b75e52e369ad6a3df8022ed3cdb50e72)) -* **exam-correct:** stub ([0467194](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/0467194e3df2da569bd2a4bd8c63bc2d23f44c30)) -* **exam-correct:** submit on enter ([10de1a7](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/10de1a7de7e6855c6c84933618aa84d20f7f07b9)) -* **exam-correct:** upsert exam part results (TODO) ([c0f91bc](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/c0f91bccdd48dc0c99e3b8ef133cfedd890866cf)) -* **exam-correct:** upsert exam part results (TODO) ([650598f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/650598fc22cf33d9eafb97a988db672d94cc3a48)) -* **exam-correct:** use examId instead as uw-exam-correct value ([2d9a877](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/2d9a8771efb5bd8d3c22c799ad21bc1f750a4837)) -* **exam-correct:** use examId instead as uw-exam-correct value ([5d7427a](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/5d7427ad463f114648f6d5b958e5ecba519f0abe)) -* **exam-correct:** validate user input stub ([7f04862](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/7f04862a6f0726a7b721d8bbf1cc6c81b2ced5f8)) -* **exam-correct:** validate user input stub ([431d004](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/431d0046658f6f096fb3bf4c11acba0337f10629)) -* **exam-correct:** work on delete ([014036e](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/014036e4e31f17267e0ad2c82faff163f6064d6c)) -* **exam-office:** course/user opt-outs ([484fa1c](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/484fa1cc632b16d21e694426ae6552dc9098a8f1)) -* **exam-office:** exam-office permissions by courseSchool ([5841a7b](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/5841a7b5d2c9ee1b291a003f3d03b41d5e0b5d95)) -* **exam-office:** exams list ([651f0bc](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/651f0bc4d47477f5f60ed1f91b038cfe6c74cf92)) -* **exam-office:** grade export ([72a7f6e](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/72a7f6e8a8edfacc6aa8ae477a8da46f6f88c551)) -* **exam-office:** notifications ([52e1844](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/52e1844d5e5f5c38cdf639f58ba682af5cfc678a)) -* **exam-office:** show exam(Occurrence) end-time ([b638783](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/b638783f1262f8ea7817618f70474a114b182fda)) -* **exam-office:** subscription management for users & fields ([f75cc64](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/f75cc641e22b6f00bc494f85aa64cd365ac19ad5)) -* **exam-office:** user invitations ([123970a](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/123970a7833d4a10474bbdcc4dfa6102795d331d)) -* **exam-users:** allow missing columns in csv import ([e242013](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/e2420130874728ad1e98ee5c590d0a20d5f2da5e)) -* **exam-users:** document part-* family of columns ([fe07a22](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/fe07a226e9d5ee3195067e45d9c41d730218c7a2)) -* **exam-users:** provide better table defaults ([a689d19](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/a689d19bfa314ae7e559232c0c3d23948c211ebd)) -* **exam:** audit exam registrations ([31931e7](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/31931e708e998116d112fb8a29a1434769ff9d1c)) -* **exams:** accept/reset computed results ([72342f1](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/72342f13936f9e4056e3825ba872a3c5a3726e11)) -* **exams:** add extremely rudimentary registration table ([31e6b72](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/31e6b72c463e7f638a2cc2ff6f19b67f2d49db73)) -* **exams:** add warning about multiple automatic distributions ([7fc9fef](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/7fc9fefb0a255dcf627320e89802fa5b6869c542)) -* **exams:** allow assigning exam participants to occurrences ([e1996ac](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/e1996ac2e51d74db09c833b6c57a80fcdcb9f6bf)) -* **exams:** allow forced deregistration ([1b532c4](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/1b532c4e4d2aa90da93a08dd4f1dbaf8626e8077)) -* **exams:** allow mixed ExamGradingMode ([acffe04](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/acffe0435037b10cabdeffebd1cdcafa03b74d0c)) -* **exams:** auth ExamResults by ExamExamOfficeSchools ([29a3e24](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/29a3e24bcf01cd9c893857eda00dcd249e6cbbe2)) -* **exams:** automatic exam occurrence assignment ([e994faf](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/e994fafe28a32022c06c2cce123181525061f24e)) -* **exams:** automatically compute examResults ([ea5a398](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/ea5a398bab2ca0a63af06e167129c2656e887c74)) -* **exam:** save registration timestamp ([78e4369](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/78e4369afb1f76c1a6a30580a2ae35273f495e43)) -* **exams:** basic required optional action for authorship statements ([5cc41ae](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/5cc41aeef94993a24538b2f88af1fb75625036a8)) -* **exams:** better display exam-result-information ([0ebda4d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/0ebda4d38243d54bf2638e4ce7808bbc084d10dd)) -* **exams:** better explain "enlist directly" ([f07eb3d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/f07eb3dcc33fffb823fb490e36576a202328a0f4)) -* **exams:** check exam_discouraged_modes ([f9c50c8](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/f9c50c80f22770f5376396923b8921eaac3e7216)) -* **exams:** convenience for automatic grade calculation ([ec6a8ae](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/ec6a8ae463ca42fd80538da782a864b739f6ba3e)) -* **exams:** CRU (no D) for exams ([67a50c9](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/67a50c9e87d3368aafe7f52a3b81e580713e6c24)) -* **exams:** csv-based grade upload ([932145c](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/932145ccf794cffce396ddb2d85f01e74d1c7c75)) -* **exams:** csv-export exercise data ([2218103](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/2218103cbd6a021fd24629f9215c71dd115f08e4)) -* **exams:** csv-import of ExamPartResults ([29f4e28](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/29f4e2853667db251b48857bcb21d22482534f2f)) -* **exams:** disable and set use-custom field according to school setting ([22dfd33](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/22dfd33aca9b8ad797c2617bbc656cf8276edf38)) -* **exams:** display school default in form ([abd68ac](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/abd68ac0322a34afb62c416b60965e87ee6f10c2)) -* **exams:** do form validation ([bf7b25c](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/bf7b25ca9e9d11df94b91f7483ee339cefd3e0c9)) -* **exams:** exam design & school exam rules ([f7bab3b](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/f7bab3befc4c42cde430699681f8caf8a959ab39)) -* **exams:** exam finish button ([78d0f25](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/78d0f2522db759c2ee465e040939c92b2f9a1891)) -* **exams:** exam registration ([99184ff](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/99184ff05322573a6958f09e30fa0fdcdd3d665b)) -* **exams:** exam sheets ([500000b](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/500000ba0f6f3b3c32cfd7593e5468796660d46b)) -* **exams:** exam staff & additional schools ([94436ee](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/94436ee0e1ce2cbf13a66f9ad81883d7286acb9b)) -* **exams:** filter on occurrence ([cf040ce](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/cf040ce6863488f4708c1c2059f783413b1183d1)) -* **exams:** first do-nothing stub for exam-wide authorship statements ([0392297](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/0392297ddbfccbb9a08e678696a9cedd1098121a)) -* **exams:** Form validation ([6fb1399](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/6fb1399ef448eb1a6b92c652e644d6aaafe11673)) -* **exams:** implement exam registration invitations ([dd90fd0](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/dd90fd04a3ebf43e35837d41ca14424f7568bc2c)) -* **exams:** implement rounding of exambonus ([e97cd56](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/e97cd5616bfadb838d3ba279768f38fb536dd4fc)) -* **exams:** improve handling of exam results everywhere ([0e49bc1](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/0e49bc14e5a66f75f75612a57024dace0303f299)) -* **exams:** improve immediate exam table on home page ([93e718f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/93e718f32366b4f4b6cd083473f15b192aeb642f)) -* **exams:** improve occurrence display ([2b56f26](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/2b56f26c45bb0c17bd2b4ad0a491b912c96e9acb)) -* **exams:** introduce examOccurrenceName ([379a7ed](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/379a7edd12b16ed55d39e99637de647a51fb4267)) -* **exams:** notifications wrt. registration ([ae27ff0](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/ae27ff0bb16e1daab034bdac7bfbf787f3a3a77d)) -* **exams:** optionally close on finish ([4b525ea](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/4b525ea8246706d191fce109d4a9d1f5cc4c22d1)), closes [#652](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/652) -* **exams:** re-introduce ExamBonusManual ([54e94a6](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/54e94a667027548056a12139ac96512fc4609911)) -* **exams:** refine exam form ([014a17a](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/014a17a3be8811586caea5d9f178c5cd318fae29)) -* **exams:** show exam bonus in webinterface ([2b23600](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/2b23600a2287e96e5b482c4e53125a55b64bbb93)) -* **exams:** show exam results ([b8b308d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/b8b308d608a6acb9e70a143157437038de1f92ac)) -* **exams:** show number of registrations to course admins ([ec020c5](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/ec020c5486eb0573fa35a710b8ed0752d0443ea6)) -* **exams:** show occurrenceRule in exam overview ([06673e0](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/06673e00311d01dbc7b8844cf2ab2f049e045cf1)) -* **exams:** show study features of registered users ([04bea76](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/04bea764f4dcafda1a07ca3a98f290433c207bc5)) -* **exam:** start work on automatic exam-occurrence assignment ([282df86](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/282df86bc20b5ec884379c6c81f232abfb4631c3)) -* **exams:** use template authorship statement settings if applicable ([57a259d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/57a259d8a2822ac1c593663e99f6e41163909c91)) -* **exam:** working prototype of automatic occurrence assignment ([f89545f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/f89545f36ec4b0d45a0607b04d4b0d86b5dd1caa)) -* external exam csv export ([553c117](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/553c1176269850048998ef1a0b8f580c0d4dc267)) -* external exam csv import & ldap lookup during csv import ([1d14b6a](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/1d14b6a69cc569e3924523d4270a897c7529281a)) -* external exams in exam office exams table ([3b739f7](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/3b739f751d195063665430fc144169e998c8fa55)) -* **external-exams:** add actions to EEUsers ([2cf4895](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/2cf4895231e889711dc62896937e775b3d85fe79)) -* **external-exams:** auditing ([2b153c1](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/2b153c1863752ddaf7a8f476fc9696448fed17e6)) -* **external-exams:** create new exams ([94bb391](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/94bb3911cb7dc1d12544be5a644ff0b7ec25725a)) -* **external-exams:** display staff & add' schools ([c14d90f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/c14d90fd531a9d391f90df3c27724c0d3219f2ee)) -* **external-exams:** edit existing exams ([1252a5f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/1252a5fc79354df10bd0b8d17953fb306ae5024a)) -* **external-exams:** list ([fa3521d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/fa3521d6dbebe1d07352bec2269b10e5eb3e31d5)) -* **external-exams:** open defaults wrt. external exam schools ([ef1411e](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/ef1411efdb644e300656403c071cfcdef9caf077)), closes [#651](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/651) -* **external-exams:** plan for student grade access ([b7506a0](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/b7506a03b1de498c3231e81c3330994d55dbb54e)) -* **external-exams:** requisite routes ([f25b21a](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/f25b21aa4b9f4be71bf1afc7cdfae58f7f93c689)) -* **failover:** treat alternatives cyclically ([9213b75](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/9213b7554a6da2a40ea0c82ad4601a951dd7ebb4)) -* **faq:** exam-points ([aebc05d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/aebc05d021dc27c6539373b7e30a1f97898bf15c)), closes [#595](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/595) -* **faqs:** i18n ([a1a0fa3](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/a1a0fa3a448c75f83949eb9f3a5f681fcd6e5792)) -* **faqs:** initial ([7b53377](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/7b5337723d6dd300dc18a0881c589f89dea0bdbf)) -* **faqs:** more faqs ([18766ed](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/18766edc7c9757d26a9d83f1b996fdc473f48f8a)) -* **faqs:** more links to faq ([10d44d1](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/10d44d189bb7df852bdbc64466e31f09fe91f619)) -* **favourites:** usability improvements ([fccc2ea](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/fccc2ea212fd5d780510ee6b59191cd1d615c8b4)) -* **fe-heatmap:** add css class heated for heatmap elements ([b09b876](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/b09b876969f43cc0d21af9f9c0057c1285811e3c)), closes [#405](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/405) -* **features-of-study:** record parent & standalone candidates ([2621d36](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/2621d36b7d020e67b66e0371004634decc5208fd)) -* **file-uploads:** maximum file sizes ([9dee134](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/9dee134b11adb7f72ecb66117c4373c08a664979)) -* **files:** avoid initial unnecessary rechunking ([e80f7d7](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/e80f7d7a89e205ce53a70178e0b44d9b0ddf5b97)) -* **files:** buffer uploads to minio ([d9e9179](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d9e9179a52d1c17633b6dedae7d2a263f3612ac2)) -* **files:** chunk prune-unreferenced-files finer ([58c2420](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/58c242045887673f69c368668803574d829cc823)) -* **files:** chunking ([8f608c1](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/8f608c19552ef7bd6ce61af92496b3d5f5bf61b1)) -* **files:** content dependent chunking ([d624a95](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d624a951c54bda86e04d440eba9901d2a65153b9)) -* **files:** further balance file jobs ([1926917](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/1926917dd7463a4ed11b9e7ee64fab6c8167de6f)) -* **files:** monitor missing files ([fb0ae65](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/fb0ae65ac5928443abc01de9b57c69849d6a6b21)) -* **files:** move uploads from buffer to database ([9a2cba5](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/9a2cba5c0acd0db489d5958938efcdbf6d2dcc63)) -* **files:** safer file deletion ([88a9239](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/88a92390d580d618b15081c87dabe51c7c5e0eca)) -* **footer:** add link to source code ([5a88d5c](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/5a88d5c41f39a45147b049985d2f59cf70daddec)) -* **forms:** allow customisation of user-facing datalist values ([412ce98](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/412ce98fa0efc2715a9ba75ba8e95786fef47450)) -* **forms:** improve field labeling & error reporting ([3820b45](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/3820b45b3e87de3d7ba43a11d84666227f42582d)), closes [#588](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/588) -* **forms:** Introduce more convenient form validation ([f8d0b02](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/f8d0b021edcf254c161ff98e1183e9e4bfab0df9)) -* **forms:** show studyFeaturesField in studyFeaturesFieldFor ([b7496f9](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/b7496f994075836949a9f6f5c584fa34a2441d1d)), closes [#451](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/451) -* **foundation:** move stuff out of Foundation ([e27beba](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/e27bebac59e42857b82e567f07576167bafcf8e3)) -* **frontend:** password visibilty toggle ([f0e4547](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/f0e45477fa85a1d82750597cdaf122e41e9c7764)) -* **frontend:** split up util registry ([67e472f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/67e472fa5e09ed2068d477ef12b12adb0ca98c4f)) -* **frontend:** use webpack more extensively ([5d8c2af](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/5d8c2af51d69c5d33c84447b7baabc75e34d930a)) -* generate & include new favicon ([b78c484](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/b78c48465a4f42366c9a8e8ba924b8d5c2315d71)) -* generated columns tooltip ([2c4080d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/2c4080d0e0d7f59829238830a5200116a9d884ec)) -* **generic-file-field:** prevent multiple session files of same name ([98e1141](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/98e1141e602b08d422ae0db1d25b24b35e6e3238)) -* **glossary:** english glossary ([237c586](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/237c5868b708e6f5734eef19c63f1764eb3cbdfb)) -* **glossary:** more de-de-formal ([7daa42d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/7daa42db983c815dbfc65b50c59a10dbc5054bda)) -* **glossary:** most glossary entries in de-de-formal ([ba7c60e](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/ba7c60ec1ce697e65a9a507073f03c540bb20d41)) -* **guess-user:** add option to limit query ([4154a39](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/4154a395f44edb059225caa090bd3ba95a1451c2)) -* **guess-user:** replace guessUser and usages ([ca96518](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/ca96518e0eb348b91964d21680b1e8e8d3600fa3)) -* **guess-user:** variant of guessUser ([58ae9dd](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/58ae9dddbc994f17cc52cc0940d996dedf583ba5)) -* **health:** check for active job workers ([d1abe53](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d1abe530b60939f69289b60216f52eab7e7ba6a4)) -* **health:** timeout all health checks ([33338cd](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/33338cdfe94754759e4aa8cbf5ccd9f9fc939fa6)) -* **help:** attach last error message ([fdd6b1a](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/fdd6b1a194fd2d4a1deb399cd3914e63e167d30a)) -* **hide-columns:** add hider label th attr ([6c05a8f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/6c05a8f09fdd0d1679c3c5b33277829901d2e8c0)) -* **hide-columns:** add hider label th attr ([71e90a1](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/71e90a18178dcab90ee68519b15e44e51ff79a91)) -* **hide-columns:** add hider labels for material list ([ccafd95](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/ccafd955b9b9659d05e34aadd0bd8fdc15ac44d9)) -* **hide-columns:** add hider labels for tutorial list on course page ([3553df2](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/3553df23ca75eab3c324d9b67aa11ce01212c230)) -* **hide-columns:** add hider labels for tutorial list on course page ([03e4ac1](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/03e4ac1ccac94dc815b9341fbd547ee548d5f839)) -* **hide-columns:** add more hider labels ([555c4ae](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/555c4aebebbd050ec00ad0b4369c196502301d7a)) -* **hide-columns:** add more hider labels ([eba58d8](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/eba58d83a04009f712f46fb2a4b123081b8ead84)) -* **hide-columns:** better positioning of hiders ([761c6d3](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/761c6d39a82a57e4bdddd193d25be459b12ca1f7)) -* **hide-columns:** correct storage keys ([610d13a](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/610d13a7292f61e7892bc0f58a666011efbbbe71)) -* **hide-columns:** don't break on dom changes ([c519792](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/c5197928b12861599338a87dda9562ff16782332)) -* **hide-columns:** fadein transformation ([506f94e](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/506f94e5d4afd2e2a955dab654f9a566a4152728)) -* **hide-columns:** first stub of hide-column util with manual styling ([111821d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/111821dcad4d0ffde6dfebbb62fb21b9b76ab9c5)) -* **hide-columns:** get table wrapper ident for storage ident ([d55d3ef](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d55d3ef4847f089970982e9554365f8699f2f9e6)) -* **hide-columns:** hide empty columns per default ([d1232ce](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d1232ce72d45ddcabe14f9770a0f8117d8efe74d)) -* **hide-columns:** more (broken) styling; move hider elements in DOM ([e655bc6](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/e655bc6e700f1b9668633f6beaef7acf8db484f0)) -* **hide-columns:** more styling ([4908702](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/49087027b250a8ce1212d508e1c9028f5122a09e)) -* **hide-columns:** opt-out on select columns ([b03c10f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/b03c10f09810796f43d313dd17b15f5c39c8e5a3)) -* **hide-columns:** refactor and auto-hide empty columns ([047c0a5](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/047c0a5787657be7f810b20599bb1015a99b4e9b)) -* **hide-columns:** set attributes for hide-columns and extra-stuff div ([169a479](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/169a4799b4996c9474ee492e79dcd0a5040af04e)) -* **hide-columns:** styling stub with repositioning ([a9c17d7](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/a9c17d75fe2ec2aef65e4150d8f080797433fe0d)) -* **hide-columns:** support colspan & don't persist autohide ([0798d68](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/0798d6870e7b4dfd0baf23940fe5d87b9c2d444c)) -* **home:** allow users to define exam warning time ([d23e222](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d23e222fd0b2eb45afd28a5cb96cd25c433cf0c2)), closes [#445](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/445) -* **home:** clean up homepage ([a6e2f64](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/a6e2f6491048186415546f5cbbd513b75c123026)) -* **homepage:** add convenience links to term and school ([83445c4](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/83445c4e7707febc569f4bff271f995ece64dcd0)) -* **homepage:** add prime action new course to homepage ([2208368](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/22083685961ca0503cce168c97bba73beaec2ea7)) -* **home:** show immediate exams on home page ([242cff3](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/242cff30600a857efa9b98a44b44cbb73a1b1001)) -* **http-client:** baseUrl and defaultUrl ([693189f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/693189fe8274fea64596d09f158a1121196c35eb)) -* i18n form ([2d95f35](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/2d95f353c1209a4d3528c6aaf53c832bf5429a34)) -* **i18n:** 12h-clock for english locales ([331ba1f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/331ba1fed3ca5e25942c906b10f670e2ed03299b)) -* **i18n:** additional en-eu ([83a458d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/83a458ddf530941663108db705067fe129fb8bcc)) -* **i18n:** basic language switching ([352bdba](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/352bdba1a4377bbd3ecf76b72c6aee6fdd861316)) -* **i18n:** close language select on click anywhere ([97a29ec](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/97a29ec68cb64e4264afc1d6a95e73e608b33748)) -* **i18n:** english imprint ([7b3ed79](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/7b3ed79ddd4c1d7d29c4830f7f5929662e9011aa)) -* **i18n:** english versions of imprint and data-protection ([4ee3ad0](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/4ee3ad01bab9b8d57ae266904a259130a5c039d4)) -* **i18n:** get started on en-eu ([75677dc](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/75677dc171943cbdd4abb872dd813a9bea40e40e)) -* **i18n:** missing message translations; small fixes ([aec4b21](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/aec4b21757c4d38622db30d96faa95b4fcaa72c3)) -* **i18n:** missing translations ([153bb1f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/153bb1f62179e19e34f5c818544b86633a826e0a)) -* **i18n:** more en-eu ([67e40fd](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/67e40fd3e71a90b13ce75be33cbad7c229b1899c)) -* **i18n:** more en-eu ([3058737](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/3058737021a5f9f949d2da7a3ca608eb71f34cc8)) -* **i18n:** more en-eu ([7c8dbc9](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/7c8dbc9dcc0c79bb50f464eb565d1cebba83b4b5)) -* **i18n:** populate frontend datetime locale from backend settings ([498d616](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/498d6168a0f47b0f93bab75d65b87c34670535f1)) -* **i18n:** store language in user account ([f0f9411](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/f0f94112f4d8b9af96c2048a046e43ce7ae7351b)) -* implement in-memory cache for file download ([36debd8](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/36debd865f6e74856c74bd658dc4694140183fed)) -* implement system-exam-office ([42aee66](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/42aee66d1f9c189a6a6b13b1970c61e0299630ae)) -* **implementation:** add paragraph about license (AGPL-3.0-or-later) ([d4b341b](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d4b341ba259e710a142d813ede83c209fe2fe45a)) -* improve logging/metrics wrt. batch jobs ([d21faf4](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d21faf4de0d40a3683ff2a7a3020bc85717f827c)) -* improve navigation ([95ffda2](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/95ffda25b6f1a6e9e8ae0b0be5e58a7260c4f5fa)) -* **info-lecturer:** add english translation; minor fixes in german ([a4fc555](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/a4fc5551f1936a1e8fb5e7c28a2911fbc1ae8803)) -* **info-lecturer:** add expiry time for newFeat ([fa9e6b5](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/fa9e6b587b2143160ad2067923b55592c7db64ea)) -* **info-lecturer:** add inline newU2W icons ([5a49feb](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/5a49febf9c484bb0cf40007fee3197f206b5488e)) -* **info-lecturer:** add newU2W icons on info page ([9f02ef0](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/9f02ef02b3cfa2b4f7ee5ace2cda1a492f76ee5a)) -* **info-lecturer:** minor adjustments ([64b391a](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/64b391a0feb8150c5a213a5ccf7e3eadddeaa714)) -* **info-lecturer:** more bullhorns ([4a5e7d9](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/4a5e7d9e7e1c5cc8f56169401693fea3f4c65a8a)) -* **info-lecturer:** remove "news" section ([cb1e3a6](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/cb1e3a604b305ca45584bc009f4bf0c027613e22)) -* **info:** info seiten überarbeitet ([7459fc3](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/7459fc34bc747fe7af31a4bc87930e1bea03c923)) -* **info:** start glossary ([73b0546](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/73b0546db674914ca24e0f69c0c17a309e88fc1e)) -* **inject-files:** additionally throttle by file count ([3cf0335](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/3cf033560e90ddc104e4056c470459f92b6eb4ae)) -* **invitations:** additional explanation for new users ([bb9c34f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/bb9c34fa4de6efc811e6a336a8e68f924ff37b56)) -* **invitations:** anonymous invitations ([1380d9d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/1380d9d21ea457ad631998c64b63d6aa85b764ce)) -* **invitations:** save expiresAt to DB ([1c2f2b7](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/1c2f2b7221d2bb237f3f1da61ca1cd9cde791506)) -* **jobs:** batch job offloading ([09fb26f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/09fb26f1a892feba32185166223f8f95611ea9ef)) -* **jobs:** move held-up jobs to different workers ([284aae1](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/284aae12135ad97b1cf85b45f1176da6930876ee)) -* **jobs:** queue by jobctl priority ([a27a553](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/a27a553e0a9782eda6023ec0b8b1055757bb511f)) -* **ldap:** automatically synchronise user data from ldap ([b39ba8b](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/b39ba8b268ca705e93398175df55f8cb741c376a)) -* **ldap:** expose active directory errors ([51ed7e0](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/51ed7e0a26a94d2178a4ca10ad7ea36b99076b54)) -* **ldap:** failover ([0e68b6c](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/0e68b6cf5348bbf5baa5014a86be321a7e5e4b49)) -* **ldap:** manually trigger ldap sync ([83afb6f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/83afb6f15fb107b5302958020dcba4018f98ba8d)) -* **lecturer type:** aenderung ([89e1d67](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/89e1d675c3be0fec106e84920184a8c95dfa6346)) -* **lecturer-info:** add planned features icon; update info ([a4068b4](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/a4068b4a82931f1bc40d0edceca53297e0e45120)) -* **lecturer-info:** fix typos, add info (adding tutorial participants) ([5139825](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/5139825aad1a9ab602000e2d870e424da6b84e48)) -* **lecturer-info:** replaced icons with icon-tooltips; edited text ([2ca7085](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/2ca7085ec95c981fd4721a952064b3df8dbccb58)) -* **legal:** fix translations and links ([cdc4053](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/cdc405307613eae1ef30b19e417faf27d6aee4a5)) -* **legal:** move legal info to one single page ([565c6a4](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/565c6a4f3d3faabaee7d52f6bdb701c703aae7a6)) -* **letter:** allow printing of multiple course certificates at once ([768f03f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/768f03f6727f54b7c7aa18ecef8bc67302ee27cd)) -* **link password time:** application restore ([6d536c3](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/6d536c39bd9f3117f18d2e52c93f178aea4a002d)) -* **link password time:** done ([4490e9a](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/4490e9ad20c55153e81a344c7dbf7813cb219108)) -* **link password time:** done ([2321216](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/2321216b0f4f194c7cd8b47eb020819d6aa1f2e5)) -* **link password time:** new time format ([df2a9bc](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/df2a9bc20fe9f958cbee98315b644ec2fcba0630)) -* **link password time:** restore application ([c5c5417](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/c5c541709b5053c08d21bdd753bb99df574c6c5b)) -* **link password time:** restore application ([85006ff](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/85006ff389188b56a8b61943621c190c9a9503b7)) -* **lms:** configurable csv settings for lms direct import and export routes ([6159403](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/6159403b27dab30178645dc37c99d41b4aaf610c)) -* **lms:** enable upload handlers for all upload routes ([a5121f0](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/a5121f0d3e7a77695a6198057afd23f5f86ff174)) -* **lms:** random ident pw generation without db ([21b74a5](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/21b74a5d7ff3c03be466ef911fbb8ed2a1b67f4b)) -* load shedding ([9df0686](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/9df0686086ff7b64d401a2302edd2fe7636db111)) -* **load:** allow creation of submissions without login (w/ token) ([2e826d3](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/2e826d3c4585b0dafe8ec7abbdd63e23f1d5d341)) -* log ldap error messages on invalid-credentials ([0b4fade](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/0b4fadedd2d7ffbb58598d9844e1c7d97cabc447)) -* log sent notifications for analysis ([c5ef6bb](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/c5ef6bb5a5430a5f9b4dd4b2a5635332d1edfed7)), closes [#535](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/535) -* **logging:** additional logging for inject-files ([cbf41b2](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/cbf41b2ea061aa276f455dde1e31464d106cd3d7)) -* **log:** remove container log setting in order to use stdout ([8f460bd](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/8f460bd0a3aba9013f2ee2d20a1465487ecfe629)) -* **lpr:** print center allows filtering by day now ([cac4870](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/cac4870c95f5367536ee48644fea8a526a0da5a3)) -* **mail:** archive all sent mail & better verp ([1666081](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/1666081fea0eec0bf5440a100db0e8cc69be8295)) -* make git revision accessable to nix build ([b37c2e6](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/b37c2e6aec125952c26156ad599f18496d5cea8e)) -* make pagesize changes load async ([6486120](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/64861205361bb6ee25b172528bbf939850ba3efd)) -* markdown help requests ([06f3ac6](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/06f3ac656313dd751f8a758349f6151497dc4ddf)) -* **mass-input:** automatic add before submit ([7540a4f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/7540a4fe5fe0f61d449bc7cc5f5aa7d3da034f55)) -* **massinput:** reduce size of ajax requests ([72838e2](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/72838e2592f159ab79c9f245ac28a4f9bd807e19)) -* **memcached:** introduce general purpose memcached ([e8c2dc5](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/e8c2dc5aaa5baeeffef805752a7639c9e412dc21)) -* **messages:** implement custom parser for message files ([bb877eb](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/bb877eb81396211a801496061ea603b39753829b)) -* **messages:** mkMessageAddition ([ea33d84](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/ea33d844cc4acb2503fc4780c7895299eb9d5ef5)) -* **messages:** rename subs grade ([534c32d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/534c32d9f90fc95a5cf7ff16056ffffeca8cf964)) -* **metrics:** basic collection & export of metrics ([b8f41ef](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/b8f41ef0b36c092f734f9ee901b2f438ea21aab8)) -* **metrics:** measure file i/o ([4801d22](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/4801d22cb360dcd936c57494ff2ff02655431409)) -* **metrics:** monitor job durations ([0da6c49](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/0da6c493924ef544208a3a330bde7714a61fd835)) -* **metrics:** monitor job executor state ([b74bb53](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/b74bb53041073546df1b45b03758b559c98b95c8)) -* **metrics:** observe login attempts ([0c7e56f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/0c7e56f4054593eef757dffba49fdc27f7b060df)) -* **metrics:** report on health checks ([bec4023](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/bec40236dbdedd022b968e83f563df75ab35c959)) -* migrate indexes ([dfe68d5](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/dfe68d5924d37ea4d3fd0df0a8e68871bcd187d5)) -* **migration:** switch from versions to enum ([f2fb7d8](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/f2fb7d8c267fed96f0dbdb237f4984c8996fbce8)) -* **minio:** use separate bucket for temporary files ([1cd79d3](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/1cd79d35e2761d84bb904a77e74d5cacb0b2244c)) -* **monitoring:** observe database connection opening/closing ([d801a2f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d801a2f84ae42862dfc357a58ee47dd6dc39eef8)) -* more date & time formats ([936c366](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/936c3666fcc39e00ef1c868b3f23d4bb88336702)) -* more en-eu translations; minor fixes in de-de-formal ([870f1df](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/870f1df4d30182ef08d4c7b2f8bb15a6ea722925)) -* **multi-user-field:** improve placeholder ([2936eef](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/2936eefbd1626fb69afa735df2d2dae5783ba842)) -* **multi-user-field:** multi-user-invitation-field ([c072b85](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/c072b85299f87f6ddfcdf72ff3db4881a079af41)) -* navbar header containers ([1348c91](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/1348c91c3c7a5c645decda9215c352fa2130aaaa)) -* **navigate-away-prompt:** prompt on actual value change only ([293ab6d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/293ab6dc62725a2b9785b18d7f06ff8efbf860ea)) -* **news:** active allocations for lecturers ([cde0122](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/cde012252907c57506eef44868b3d25fae50a8f7)) -* **news:** show system messages ([0d39924](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/0d399247773a0e4799602c49e6c06de906b43fec)) -* **news:** timeout sheets after a month ([31aa25a](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/31aa25a1fd6acd0994e2af156b4b166b3717de13)) -* notification about externalExamResults to exam-office ([a304840](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/a30484003ae75ccbb98a4d275423320bd9d09f33)) -* **notification triggers:** redesign interface ([84c12b5](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/84c12b5fc7c875940900c2c38cf59b09c0d63fab)), closes [#410](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/410) -* **notifications:** add NotificationExamResult ([a7e2921](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/a7e2921a731d74eb4b79d60e8e1f34aa90161b60)) -* **notifications:** sheet-hint & sheet-solution ([f11b215](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/f11b215773fb2af8cb400d5798029802768a631a)) -* optional ribbon ([c2e13cf](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/c2e13cf4df1fc8a6e0d919b5171a7eaf002fd381)) -* pageactions for exam correct interface ([0d4dcf8](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/0d4dcf8658d7b6d6f3fe692400c772425bbfb3b0)) -* **pageactions:** finish restoration ([e1cac76](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/e1cac76f1518e2de0f9d7e9f0f80278ea07fac0d)) -* **pageactions:** restore pageactions ([4bc48a5](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/4bc48a50faf4fbecff14fd7e38a31503097d74d8)) -* **pageactions:** restore pageactions ([926bd44](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/926bd4473696a7d1524659895975749d7b4b3a79)) -* pandoc-markdown based htmlField ([c5848b2](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/c5848b24e850eb0bfc13db3ff68fd05df522b057)) -* partial support for lsf import ([37cdc77](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/37cdc775b5b2d3e4cd1cc22858b2c05e75de8a3c)), closes [#686](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/686) -* partial/conditional downloads & video streaming ([5b28303](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/5b28303539e28024b43addb413aedc4e5ee0e470)) -* participants intersection ([697c3e1](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/697c3e11fc3bdd279c0df2d4fd9362bf2981ccae)) -* **participants:** basic funktions added ([b96327b](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/b96327b18dafcd020c94bb84c6aafffb53544076)) -* **participants:** corrections ([fd11121](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/fd111215447aff817399db379a4ca8e90eb73cff)) -* **participants:** corrections 2 ([d6ce0c4](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d6ce0c47d92fac76ccdc59805fcdbd3ad932d3e3)) -* **participants:** first finished verson ([0a3fd23](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/0a3fd23e22a81b3636fb3ac224dce52df3f752f2)) -* **participants:** second version, Intersection added ([02354f0](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/02354f0998e61c236bc982848b9d709c927690f5)) -* **participants:** small Name-change ([6f3243d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/6f3243d90bdc137e7f2ea9fe8e271f1cdc32dfbd)) -* **participants:** small Name-change ([eced778](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/eced7781ae346e285b7f3949917f23883b4dfaa8)) -* persist bearer tokens in session ([d8040e7](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d8040e7aa864e2078962ed2c938ae91408dd9e50)) -* **personalised-sheet-files:** collated ignore ([1fe63a2](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/1fe63a23a0b41dba3b87b97903eb58ced87f8b2d)) -* **personalised-sheet-files:** download from CUsersR ([93d0ace](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/93d0ace8ba97d19edba92a65b3f37a793165baab)) -* **personalised-sheet-files:** finish upload functionality ([ed5fb6e](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/ed5fb6e218097250f197b7795d448bbfe460bf99)) -* **personalised-sheet-files:** i18n ([f452b2b](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/f452b2b24f60e3d2cfefee0501d9833e2f7665db)) -* **personalised-sheet-files:** introduce routes & work on crypto ([9ee44aa](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/9ee44aa2f1aaa91c898b216f0dba58017122c75f)) -* **personalised-sheet-files:** participant interaction ([db205f6](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/db205f635d8c80b037f96f34488fde451140eda7)) -* **personalised-sheet-files:** restrict download by exam ([a8f2688](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/a8f268852a256209f6ab187167c2a1c066618c4c)) -* **personalised-sheet-files:** seeds ([cf67945](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/cf679452928c14200e1eb3877987ee299fbf9f6f)) -* pruning of unreferenced files ([ff161b2](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/ff161b2e045dcd1281aec21513ed1c818f2174aa)) -* **qualfications:** renewal actions and filtering by card and personal number ([4df0243](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/4df024374d387fc85a833b3faffe1b6ef8edc7d9)) -* **rating:** pretty-print to new yaml based format ([2bf4846](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/2bf484609e4efb905614ffddb3b8f0dee03f5483)) -* **ratings:** i18n rating file names ([1195231](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/1195231bc3d3502fa4f77db64d30f2138cd7fa20)) -* **ratings:** parsing for new format ([af79473](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/af7947328d38ef6db4450bae177efdae7877ab56)) -* reduce number of study features for courses ([51a98f0](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/51a98f067086bcef3daff601b53d5eb45f4a27f0)) -* refine presentation of exam-correct ([95c1755](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/95c17557107c3ff41ea85cd32d3e5ff435feebb9)) -* rename "Start" to "Beginn" in error messages ([66bd10e](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/66bd10e4142cee5f1683e1252885f1b5a0a07fa6)) -* renamed "Bewertung abgeschlossen ab" to "Ergebnisse sichtbar ab" ([6b610e1](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/6b610e1e54cbca89bd957101723e9c79652d315a)) -* restore & improve navbar contents ([51fc6dc](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/51fc6dc541ea729c049271d3f1d16afffd5ef6c0)) -* restore study features in all tables ([363f7ab](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/363f7abc192872ebd2a609b8bd89b58032bc9131)) -* **robots.txt:** disallow ahrefs ([9afee89](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/9afee89a090030c31b6025eef22b5055a4251c0b)) -* **rooms:** different room types & hidden rooms ([319c75a](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/319c75a85aa5e5f7e2f2af328d69960e1df3cb80)) -* **schools:** add school settings regarding authorship statements ([cb8e338](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/cb8e3385889c0c4c13418bc69af091b9c8a3f22f)) -* **schools:** implement cru ([18ae28a](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/18ae28abbcfcff6015b419f6791bc60fe6dd88f5)) -* **schools:** more school-wide configuration authorship statements ([960bd76](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/960bd76acafc9cd077b831b67a281eb7b20e703c)) -* **schools:** store school authorship statements as html ([09927ae](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/09927ae14004f7a27f816ad874704969641dad83)) -* **serversessions:** move session storage to dedicated memcached ([9960059](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/996005935df86cbbc48bd823cc8cba13aa2f8bca)), closes [#390](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/390) -* **sheetlist:** sort sheet file types in db by haskell Ord ([643cc41](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/643cc4165fdec197ae8f744f193a731e731f537b)) -* **sheets:** add required flag and definition ([541dd76](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/541dd7688ffa36be8a968f26f920507ed5aae646)) -* **sheets:** better explain rating-done ([3944ce0](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/3944ce02615f8f4d3ec6a14ae4beaceb324c6104)) -* **sheets:** display authship req on SShowR ([44473b4](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/44473b45756c5df20e6a81927867de191cf70366)) -* **sheets:** eliminate authship statement required Bool ([0735c05](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/0735c05a7489957ed500bac1c006f4ecfdab74f3)) -* **sheets:** fetch school statement as statement default ([a39a0d7](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/a39a0d7c8763e158dae5750afac8a78bd953dcdf)) -* **sheets:** introduce sheet-specific statements for exam-unrelated sheets and as exam-statement overrides ([3f87f20](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/3f87f20eb14e5db8a63c61885c4570689169ebed)) -* **sheets:** pass-always ([b2ebce4](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/b2ebce483658d74239b7a9dd5462b7c78371b896)) -* **sheets:** require exam registration ([d770afd](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d770afd2c6ade597fa2b8ecf229c312f1ee6be56)) -* **sheets:** submission groups & rework sheet form ([57f1ce9](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/57f1ce9265e2a122aa7a318b923df51ee9ead0a3)) -* **sheets:** upload-empty-ok ([ab1940c](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/ab1940cb09e824fbba03264b5451fa8b17c5c804)) -* **sheet:** warn about no submission without not graded ([9373266](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/937326639a02c576f278b79b8ebb441a2652bece)), closes [#342](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/342) -* show authorship statement requirement for sheet ([5e96982](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/5e969825ad0c84c240b5c17b011dacbb63f4bfdf)) -* **sort-table:** add basic SortTable util stub ([53131e2](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/53131e2de891edc57d900a618aeadc31533f305b)) -* **sort-table:** add basic SortTable util stub ([11c0bd0](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/11c0bd07e93a4a59d78226dbf3a2ca94d02a5883)) -* **sorting tutorial table:** application restore ([9dc12de](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/9dc12de056e73736659c053b0eabef66ca524047)) -* **sorting tutorial table:** done ([482241d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/482241d033c32c52c31ea20920a4fec07ba975dd)) -* **standard-version:** allow adding additional changes to release ([7ed6fe4](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/7ed6fe4fedc2f0ee463680a0d3210d7a6f4ac7ab)) -* **standard-version:** complete release workflow ([605e62f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/605e62f4458238119eaf13b3e8151924d758e3a9)) -* **static pages:** touch ups ([d2c0043](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d2c0043debb0896446cefaef5f488e99470bbf39)) -* **status:** show instance running time ([8743719](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/8743719183abfa10d089c6e7e765e49f6da3c50d)) -* **storage-key:** add breadcrumb and import ([8cf5d63](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/8cf5d63cf2d63d3fa0017d3c555a2070431086f6)) -* **storage-key:** add breadcrumb and import ([1580d3f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/1580d3f59bf4d716228ecc15c8af17b8af445250)) -* **storage-key:** add StorageKeyR to routes; minor Handler refactor ([2d1d58f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/2d1d58f78f3bcdce5030da2eb2e67532f3ccff27)) -* **storage-key:** add StorageKeyR to routes; minor Handler refactor ([4d4dc8f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/4d4dc8f58be9f0c705b52d6bbd42041d41b2749f)) -* **storage-key:** postStorageKeyR ([059efe5](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/059efe5085967366df663ca531f1e4fb521ac9ab)) -* **storage-key:** postStorageKeyR ([b51c466](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/b51c466a650743d9df9e954313e9c7501f66c4f0)) -* **storage-manager:** add en-/decryption stub (WIP) and restructure ([54d852f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/54d852f30823c59146d75d33f05f35f2c66a228f)) -* **storage-manager:** add en-/decryption stub (WIP) and restructure ([0016145](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/001614522e0ae3841b27086e9370c999c24a2963)) -* **storage-manager:** add storage manager library ([1023240](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/10232401369fa18fd7866a584b4f6d3eb1380e5c)) -* **storage-manager:** location hierarchy ([80ff4ac](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/80ff4ac2a734bf295a026aa68d5d881da9cca3bd)) -* **storage-manager:** store encryption info per location ([25a7c34](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/25a7c3420a68ef6a959506db27411dc363c1b839)) -* **storage-manager:** store encryption info per location ([8122ab1](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/8122ab10b0277069ec4a6de7f77dd34748df3fe5)) -* study feature filtering ([96d0ba8](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/96d0ba8f7a1c8d8d4e895541b66e36d35392fb25)) -* **study-features:** add study-features-first-observed ([dcb83d9](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/dcb83d96fc0e52c0c322e50d9467d9a2bed90359)) -* **study-features:** cache study features term relevance ([8f6d54d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/8f6d54d0125e01f5c8a90843b54129d6412b79f1)) -* **study-features:** complete StudyFeatures admin-interface ([c4c82f5](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/c4c82f54396abd6cdb3c719079a6f87a883c4989)) -* **study-features:** further restriction by course ([f7a9bc8](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/f7a9bc831a3b0ef58fcbf7918be9f5e3b262641e)) -* **submission-groups:** invite w/ submission-group & audit ([7f10d44](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/7f10d44aee0fea561e331e20d03ff814a6df9baa)) -* **submission-list:** bulk download submission originals ([d7f2d11](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d7f2d113929f9dc11291d6db916c8944ae158c3b)), closes [#707](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/707) -* **submission-show:** display authorship statements ([cbd6d7d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/cbd6d7d2b098f8e2c921fd7a56a458d62331d784)) -* **submission:** add correction to sub-show-r ([e060080](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/e06008026160185103b9adf60b5cfbc6991d77df)) -* **submission:** allow restriction of submittors via token ([0fa8d37](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/0fa8d370374ae8022faa0ebd7dfcc5a50afa4e4c)) -* **submission:** edit notifications ([98c0d69](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/98c0d6919e0e4ef4fca6eb451edbd8283829ebf6)) -* **submissions:** also warn correctors about multiple submissions ([8795edd](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/8795edd1fa452d012704146481c8318d206634a5)) -* **submissions:** display authorship statements ([7749238](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/7749238e554b612a8bf69e6beb94efe3e5d02973)) -* **submissions:** display submittors more explicitly ([d2e2456](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d2e2456f6204245d933fb6abc87c44388ce3e339)) -* **submissions:** ignore additional filename components ([38f69c3](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/38f69c3aedaf497753a5240d1d64970320f4f64e)) -* **submissions:** improve behaviour of sheet-type-exam-part ([91a5166](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/91a51664c32bd17e4c2d1cd496bf05338146291d)), closes [#676](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/676) -* **submissions:** non-anonymized correction ([fd2c288](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/fd2c2881ea5a465458eb3f64d7767be3a307dc46)), closes [#524](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/524) [#292](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/292) -* **submissions:** optionally disable consideration for deficit ([c6a6ec7](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/c6a6ec721c2a863d324ddfb5d2b2c1e42e659067)) -* **submissions:** warn about multiple submissions for same user ([c19a00d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/c19a00dcefb2dcae017026edb6e1c7cb6ce16841)) -* **submission:** warn about deleting co-submissions ([e87f607](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/e87f6075d379eac84decaf0079cf62d8d5d0698d)) -* support exam registration including room (ExamRoomFifo) ([14bb020](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/14bb020fe9d8f7579269468c9fb55a1dc373d145)) -* support for ldap primary keys ([bbfd182](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/bbfd182ed93d1e602229a2fd1ac1e0fa4c4439ef)) -* **system-messages:** hiding ([c81bc23](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/c81bc2314e75d82ad2a246218b7e077d5cb02781)) -* **system-messages:** manual priority ([cf06f79](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/cf06f798072a5c4cd1a7f6f92c035929f467ff30)) -* **system-messages:** refactor cookies & improve system messages ([ead6015](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/ead6015dfef7e667e52116b26fd92ebfd4f908eb)) -* targets on InfoLecturerR ([5ffee38](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/5ffee38979e239c40390c4c5e4ac8db1532bbede)) -* **terms:** better prediction of term dates ([e5732df](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/e5732df1b62756aa267fbaad598f96478cba0220)) -* **terms:** improve term display/editing ([8b7e8e4](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/8b7e8e4bd541dec10e098485453b6a08c758bb68)), closes [#485](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/485) -* **terms:** time based term activity ([df073ef](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/df073ef7947eb80dc35fe955b92e635881eb50fa)) -* **theses:** additional state explanation ([1e38734](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/1e3873485e3da81b25dd0a0eb5aed7b9e0fe42b2)) -* **tokens:** multiple authorities ([bc47dcf](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/bc47dcf43f08fdc6e9b52dc205fbabea1893259f)) -* **tooltip:** added test warning to admin test page ([885efd3](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/885efd364b5a59365b1594e33c5de8317e9f16fd)) -* **tooltips:** add auto unzip and multiFileField tooltips ([276dcb6](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/276dcb6ad962b4ffca07e58b6231cebfaceb68c6)) -* **tooltips:** add option for inline tooltips ([0b2e931](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/0b2e9319be2bec3546f2af2568b86ce39f114025)) -* **tooltips:** replace tooltips ([3b0e1d5](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/3b0e1d570d4000cf386def168400413fb0992753)) -* **tooltips:** tooltips from messages ([f85ab69](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/f85ab69114f4947cf1b80469d3e75c2848bc8d5a)) -* **tou:** add english translation ([ce8b1a6](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/ce8b1a6c64a8f4f0e64f90367e23d0f85a7f1e8c)) -* **tou:** first stub of german tou ([74caeca](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/74caeca9676ae1c1a00fc22e16b6b47209499d88)) -* **tou:** implement Terms of Use (tou) route ([932cd5c](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/932cd5cfdba309b1c70682c0f1731ac173298432)) -* **tou:** small fix in english translation ([aced70f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/aced70f8346cb9f2d4b7a58624dc865005f35a48)) -* **tou:** small fixes in german version ([246af70](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/246af702d2fc898fb012d448f36b9d509371a6f1)) -* **transaction-log:** more details about submission files ([b9cc5b9](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/b9cc5b9970cc0b1f61c9df03c54901d9d6e822d0)) -* **tutor tabel sorting:** dbt sorting tutors added ([b1787cd](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/b1787cd77e8a643accc0ef54cc18c87df215680c)) -* **tutorial-users:** replace study-fields column with qualifications column ([9850e1d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/9850e1dd88a5371abe67fd5fb69458d7f52ea8e8)) -* **tutorial-users:** table action for granting qualifications ([fa0caba](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/fa0caba55d05f080f5ed98b0b83dde3c6cebe7b7)) -* **tutorials:** delegate control to tutors ([261f3ed](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/261f3ed92f4ae689d0fbaf83a8070b619d3c2444)) -* use c++ library for json parsing from database ([f226751](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/f22675189e19d1cce20362121ef8c8aebe3628f1)) -* use pandoc to convert html emails to markdown (plaintext) ([4879bb8](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/4879bb840482b6b81b0dee58a01f3f33c5c1c725)) -* **user-schools:** allow users to override automatic school assoc' ([7d927fd](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/7d927fdd5fe6e1e4abd315abf4b415c58f99e89b)) -* **user-schools:** automatically assign users to schools ([12067de](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/12067de2ff7e645f324ba266869d4ba1ca0ad064)) -* usergroups & metrics usergroup ([9204565](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/9204565cac7b2c52f5d69ada066824e37ba6ae38)), closes [#538](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/538) -* **users-add:** add error message for users not found in avs ([e273c60](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/e273c60a2325f75033d2393725ce7a25368821bf)) -* **users-add:** redirect to different routes depending on tutorial ([93c6853](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/93c6853b082a5d2195bb55cbf7b792d2f4307254)) -* **users-add:** upsert tutorial participants ([662445e](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/662445e8cc92cf9f5815851d7e9d0b559cef289e)) -* **users:** allow customisation of displayed email address ([2f38278](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/2f38278ab141ac4db7d16a4b6d990c58067b200e)), closes [#459](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/459) -* **users:** allow customisation of userDisplayName ([a85f317](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/a85f317bf2de8c5038b406d6c5601d0ead8e4bd2)), closes [#346](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/346) -* **users:** allow users to set postal address and email encryption password ([655fcf7](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/655fcf756471a2dfc6380e4b63236ca8d5229e11)) -* **users:** assimilation ([ef51c6e](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/ef51c6e7c34effa691125e4313876d95feda96af)) -* **users:** generalise UserLecturer and UserAdmin to UserFunction ([76f8da5](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/76f8da52e0f532ef08df5ad649aa3d2bb24159f5)), closes [#320](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/320) -* **users:** ldap-synchronise arbitrary subsets of users ([0789536](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/07895368ddda85cf8d1ce9838d5cfe5db32c511d)) -* **users:** lecturer invitations ([e6c3be4](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/e6c3be4f7b1694420756ab194f0c607af427cfdd)) -* **users:** sex ([c2a8381](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/c2a83812785e9f8f2ad948a551527df95e24d118)) -* **users:** store first names and titles ([ceed070](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/ceed070e35f8ef4decc0afcfc338abbfdb8a46ac)) -* **users:** switching between AuthModes & password changing ([0d610cc](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/0d610ccf4459ab929d18ab7285dd080b51394ad2)) -* **util-registry:** ensure specific start ordering ([baf8b18](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/baf8b18dc3049559891647ab2ef43a23a982cbdb)), closes [#587](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/587) -* **util-registry:** more debug info for setup util instances ([00584f9](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/00584f95901786ce211f64e1edcfeedab2299c45)) -* **utils:** throwLeftWith to facilitate ldap code ([8417eb5](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/8417eb57c9b8324cee81cbd16ff72f6039757a8e)) -* warnings about multiple terms/schools ([91e1bf9](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/91e1bf99966655b0a8f7ab99d0ddebe5642c627b)) -* well known files ([068632b](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/068632b11753f58e8142608db3dc139d0c84f93f)) -* **workflows:** add missing instances; correct Int64 workaround ([8b32ede](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/8b32edee64509ca5a3d5fc206192d4fa43cc1971)) -* **workflows:** additional work on WorkflowWorkflowWorkflow ([5108e14](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/5108e1494aa8b2bc8b383a349d1d2a4e0249501f)) -* **workflows:** create new workflow definitions ([4d63d30](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/4d63d306347ed452822b6bea101cdf4391363ed1)) -* **workflows:** definition route stubs & i18n ([e3b5b93](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/e3b5b93c71e49203e428382cfabb3d536f290cc4)) -* **workflows:** delete definitions ([bda4f81](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/bda4f81702d94d81427a4980b217be8cae2b9152)) -* **workflows:** edge messages ([c22004e](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/c22004e1b2f3cd85297faaf41d76954c0625e308)) -* **workflows:** enum fields ([426c40f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/426c40f0a4f596804eca723e09894f9c5606af6e)) -* **workflows:** explanation text ([aba6737](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/aba673756e9e40057625c41da8b32d378e9b67c6)) -* **workflows:** further work on WorkflowWorkflowWorkflow ([5b897c7](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/5b897c7a42067d6a7918dc7bc9640b5c3d8a1367)) -* **workflows:** improve linter ([316097a](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/316097a07ed89e40ecbf3dd8a7160eca95bd7a67)) -* **workflows:** initiate ([fd7c91f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/fd7c91f5b8aa2645e0e072115d6a7da58323971a)) -* **workflows:** list & edit definitions ([ff370c6](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/ff370c68c735c492e8e588a8bb8e4055aa8cc0f4)) -* **workflows:** list involved users ([d8878a9](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d8878a905e07f1b5fb5159ecdaf70f27e9c1dc37)) -* **workflows:** make admin or token sufficient for all roles ([7a7cd4d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/7a7cd4d07c907611cf72e5ebe3ae41c3a401ef64)) -* **workflows:** new field CaptureDateTime ([5944a17](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/5944a174bc8a749c60718b58d656f44cd21e7ecf)) -* **workflows:** node messages ([6a7a892](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/6a7a892c74ad7da906a841fbfd031cca59174a8c)) -* **workflows:** placeholder handlers ([baea302](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/baea302e48dd6c603eebba7040923f0c23266f40)) -* **workflows:** prepare for admin-workflow-instance-edit ([ee6fecb](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/ee6fecb79e4807beceadd15f19e41393f7707135)) -* **workflows:** proper workflow-workflow-tables ([ac08846](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/ac08846c267f20fa053e3bd73bea72b224b636c6)) -* **workflows:** replace pages with warning if turned off ([8634d20](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/8634d20e2ad2d3746cf7b6111b91db9e57e4863b)) -* **workflows:** restrict day field wrt. current time ([b742731](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/b7427315119843e6b2cebad4f6f420d57c2efaf0)) -* **workflows:** update instances from definitions ([32efdae](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/32efdae839b1a3e43ed4161d20e598964970f15e)) -* **workflows:** wire up ws-school ([82b3a63](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/82b3a6364c77c64a653e927cd0242d64ffcf9d2a)) -======= * **model:** separate user authentication data from User table; add ExternalAuth and InternalAuth models * **model:** move user authentication data to new ExternalUser model * **settings:** rename userdb app settings @@ -1749,6 +874,7 @@ them together now) * **model:** separate user authentication data from User table; add ExternalAuth and InternalAuth models ([54f2430](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/54f2430b3e79d3b7c396ac4cf1d4d0da860e3d02)) * **settings:** rename userdb app settings ([9f299c8](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/9f299c854c9d2d2f1b1127c85a31b787f85fa210)) +======= ## [27.4.79](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/v27.4.78...v27.4.79) (2024-09-10) @@ -1963,6 +1089,78 @@ them together now) * **i18n:** fix some bad plurals ([890f8ad](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/890f8ad8b60115533faa6b99f4c4504243cbfb1d)) * **lint:** remove minor superfluous dollar ([64a1233](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/64a123387f3539b73649d02a6ecd97de577097e6)) * **qualification:** fix [#159](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/159) by removing an misleadingly named column for user qualification table ([fd6a538](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/fd6a5384d3517958a3c7726e32eed3bad197a591)) +## [v27.4.59-test-g0.0.17](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-f0.0.17...v27.4.59-test-g0.0.17) (2025-02-18) + +## [v27.4.59-test-f0.0.17](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-g0.0.16...v27.4.59-test-f0.0.17) (2025-02-17) + +## [v27.4.59-test-g0.0.16](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-f0.0.16...v27.4.59-test-g0.0.16) (2025-02-16) + +## [v27.4.59-test-f0.0.16](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-f0.0.15...v27.4.59-test-f0.0.16) (2025-02-16) + +## [v27.4.59-test-f0.0.15](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-e0.0.15...v27.4.59-test-f0.0.15) (2025-02-15) + +## [v27.4.59-test-e0.0.15](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-f0.0.14...v27.4.59-test-e0.0.15) (2025-02-14) + +## [v27.4.59-test-f0.0.14](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-e0.0.14...v27.4.59-test-f0.0.14) (2025-02-14) + +## [v27.4.59-test-e0.0.14](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-e0.0.13...v27.4.59-test-e0.0.14) (2025-02-13) + +## [v27.4.59-test-e0.0.13](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-e0.0.12...v27.4.59-test-e0.0.13) (2025-02-12) + +## [v27.4.59-test-e0.0.12](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-d0.0.12...v27.4.59-test-e0.0.12) (2025-02-12) + +## [v27.4.59-test-d0.0.12](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-d0.0.11...v27.4.59-test-d0.0.12) (2025-02-11) + +## [v27.4.59-test-d0.0.11](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-c0.0.11...v27.4.59-test-d0.0.11) (2025-02-11) + +## [v27.4.59-test-c0.0.11](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-b0.0.11...v27.4.59-test-c0.0.11) (2025-02-11) + +## [v27.4.59-test-b0.0.11](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-c0.0.10...v27.4.59-test-b0.0.11) (2025-02-11) + +## [v27.4.59-test-c0.0.10](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-b0.0.10...v27.4.59-test-c0.0.10) (2025-02-11) + +## [v27.4.59-test-b0.0.10](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-a0.0.10...v27.4.59-test-b0.0.10) (2025-02-11) + +## [v27.4.59-test-a0.0.10](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-a0.0.9...v27.4.59-test-a0.0.10) (2025-02-11) + +## [v27.4.59-test-a0.0.9](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-a0.0.8...v27.4.59-test-a0.0.9) (2025-02-10) + +## [v27.4.59-test-a0.0.8](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-a0.0.7...v27.4.59-test-a0.0.8) (2025-02-10) + +## [v27.4.59-test-a0.0.7](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-a0.0.6...v27.4.59-test-a0.0.7) (2025-02-10) + +## [v27.4.59-test-a0.0.6](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-a0.0.5...v27.4.59-test-a0.0.6) (2025-02-08) + +## [v27.4.59-test-a0.0.5](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-a0.0.4...v27.4.59-test-a0.0.5) (2025-02-07) + +## [v27.4.59-test-a0.0.4](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-a0.0.3...v27.4.59-test-a0.0.4) (2025-02-07) + +## [v27.4.59-test-a0.0.3](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-a0.0.2...v27.4.59-test-a0.0.3) (2025-02-06) + +## [v27.4.59-test-a0.0.2](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-a0.0.1...v27.4.59-test-a0.0.2) (2025-02-05) + +## [v27.4.59-test-a0.0.1](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59-test-a0.0.0...v27.4.59-test-a0.0.1) (2025-02-05) + +### Bug Fixes + +* **ghci:** ghci works now as expected ([c3117db](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive/commit/c3117dbdcd1de9ef9f0751afa45018e2ebce2c42)) + +## [v27.4.59-test-a0.0.0](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive//compare/v27.4.59...v27.4.59-test-a0.0.0) (2024-10-25) + +### Features + +* **util script:** Util script for renaming of files added. ([caf8fec](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive/commit/caf8fec5acb94df16293bf9aa0cdab766f8829e8)) +* **frontend:** load icons from svg files ([22781e1](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive/commit/22781e1565e890cf6c5b40973146b0334cb667aa)) + +### Bug Fixes + +* **stack.yaml:** move to uniworx.de gitlab ([55484e6](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive/commit/55484e631b786ea3710d322282019baf5292c243)) +* **utils/renamer:** Mehr outputs nur im verbose-Fall. ([ac30cb9](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive/commit/ac30cb9e6712d0ee3f204da4863d1e2509af8a76)) +* **utils:** Verboseparameter -v hinzugefuegt; rekursives makedir; genauere Meldungen. ([1806d9f](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive/commit/1806d9f01fc4a0746d2f9df42ef1ee6827c7fa09)) +* **Dockerfile:** change rights of source dir to env user ([e7a8183](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive/commit/e7a8183656ae419cfee2942543045c6fa6a9caa3)) +* **Makefile:** add missing dependency on well-known for backend-builds ([a09dc59](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive/commit/a09dc59f260843f8815c382576bb5254d21104bf)) +* **frontend:** fixed icon colour in table headers ([4c4571d](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive/commit/4c4571d2d0879e89f2572eba6015d34a7f4794c8)) +* **doc:** minor haddock problems ([d4f8a6c](https://fraport@dev.azure.com/fraport/Fahrerausbildung/_git/FRADrive/commit/d4f8a6c77b2a4a4540935f7f0beca0d0605508c8)) ## [27.4.59](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/v27.4.58...v27.4.59) (2024-02-13) diff --git a/Makefile b/Makefile index 89fb315b7..cdf790fc8 100644 --- a/Makefile +++ b/Makefile @@ -69,13 +69,19 @@ clean: -rm -rf .stack-work .stack-work.lock -rm -rf bin .Dockerfile develop -$(CONTAINER_COMMAND) container prune --force +.PHONY: clean-images +# HELP: stop all running containers and clean all images from local repositories +clean-images: + rm -rf develop + sleep 5 + -$(CONTAINER_COMMAND) system prune --all --force --volumes + -$(CONTAINER_COMMAND) image prune --all --force + -$(CONTAINER_COMMAND) volume prune --force .PHONY: clean-all # HELP: like clean but with full container, image, and volume prune clean-all: clean -rm -rf .stack - -$(CONTAINER_COMMAND) system prune --all --force --volumes - -$(CONTAINER_COMMAND) image prune --all --force - -$(CONTAINER_COMMAND) volume prune --force + $(CONTAINER_COMMAND) system reset --force .PHONY: release # HELP: create, commit and push a new release @@ -89,16 +95,20 @@ release: git push origin $${VERSION} .PHONY: compile +# HELP: perform full compilation (frontend and backend) compile: $(MAKE) compile-frontend $(MAKE) compile-backend .PHONY: start +# HELP: start complete development environment with a fresh test database start: $(MAKE) start-postgres $(MAKE) start-memcached $(MAKE) start-minio + $(MAKE) start-maildev $(MAKE) compile-frontend + $(MAKE) compile-uniworxdb $(MAKE) start-backend .PHONY: %-backend @@ -135,7 +145,7 @@ start: %-postgres: SERVICE=postgres %-postgres: SERVICE_VARIANT=postgres %-postgres: BASE_PORTS = "PGPORT=5432" -%-postgres: IMAGE=localhost/fradrive/postgres +%-postgres: SET_IMAGE=localhost/fradrive/postgres .PHONY: %-memcached %-memcached: SERVICE=memcached @@ -143,6 +153,18 @@ start: %-memcached: SET_IMAGE=$$(MEMCACHED_IMAGE) --port=`cat $$(CONTAINER_FILE) | grep 'MEMCACHED_PORT=' | sed 's/MEMCACHED_PORT=//'` %-memcached: BASE_PORTS = "MEMCACHED_PORT=11211" +.PHONY: %-maildev +%-maildev: SERVICE=maildev +%-maildev: SERVICE_VARIANT=maildev +%-maildev: SET_IMAGE=$$(MAILDEV_IMAGE) --port=`cat $$(CONTAINER_FILE) | grep 'MAILDEV_PORT=' | sed 's/MAILDEV_PORT=//'` +%-maildev: BASE_PORTS = "MAILDEV_PORT=1025" + +.PHONY: %-release +%-release: PROD=true +%-release: SERVICE=fradrive +%-release: SERVICE_VARIANT=fradrive +%-release: IMAGE=localhost/fradrive/fradrive + .PHONY: %-minio %-minio: SERVICE=minio %-minio: SERVICE_VARIANT=minio @@ -234,6 +256,7 @@ endif IMAGE="$(SET_IMAGE)" ; \ else \ IMAGE=$(IMAGE) ; \ + MAKECALL="make -- --$(JOB)-$(SERVICE_VARIANT) IN_CONTAINER=true" ; \ fi ; \ CONTAINER_ID=`$(CONTAINER_BGRUN) \ -v $(PWD):$(PROJECT_DIR):rw \ @@ -244,7 +267,7 @@ endif --env SRC=$(SRC) \ --name $${CONTAINER_NAME} \ $${IMAGE} \ - make -- --$(JOB)-$(SERVICE_VARIANT) IN_CONTAINER=true \ + $${MAKECALL} \ ` ; \ printf "CONTAINER_ID=$${CONTAINER_ID}" >> "$(CONTAINER_FILE)" ; \ if [[ "true" == "$(CONTAINER_ATTACHED)" ]] ; then \ @@ -253,7 +276,7 @@ endif # For Reverse Proxy Problem see: https://groups.google.com/g/yesodweb/c/2EO53kSOuy0/m/Lw6tq2VYat4J # HELP(start-backend): start development instance ---start-backend: +--start-backend: --dependencies-backend export YESOD_IP_FROM_HEADER=true; \ export DEV_PORT_HTTP=`cat $(CONTAINER_FILE) | grep 'DEV_PORT_HTTP=' | sed 's/DEV_PORT_HTTP=//'`; \ export DEV_PORT_HTTPS=`cat $(CONTAINER_FILE) | grep 'DEV_PORT_HTTPS=' | sed 's/DEV_PORT_HTTPS=//'`; \ @@ -273,11 +296,12 @@ endif export AVSPASS=$${AVSPASS:-nopasswordset} ; \ stack $(STACK_CORES) exec --local-bin-path $$(pwd)/bin --copy-bins -- yesod devel -p "$${DEV_PORT_HTTP}" -q "$${DEV_PORT_HTTPS}" # HELP(compile-backend): compile backend binaries ---compile-backend: +--compile-backend: --dependencies-backend stack build $(STACK_CORES) --fast --profile --library-profiling --executable-profiling --flag uniworx:-library-only $(--DEVELOPMENT) --local-bin-path $$(pwd)/bin --copy-bins # HELP(dependencies-backend): (re-)build backend dependencies --dependencies-backend: #uniworx.cabal chown -R `id -un`:`id -gn` "$(PROJECT_DIR)"; \ + stack install hpack; stack install yesod-bin; \ stack build -j2 --only-dependencies # HELP(lint-backend): lint backend --lint-backend: @@ -289,10 +313,10 @@ endif # stack exec -- hpack --force # HELP(compile-frontend): compile frontend assets ---compile-frontend: node_modules assets esbuild.config.mjs frontend/src/env.sass +--compile-frontend: --dependencies-frontend npm run build --start-frontend: --compile-frontend; ---dependencies-frontend: node_modules assets; +--dependencies-frontend: node_modules assets esbuild.config.mjs frontend/src/env.sass; node_modules: package.json package-lock.json npm install --cache .npm --prefer-offline package-lock.json: package.json @@ -306,7 +330,7 @@ assets/icons: node_modules assets/icons-src/fontawesome.json -cp assets/icons-src/*.svg assets/icons/fradrive frontend/src/env.sass: echo "\$$path: '$${PROJECT_DIR}'" > frontend/src/env.sass -static: node_modules assets esbuild.config.mjs frontend/src/env.sass +static: --dependencies-frontend npm run build well-known: static; --lint-frontend: --compile-frontend diff --git a/assets/icons-src/fontawesome.json b/assets/icons-src/fontawesome.json index 32bd6f55a..ef5dee6ee 100644 --- a/assets/icons-src/fontawesome.json +++ b/assets/icons-src/fontawesome.json @@ -29,6 +29,7 @@ "file-upload": "file-arrow-up", "file-zip": "file-zipper", "file-csv": "file-csv", +"file-missing": "file-circle-minus", "sft-question": "circle-question", "sft-hint": "life-ring", "sft-solution": "circle-exclamation", @@ -76,12 +77,13 @@ "submission-no-users": "user-slash", "reset": "arrow-rotate-left", "blocked": "ban", -"certificate": "certificate", +"certificate": "car-side", "print-center": "envelopes-bulk", "letter": "envelopes-bulk", "at": "at", "supervisor": "person", "supervisor-foreign": "person-rays", +"superior": "user-tie", "waiting-for-user": "user-gear", "expired": "hourglass-end", "locked": "lock", @@ -89,9 +91,18 @@ "trash": "trash", "reset-tries": "trash-can-arrow-up", "company": "building", +"company-warning": "building-circle-exclamation", "edit": "pen-to-square", "user-edit": "user-pen", "loading": "spinner", -"placeholder": "notdef" +"placeholder": "notdef", +"reroute": "diamond-turn-right", +"top": "award", +"wildcard": "asterisk", +"user-unknown": "user-slash", +"user-badge": "id-badge", +"glasses": "glasses", +"missing": "question", +"pin-protect": "key" } diff --git a/config/develop-settings.yml b/config/develop-settings.yml new file mode 100644 index 000000000..3b16a381d --- /dev/null +++ b/config/develop-settings.yml @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: 2024 Steffen Jost +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +# Values formatted like "_env:ENV_VAR_NAME:default_value" can be overridden by the specified environment variable. +# See https://github.com/yesodweb/yesod/wiki/Configuration#overriding-configuration-values-with-environment-variables +# NB: If you need a numeric value (e.g. 123) to parse as a String, wrap it in single quotes (e.g. "_env:PGPASS:'123'") +# See https://github.com/yesodweb/yesod/wiki/Configuration#parsing-numeric-values-as-strings + + +# DEVELOPMENT ONLY, NOT TO BE USED IN PRODUCTION + +avs-licence-synch: + times: [12] + level: 4 + reason-filter: "(firm|block)" + max-changes: 999 + +mail-reroute-to: + name: "FRADrive-QA-Umleitungen" + email: "FRADrive-TEST-Umleitungen@fraport.de" + +# Enqueue at specified hour, a few minutes later +job-lms-qualifications-enqueue-hour: 16 +job-lms-qualifications-dequeue-hour: 4 + +# Using these setting kills the job-workers somehow +# job-workers: 5 +# job-flush-interval: 600 +# job-stale-threshold: 3600 +# job-move-threshold: 60 + diff --git a/config/settings.yml b/config/settings.yml index 6aea21504..a88f327f2 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022-2024 Sarah Vaupel , Gregor Kleen ,Sarah Vaupel ,Steffen Jost ,Wolfgang Witt +# SPDX-FileCopyrightText: 2022-2025 Sarah Vaupel , Gregor Kleen ,Sarah Vaupel ,Steffen Jost ,Wolfgang Witt ,Steffen Jost # # SPDX-License-Identifier: AGPL-3.0-or-later @@ -91,10 +91,6 @@ synchronise-avs-users-interval: "_env:SYNCHRONISE_AVS_INTERVAL:21600" # alle 6 study-features-recache-relevance-within: 172800 study-features-recache-relevance-interval: 293 -# Enqueue at specified hour, a few minutes later -job-lms-qualifications-enqueue-hour: 16 -job-lms-qualifications-dequeue-hour: 4 - log-settings: detailed: "_env:DETAILED_LOGGING:false" all: "_env:LOG_ALL:false" @@ -173,12 +169,14 @@ user-sync-within: "_env:USER_SYNC_WITHIN:1209600" # 14 Tage in Sekunden user-sync-interval: "_env:USER_SYNC_INTERVAL:3600" # jede Stunde lms-direct: - upload-header: "_env:LMSUPLOADHEADER:true" - upload-delimiter: "_env:LMSUPLOADDELIMITER:" - download-header: "_env:LMSDOWNLOADHEADER:true" - download-delimiter: "_env:LMSDOWNLOADDELIMITER:," - download-cr-lf: "_env:LMSDOWNLOADCRLF:true" - deletion-days: "_env:LMSDELETIONDAYS:7" + upload-header: "_env:LMSUPLOADHEADER:true" + upload-delimiter: "_env:LMSUPLOADDELIMITER:" + download-header: "_env:LMSDOWNLOADHEADER:true" + download-delimiter: "_env:LMSDOWNLOADDELIMITER:," + download-cr-lf: "_env:LMSDOWNLOADCRLF:true" + orphan-deletion-days: "_env:LMSORPHANDELETIONDAYS:33" + orphan-deletion-batch: "_env:LMSORPHANDELETIONBATCH:12" + orphan-deletion-repeat-hours: "_env:LMSORPHANDELETIONREPEATHOURS:24" avs: host: "_env:AVSHOST:skytest.fra.fraport.de" @@ -233,9 +231,6 @@ memcached: timeout: "_env:MEMCACHED_TIMEOUT:20" expiration: "_env:MEMCACHED_EXPIRATION:300" memcache-auth: true -memcached-local: - maximum-ghost: 512 - maximum-weight: 104857600 # 100MiB upload-cache: host: "_env:UPLOAD_S3_HOST:localhost" # should be optional, but all file transfers will be empty without an S3 cache @@ -347,17 +342,6 @@ fallback-personalised-sheet-files-keys-expire: 2419200 download-token-expire: 604801 -file-source-arc: - maximum-ghost: 512 - maximum-weight: 1073741824 # 1GiB -file-source-prewarm: - maximum-weight: 1073741824 # 1GiB - start: 1800 # 30m - end: 600 # 10m - inhibit: 3600 # 60m - steps: 20 - max-speedup: 3 - bot-mitigations: - only-logged-in-table-sorting - unauthorized-form-honeypots diff --git a/config/test-settings.yml b/config/test-settings.yml index 0ea3e4c5c..5ec83701a 100644 --- a/config/test-settings.yml +++ b/config/test-settings.yml @@ -16,5 +16,4 @@ log-settings: auth-dummy-login: true server-session-acid-fallback: true -job-cron-interval: null -job-workers: 1 +job-workers: 20 \ No newline at end of file diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index 2fb91d587..47806a888 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -17,22 +17,45 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ apt-get -y update && apt-get install -y --no-install-recommends locales locales-all # run-time dependencies for uniworx binary -RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ ---mount=type=cache,target=/var/lib/apt,sharing=locked \ -apt-get -y update && apt-get -y install fonts-roboto # RUN apt-get -y update && apt-get -y install pdftk -RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ ---mount=type=cache,target=/var/lib/apt,sharing=locked \ -apt-get -y update && apt-get -y install texlive-latex-recommended texlive-luatex texlive-plain-generic texlive-lang-german texlive-lang-english +# RUN apt-get -y update && apt-get -y install \ +# texlive texlive-latex-recommended texlive-luatex texlive-plain-generic texlive-lang-german texlive-lang-english +RUN apt-get -y update && apt-get -y install \ + wget \ + perl \ + xz-utils \ + fonts-roboto \ + texlive \ + texlive-luatex \ + texlive-latex-extra \ + texlive-fonts-recommended \ + texlive-fonts-extra \ + && apt-get clean +# RUN ls /usr/local/texlive +# RUN chown -hR root /usr/local/texlive/2018 +ENV PATH="/usr/local/texlive/2018/bin/x86_64-linux:${PATH}" +ENV TEXLIVE_VERSION=2018 +RUN tlmgr init-usertree +RUN tlmgr option repository ftp://tug.org/historic/systems/texlive/2018/tlnet-final +RUN tlmgr update --self --all +RUN tlmgr install \ + babel \ + babel-english \ + babel-german \ + booktabs \ + textpos \ + enumitem \ + # luatex lualatex luatexbase lualatex-math eurosym \ + # above line requires tlmgr to run in -sys mode (~root?! apparently -privileged is missing) + koma-script \ + unicode-math \ + selnolig + + ARG PROJECT_DIR=/fradrive ENV PROJECT_DIR=${PROJECT_DIR} -RUN mkdir -p "${PROJECT_DIR}"; chmod -R 7777 "${PROJECT_DIR}" +# RUN mkdir -p "${PROJECT_DIR}"; chmod -R 777 "${PROJECT_DIR}" WORKDIR ${PROJECT_DIR} ENV HOME=${PROJECT_DIR} -ENV STACK_ROOT="${PROJECT_DIR}/.stack" - -RUN if [ ! -z "${IN_CI}" ]; then \ - stack install yesod-bin; \ - stack install hpack; \ -fi \ No newline at end of file +ENV STACK_ROOT="${PROJECT_DIR}/.stack" \ No newline at end of file diff --git a/docker/fradrive/Dockerfile b/docker/fradrive/Dockerfile index 77f574eae..238934af9 100755 --- a/docker/fradrive/Dockerfile +++ b/docker/fradrive/Dockerfile @@ -14,10 +14,12 @@ ENV DEBIAN_FRONTEND=noninteractive ENV TZ=Etc/UTC RUN apt-get update && apt-get -y install libpq-dev RUN apt-get update && apt-get -y install libsodium-dev +RUN apt-get update && apt-get -y install fonts-roboto # TODO: minimize texlive dependencies, switch to basic schemes where possible RUN apt-get update && apt-get -y install \ - texlive-latex-base \ + texlive-full \ texlive-luatex \ + texlive-plain-generic \ texlive-fonts-recommended \ texlive-fonts-extra \ texlive-lang-english \ diff --git a/fixtest.sh b/fixtest.sh deleted file mode 100755 index d59f51144..000000000 --- a/fixtest.sh +++ /dev/null @@ -1,6 +0,0 @@ -if [[ ! -d .stack-work-test ]]; then - mv -vT .stack-work .stack-work-test - [[ -d .stack-work-build ]] && mv -vT .stack-work-build .stack-work -else - echo "Directory .stack-work-test exists already." -fi diff --git a/frontend/src/icons.scss b/frontend/src/icons.scss index 0392849aa..2d430aa0e 100644 --- a/frontend/src/icons.scss +++ b/frontend/src/icons.scss @@ -5,7 +5,7 @@ @import 'env'; -$ico-width: 30px; +$ico-width: 15px; $icons: new, ok, @@ -35,6 +35,7 @@ $icons: new, file-upload, file-zip, file-csv, + file-missing, sft-question, sft-hint, sft-solution, @@ -95,9 +96,15 @@ $icons: new, trash, reset-tries, company, + company-warning, edit, user-edit, placeholder, + glasses, + user-badge, + user-unknown, + missing, + pin-protect, loading; @@ -133,6 +140,7 @@ $icons: new, .large-ico { font-size: 2em; + min-width: 1em; } .ico-spin { diff --git a/load/Load.hs b/load/Load.hs index 843127132..fd1c47886 100644 --- a/load/Load.hs +++ b/load/Load.hs @@ -96,7 +96,7 @@ sampleIntegral = sampleN scaleIntegral instance PathPiece DiffTime where toPathPiece = (toPathPiece :: Pico -> Text) . MkFixed . diffTimeToPicoseconds fromPathPiece t = fromPathPiece t <&> \(MkFixed ps :: Pico) -> picosecondsToDiffTime ps - + data LoadSimulation = LoadSheetDownload @@ -214,13 +214,13 @@ runSimulation sim = do delays <- replicateM (fromIntegral p) $ do d <- view $ _2 . _simDelay sampleNDiffTime d - + forConcurrently_ ([1..p] `zip` sort delays) $ \(n, d') -> do begin <- liftIO getCurrentTime dur <- view $ _2 . _simDuration tDuration <- sampleNDiffTime dur - + let MkFixed us = realToFrac d' :: Micro threadDelay $ fromInteger us start <- liftIO getCurrentTime @@ -268,7 +268,7 @@ runSimulation' LoadSheetSubmission = do -- Just formData <- return . getFormData FIDsubmission $ resp ^. responseBody -- Just addButtonData <- return . flip (runFormScraper FIDsubmission) (resp ^. responseBody) $ do -- let btnSel = "button" Scalpel.@: [Scalpel.hasClass "btn-mass-input-add"] - + -- name <- Scalpel.attr "name" btnSel -- value <- Scalpel.attr "value" btnSel -- guard $ value == "add__0__0" @@ -305,7 +305,7 @@ runSimulation' LoadSheetSubmission = do procEnd <- join $ asks runtime print ("proc", procEnd - procStart) - + resp3 <- liftIO . httpRetry $ Session.post session (uriToString id formURI mempty) subData void . evaluate $! resp3 where @@ -328,11 +328,11 @@ runSimulation' LoadSheetSubmission = do -> m () logRetry shouldRetry err status = liftIO . putStrLn . pack $ Retry.defaultLogMsg shouldRetry err status - + -- runSimulation' other = terror $ "Not implemented: " <> tshow other runFormScraper :: FormIdentifier -> Scalpel.Scraper Lazy.ByteString a -> Lazy.ByteString -> Maybe a -runFormScraper fid innerS = fmap join . flip Scalpel.scrapeStringLike $ +runFormScraper fid innerS = fmap join . flip Scalpel.scrapeStringLike $ fmap listToMaybe . Scalpel.chroots "form" $ do fid' <- Scalpel.attr "value" $ "input" Scalpel.@: ["name" Scalpel.@= "form-identifier"] guard $ fid' == encodeUtf8 (fromStrict $ toPathPiece fid) @@ -341,11 +341,11 @@ runFormScraper fid innerS = fmap join . flip Scalpel.scrapeStringLike $ getFormData :: FormIdentifier -> Lazy.ByteString -> Maybe [FormParam] getFormData = flip runFormScraper $ - Scalpel.chroots ("input") $ do + Scalpel.chroots "input" $ do name <- Scalpel.attr "name" Scalpel.anySelector value <- Scalpel.attr "value" Scalpel.anySelector <|> pure "" return $ toStrict name := value - + newLoadSession :: ReaderT SimulationContext IO Session newLoadSession = do @@ -354,7 +354,7 @@ newLoadSession = do let withToken = case loadToken of Nothing -> id Just (Jwt bs) -> (:) (hAuthorization, "Bearer " <> bs) . filter ((/= hAuthorization) . fst) - + liftIO . Session.newSessionControl (Just mempty) $ tlsManagerSettings { managerModifyRequest = \req -> return $ req { requestHeaders = withToken $ requestHeaders req } diff --git a/messages/uniworx/categories/admin/de-de-formal.msg b/messages/uniworx/categories/admin/de-de-formal.msg index 64f5b93b3..2bb78da7c 100644 --- a/messages/uniworx/categories/admin/de-de-formal.msg +++ b/messages/uniworx/categories/admin/de-de-formal.msg @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022-2024 Sarah Vaupel ,Gregor Kleen ,Winnie Ros ,Steffen Jost +# SPDX-FileCopyrightText: 2022-2025 Sarah Vaupel ,Gregor Kleen ,Winnie Ros ,Steffen Jost # # SPDX-License-Identifier: AGPL-3.0-or-later @@ -109,9 +109,10 @@ ProblemsDriverSynch1up: Alle gültigen Vorfeld-Fahrberechtigungen 'F' sind im AV ProblemsDriverSynch2: Alle gültigen Rollfeld-Fahrberechtigungen 'R' sind im AVS eingetragen ProblemsRDriversHaveFs: Alle Inhaber einer Rollfeld-Fahrberechtigung besitzen auch eine gültige Vorfeld-Fahrberechtigung ProblemsDriversHaveAvsIds: Alle Inhaber einer Fahrberechtigung konnten einer AVS Identifikationsnummer zugeordnet werden -ProblemsUsersAreReachable: Für alle Benutzer ist eine E-Mail oder postalische Adresse bekannt +ProblemsUsersAreReachable: Für alle Benutzer ist eine E-Mail oder postalische Adresse bekannt ProblemsNoStalePrintJobs n@Integer: Alle Briefversandaufträge #{pluralDE n "des vergangenen Tages" ("der vergangenen "<> tshow n <> " Tage")} wurden von der Druckerei bestätigt ProblemsNoBadAPCIds: Alle kürzlich empfangenen Druckauftragsbestätigungen waren gültig +ProblemsNoInsaneCompanySupervisions: Sind alle Firmen-bezogenen Ansprechpartnerbeziehungen zwischen passenden Firmenangehörigen? ProblemsUnreachableHeading: Unerreichbare Benutzer ProblemsUnreachableBody: Benutzer ohne E-Mail oder Postadresse, welche z.B. bei ablaufenden Berechtigungen nicht benachrichtigt werden können: ProblemsUnreachableButtons: Synchronisation für Unerreichbare starten @@ -123,6 +124,7 @@ ProblemsAvsSynchHeading: Synchronisation AVS Fahrberechtigungen ProblemsAvsErrorHeading: Fehlermeldungen ProblemsInterfaceSince: Berücksichtigt werden nur Erfolge und Fehler seit ProblemAvsUsrHadR: Momentan gültiges R im AVS +ProblemLastCheckTime t@Text: Letzte Prüfung vor #{t} AdminProblemSolved: Erledigt AdminProblemSolver: Bearbeitet von diff --git a/messages/uniworx/categories/admin/en-eu.msg b/messages/uniworx/categories/admin/en-eu.msg index 947ebad9a..1b21d894f 100644 --- a/messages/uniworx/categories/admin/en-eu.msg +++ b/messages/uniworx/categories/admin/en-eu.msg @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022-2024 Sarah Vaupel ,Winnie Ros ,Steffen Jost +# SPDX-FileCopyrightText: 2022-2025 Sarah Vaupel ,Winnie Ros ,Steffen Jost # # SPDX-License-Identifier: AGPL-3.0-or-later @@ -112,6 +112,7 @@ ProblemsDriversHaveAvsIds: All driving licence holder could be matched with thei ProblemsUsersAreReachable: Either Email or postal address is known for all users ProblemsNoStalePrintJobs n: All requests for letter mailing within the last #{pluralENsN n "day"} were acknowledged as printed by the airport printing center ProblemsNoBadAPCIds: All recently received print job ids from Airport Print Center were legit +ProblemsNoInsaneCompanySupervisions: All company related supervisions are between company-associated users ProblemsUnreachableHeading: Unreachable Users ProblemsUnreachableBody: Users without Email nor postal address, who thus cannot be notified about expiring qualifications: ProblemsUnreachableButtons: Start synchronisation for unreachable users only @@ -123,6 +124,7 @@ ProblemsAvsSynchHeading: Synchronisation AVS Driving Licences ProblemsAvsErrorHeading: Error Log ProblemsInterfaceSince: Only considering successes and errors since ProblemAvsUsrHadR: Currenlt R valid in AVS +ProblemLastCheckTime t: Last checked #{t} ago AdminProblemSolved: Done AdminProblemSolver: Solved by diff --git a/messages/uniworx/categories/authorization/en-eu.msg b/messages/uniworx/categories/authorization/en-eu.msg index 713afeec3..9995dfbb9 100644 --- a/messages/uniworx/categories/authorization/en-eu.msg +++ b/messages/uniworx/categories/authorization/en-eu.msg @@ -26,30 +26,30 @@ 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 type evaluation. +UnauthorizedEvaluation: You are not charged with course category evaluation. UnauthorizedExamExamOffice: You are not part of the appropriate exam office for any of the participants of this exam. UnauthorizedSchoolExamOffice: You are not part of an exam office for this school. UnauthorizedSystemExamOffice: You are not charged with system wide exam administration. UnauthorizedSystemPrinter: You are not charged with system wide letter printing. UnauthorizedExternalExamExamOffice: You are not part of the appropriate exam office for any of the participants of this exam. UnauthorizedSchoolLecturer: You are no course administrator for this department. -UnauthorizedLecturer: You are no administrator for this course type. -UnauthorizedCorrector: You are no sheet corrector for this course type. +UnauthorizedLecturer: You are no administrator for this course category. +UnauthorizedCorrector: You are no sheet corrector for this course category. UnauthorizedSheetCorrector: You are no corrector for this sheet. UnauthorizedExamCorrector: You are no corrector for this exam. -UnauthorizedCorrectorAny: You are no corrector for any course type. -UnauthorizedRegistered: You are no participant in this course type. +UnauthorizedCorrectorAny: You are no corrector for any course category. +UnauthorizedRegistered: You are no participant in this course category. UnauthorizedRegisteredExam: You are not registered for this exam. UnauthorizedRegisteredAnyExam: You are not registered for an exam. 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 type. -UnauthorizedParticipantSelf: You are no participant of this course type. -UnauthorizedCourseTime: This course type is not currently available. -UnauthorizedCourseRegistrationTime: This course type does not currently allow enrollment. +UnauthorizedParticipant: The specified user is no participant of this course category. +UnauthorizedParticipantSelf: You are no participant of this course category. +UnauthorizedCourseTime: This course category is not currently available. +UnauthorizedCourseRegistrationTime: This course category does not currently allow enrollment. UnauthorizedSheetTime: This sheet is not currently available. -UnauthorizedMaterialTime: This course type material is not currently available. +UnauthorizedMaterialTime: This course category material is not currently available. UnauthorizedTutorialTime: This course does not currently allow registration. UnauthorizedCourseNewsTime: This news item is not currently available. UnauthorizedExamTime: This exam is not currently available. @@ -61,7 +61,7 @@ UnauthorizedUserSubmission: Users may not directly submit for this exercise shee UnauthorizedCorrectorSubmission: Correctors may not create submissions for this exercise sheet. UnauthorizedCorrectionAnonymous: Correction is not anonymised. DeprecatedRoute: This view is deprecated and will be removed. -UnfreeMaterials: Course type material are not publicly accessable. +UnfreeMaterials: Course category material are not publicly accessable. UnauthorizedWrite: You do not have the write permission necessary to perform this action UnauthorizedSystemMessageTime: This system-message is not currently available. UnauthorizedSystemMessageAuth: This system-message is only available to logged in users. @@ -94,7 +94,7 @@ WorkflowRoleAlreadyInitiated: This workflow was already initiated WorkflowRoleNoSuchWorkflowWorkflow: The given workflow could not be found WorkflowRoleNoPayload: This workflow does not contain any data -CourseNoCapacity: Course type has reached maximum capacity +CourseNoCapacity: Course category has reached maximum capacity TutorialNoCapacity: Course has reached maximum capacity ExamOccurrenceNoCapacity: Occurrence/Room has reached maximum capacity CourseNotEmpty: There are currently no participants enrolled for this course. diff --git a/messages/uniworx/categories/avs/de-de-formal.msg b/messages/uniworx/categories/avs/de-de-formal.msg index fd790cef2..7e2cb7b8a 100644 --- a/messages/uniworx/categories/avs/de-de-formal.msg +++ b/messages/uniworx/categories/avs/de-de-formal.msg @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022 Steffen Jost +# SPDX-FileCopyrightText: 2022-25 Steffen Jost # # SPDX-License-Identifier: AGPL-3.0-or-later AvsPersonInfo: AVS Personendaten @@ -54,10 +54,14 @@ AvsUserUnassociated user@UserDisplayName: AVS Id unbekannt für Nutzer #{user} AvsUserUnknownByAvs api@AvsPersonId: AVS kennt Id #{tshow api} nicht (mehr) AvsUserAmbiguous api@AvsPersonId: AVS Id #{tshow api} ist nicht eindeutig AvsStatusSearchEmpty: AVS lieferte keine Ausweisinformationen -AvsPersonSearchEmpty: AVS Suche lieferte leeres Ergebnis -AvsPersonSearchAmbiguous: AVS Suche lieferte mehrere uneindeutige Ergebnisse +AvsPersonSearchEmpty: Suche im AVS lieferte kein Ergebnis +AvsPersonSearchAmbiguous: Suche im AVS lieferte mehrere uneindeutige Ergebnisse AvsSetLicencesFailed reason@Text: Setzen der Fahrlizenz im AVS fehlgeschlagen. Grund: #{reason} AvsIdMismatch api1@AvsPersonId api2@AvsPersonId: AVS Suche für Id #{tshow api1} lieferte stattdessen Id #{tshow api2} AvsUserCreationFailed api@AvsPersonId: Für AVS Id #{tshow api} konnte kein neuer Benutzer angelegt werden, da es eine gemeinsame Id (z.B. Personalnummer) mit einem existierenden, aber verschiedenen Nutzer gibt. -AvsCardsEmpty: AVS Suche lieferte keinerlei Ausweiskarten -AvsCurrentData: Alle angezeigte Daten wurden kürzlich direkt über die AVS Schnittstelle abgerufen. \ No newline at end of file +AvsCardsEmpty: Suche im AVS lieferte keinerlei Ausweiskarten +AvsCurrentData: Alle angezeigte Daten wurden kürzlich direkt über die AVS Schnittstelle abgerufen. +AvsUpdateDayCheck: Zusätzlich wird im Hintergrund ein AVS Datenabgleich für alle in der Tagesansicht vorkommenden Personen angestoßen (einmal pro Tag). + +AvsNoApronCard: Kein gültiger Ausweis mit Vorfeld-Zugang vorhanden +AvsNoCompanyCard mcn@(Maybe CompanyName): Für buchende Firma #{maybeEmpty mcn ciOriginal} liegt kein gültiger Ausweis vor diff --git a/messages/uniworx/categories/avs/en-eu.msg b/messages/uniworx/categories/avs/en-eu.msg index 787d38a16..2f3ba4b7c 100644 --- a/messages/uniworx/categories/avs/en-eu.msg +++ b/messages/uniworx/categories/avs/en-eu.msg @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022 Steffen Jost +# SPDX-FileCopyrightText: 2022-25 Steffen Jost # # SPDX-License-Identifier: AGPL-3.0-or-later AvsPersonInfo: AVS person info @@ -61,4 +61,8 @@ AvsSetLicencesFailed reason: Set driving licence within AVS failed. Reason: #{re AvsIdMismatch api1 api2: AVS search for id #{tshow api1} returned id #{tshow api2} instead AvsUserCreationFailed api@AvsPersonId: No new user could be created for AVS Id #{tshow api}, since an existing user shares at least one id presumed as unique AvsCardsEmpty: AVS search returned no id cards -AvsCurrentData: All shown data has been recently received via the AVS interface. \ No newline at end of file +AvsCurrentData: All shown data has been recently received via the AVS interface. +AvsUpdateDayCheck: In addition, a background AVS update has been scheduled for all persons occrring within the day agenda (once per Day). + +AvsNoApronCard: No valid card granting apron access found +AvsNoCompanyCard mcn@(Maybe CompanyName): No valid card for booking company #{maybeEmpty mcn ciOriginal} found diff --git a/messages/uniworx/categories/courses/courses/de-de-formal.msg b/messages/uniworx/categories/courses/courses/de-de-formal.msg index e0c589aba..60115f17f 100644 --- a/messages/uniworx/categories/courses/courses/de-de-formal.msg +++ b/messages/uniworx/categories/courses/courses/de-de-formal.msg @@ -70,9 +70,9 @@ CourseInvalidInput: Eingaben bitte korrigieren. CourseEditTitle: Kursart editieren/anlegen CourseEditOk tid@TermId ssh@SchoolId csh@CourseShorthand: Kursart #{tid}-#{ssh}-#{csh} wurde erfolgreich geändert. CourseEditDupShort tid@TermId ssh@SchoolId csh@CourseShorthand: Kursart #{tid}-#{ssh}-#{csh} konnte nicht geändert werden: Es gibt bereits einen andere Kursart mit dem selben Kürzel oder Titel in diesem Jahr und Bereich. -CourseEditQualificationFail: Eine Qualifikation konnte uas unbekanntem Grund nicht mit diesem Kurs assoziert werden. -CourseEditQualificationFailRights qsh@QualificationShorthand ssh@SchoolId: Qualifikation #{qsh} konnte nicht mit diesem Kurs assoziert werden, da Ihre Berechtigungen für Bereich #{ssh} dazu nicht ausreichen. -CourseEditQualificationFailExists: Diese Qualifikation ist bereits assoziert +CourseEditQualificationFail: Eine Qualifikation konnte uas unbekanntem Grund nicht mit diesem Kurs assoziiert werden. +CourseEditQualificationFailRights qsh@QualificationShorthand ssh@SchoolId: Qualifikation #{qsh} konnte nicht mit diesem Kurs assoziiert werden, da Ihre Berechtigungen für Bereich #{ssh} dazu nicht ausreichen. +CourseEditQualificationFailExists: Diese Qualifikation ist bereits assoziiert CourseEditQualificationFailOrder: Diese Sortierpriorität existiert bereits CourseLecturer: Kursverwalter:in MailSubjectParticipantInvitation tid@TermId ssh@SchoolId csh@CourseShorthand: [#{tid}-#{ssh}-#{csh}] Einladung zur Kursartteilnahme @@ -94,6 +94,8 @@ CourseParticipantsRegisterTutorialFieldTip: Ist aktuell keine Kurs mit diesem Na CourseParticipantsRegisterNoneGiven: Es wurden keine anzumeldenden Personen angegeben! CourseParticipantsRegisterNotFoundInAvs n@Int: Zu #{n} #{pluralDE n "Angabe konnte keine übereinstimmende Person" "Angaben konnten keine übereinstimmenden Personen"} im AVS gefunden werden CourseParticipantsRegisterTutorialFirstDayTip: Wenn ein neuer Kurs gemäß einer Vorlage erstellt wird, werden die Zeiten gemäß dem Starttag angepasst +CourseParticipantsTutorialType: Typ der Vorlage +CourseParticipantsTutorialTypeTooltip: Ein neuer Kurs wird wie ein Kurs namens "Vorlage_[typ]" erstellt, wobei zuerst in der aktuellen Kursart, danach in Kursarten gleichen Namens und möglichst neuem Datum gesucht wird. CourseParticipantsInvited n@Int: #{n} #{pluralDE n "Einladung" "Einladungen"} per E-Mail verschickt CourseParticipantsAlreadyRegistered n@Int: #{n} #{pluralDE n "Teinehmer:in" "Teilnehmer:innen"} #{pluralDE n "ist" "sind"} bereits zur Kursart angemeldet @@ -135,6 +137,9 @@ CourseUserTutorialsDeregistered count@Int64: Teilnehmer:in von #{show count} #{p CourseUserNoTutorialsDeregistered: Teilnehmer:in ist zu keinem der gewählten Kurse angemeldet CourseUserTutorials: Angemeldete Kurse CourseUserExams: Angemeldete Prüfungen +CourseUserExamOccurrences: Prüfungstermin +CourseUserExamOccurrenceOverride: Ggf. vorhandenen Prüfungstermin überschreiben +CourseUserExamOccurrenceAgainExaminer: Ggf. vorherige Prüfer erneut erlauben CourseUserSheets: Übungsblätter CsvColumnUserName: Voller Name des/der Teilnehmers/Teilnehmerin CsvColumnUserMatriculation: AVS Nummer des/der Teilnehmers/Teilnehmerin @@ -239,7 +244,7 @@ UtilEditedBy name@Text time@Text: #{time} durch #{name} CourseDate: Datum MailSubjectLecturerInvitation tid@TermId ssh@SchoolId csh@CourseShorthand: [#{tid}-#{ssh}-#{csh}] Einladung als Kursverwalter:in LecturerInvitationAccepted lType@Text csh@CourseShorthand: Sie wurden als #{lType} für #{csh} eingetragen -CourseExamRegistrationTime: Angemeldet seit +CourseExamRegistrationTime: Angemeldet am CourseParticipantStateIsActiveFilter: Ansicht CourseApply: Zur Kursart bewerben CourseAdministrator: Kursadministrator:in diff --git a/messages/uniworx/categories/courses/courses/en-eu.msg b/messages/uniworx/categories/courses/courses/en-eu.msg index 9f7835095..5e6262c3d 100644 --- a/messages/uniworx/categories/courses/courses/en-eu.msg +++ b/messages/uniworx/categories/courses/courses/en-eu.msg @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: AGPL-3.0-or-later -FilterCourse: Course +FilterCourse: Course category FilterCourseShort: Shorthand FilterTerm: Year FilterCourseSchoolShort: Department @@ -15,7 +15,7 @@ FilterCourseRegisterOpen: Enrolment is allowed CourseRegistered: Enrolled CourseRegistration: Enrolment CourseDescription: Description -CommCourseHeading: Course type message +CommCourseHeading: Course category message CourseLecturers: Course administrators CourseLecturerEmail: Email CourseLecturerAlreadyAdded: This user is already configured as a course administrator @@ -25,12 +25,12 @@ CourseLecturerRightsIdentical: All sorts of course administrators have the same CourseAcceptSubstitutesUntil: Accept substitute registrations until CourseAcceptSubstitutesUntilTip: Until which time should substitute registrations through the central allocation be accepted to fill free places in the course? If left empty no substitute registrations will be made. This deadline should not arbitrarily be set early or ommitted so as to not be an unneccesarily restrictive for students. For a seminar a valid choice might be a few hours before the first meeting in which topics will be assigned. CourseDeregisterNoShow: Record “no show” when deregistering -CourseDeregisterNoShowTip: Should “no show” be recorded as the exam achievement for all exams associated with this course type automatically whenever a course type participant deregisters themselves? This would be done once upon deregistration (if no other achievement exists for the given exam) and automatically whenever a new exam is created. +CourseDeregisterNoShowTip: Should “no show” be recorded as the exam achievement for all exams associated with this course category automatically whenever a course category participant deregisters themselves? This would be done once upon deregistration (if no other achievement exists for the given exam) and automatically whenever a new exam is created. CourseSchool: Department CourseSchoolMultipleTip: You may select from among multiple departments. Please ensure that you select the appropriate department for your course. CourseName: Title CourseShorthand: Shorthand -CourseShorthandUnique: Needs to be unique within school and year. Will be used verbatim within the url of the course type page. +CourseShorthandUnique: Needs to be unique within school and year. Will be used verbatim within the url of the course category page. CourseSemester: Year CourseDescriptionPlaceholder: Please include the module description CourseHomepageExternalPlaceholder: Optional external URL @@ -38,14 +38,14 @@ CourseHomepageExternal: External homepage CourseSemesterMultipleTip: You are currently allowed to select from among multiple years. Please ensure that you select the appropriate year for your course. CourseVisibleFrom: Visible from CourseVisibleTo: Visible to -CourseVisibleFromTip: The course type will be visible to others from this date onward. When left empty the course type will never be visible to other users. This does not affect course administrators, assistants, instructors, correctors, enrolled participants and applicants of/to this course. If the course type participates in a central allocation, the course type visibility will be forced during the application phase. -CourseVisibleToTip: Other users will be able to see the course type from "Visible From" up to this date. When left empty visible courses will remain visible indefinitely. -CourseMaterialFree: Course type material is publicly accessible +CourseVisibleFromTip: The course category will be visible to others from this date onward. When left empty the course category will never be visible to other users. This does not affect course administrators, assistants, instructors, correctors, enrolled participants and applicants of/to this course. If the course category participates in a central allocation, the course category visibility will be forced during the application phase. +CourseVisibleToTip: Other users will be able to see the course category from "Visible From" up to this date. When left empty visible courses will remain visible indefinitely. +CourseMaterialFree: Course category material is publicly accessible CourseFormSectionRegistration: Registration CourseFormSectionAdministration: Administration CourseCapacity: Capacity CourseCapacityTip: Maximum permissable number of enrolments for this course; leave empty for unlimited capacity -CourseSecretTip: Enrollment for this course type will require the password, if set +CourseSecretTip: Enrollment for this course category will require the password, if set CourseSecretFormat: Arbitrary string CourseSecretWrong: Wrong password CourseSecret: Access password @@ -59,17 +59,17 @@ CourseVisibilityEndMustBeAfterStart: The end of the visibility period must be af CourseRegistrationEndMustBeAfterStart: The end of the registration period must be after its start CourseDeregistrationEndMustBeAfterStart: The end of the deregistration period must be after the start of the registration period CourseUserMustBeLecturer: The current user needs to be a course administrator -CourseShorthandTooLong: Long course type shorthands may lead to display issues and might complicate communication with students. Please choose a more concise shorthand if possible. -CourseNotAlwaysVisibleDuringRegistration: To allow for students to register, the course type should also be visible during the entire registration period (which is currently not the case). +CourseShorthandTooLong: Long course category shorthands may lead to display issues and might complicate communication with students. Please choose a more concise shorthand if possible. +CourseNotAlwaysVisibleDuringRegistration: To allow for students to register, the course category should also be visible during the entire registration period (which is currently not the case). NoSuchTerm tid: Year #{tid} does not exist. NoSuchSchool ssh: Department #{ssh} does not exist. -NoSuchCourseShorthand csh: There is no course type with shorthand #{csh}. -NoSuchCourse: No such course type found. -CourseNewDupShort tid ssh csh: Could not create course type #{tid}-#{ssh}-#{csh}. Another course type with the same shorthand or title already exists for the given year and school. +NoSuchCourseShorthand csh: There is no course category with shorthand #{csh}. +NoSuchCourse: No such course category found. +CourseNewDupShort tid ssh csh: Could not create course category #{tid}-#{ssh}-#{csh}. Another course category with the same shorthand or title already exists for the given year and school. CourseInvalidInput: Invalid input CourseEditTitle: Edit/Create course -CourseEditOk tid ssh csh: Successfully edited course type #{tid}-#{ssh}-#{csh} -CourseEditDupShort tid ssh csh: Could not edit course type #{tid}-#{ssh}-#{csh}. Another course type with the same shorthand or title already exists for the given year and school. +CourseEditOk tid ssh csh: Successfully edited course category #{tid}-#{ssh}-#{csh} +CourseEditDupShort tid ssh csh: Could not edit course category #{tid}-#{ssh}-#{csh}. Another course category with the same shorthand or title already exists for the given year and school. CourseEditQualificationFail: A qualifikation could not be associated with this course for unknown reasons. CourseEditQualificationFailRights qsh ssh: Qualification #{qsh} could not be associated with this course, due to your insufficient rights for department #{ssh}. CourseEditQualificationFailExists: This qualification is already associated @@ -83,21 +83,23 @@ CourseParticipantInvitationAccepted courseName: You were enrolled in #{courseNam CourseParticipantEnlistDirectly: Enrol known users directly CourseSubmissionGroup: Registered submission group SubmissionGroupEmptyIsUnsetTip: Leave empty to remove users from their respective submission groups -CourseParticipantsRegisterHeading: Add course type participants -CourseParticipantsRegisterActionAddParticipants: Add course type participants +CourseParticipantsRegisterHeading: Add course category participants +CourseParticipantsRegisterActionAddParticipants: Add course category participants CourseParticipantsRegisterActionAddTutorialMembers: Add course participants CourseParticipantsRegisterUsersField: Persons to register for course CourseParticipantsRegisterUsersFieldTip: Please enter id card no (including dot), Fraport personnel number or email. Please separate multiple entries with comma or space. -CourseParticipantsRegisterTutorialOption: Register course type participants for course? +CourseParticipantsRegisterTutorialOption: Register course category participants for course? CourseParticipantsRegisterTutorialField: Course -CourseParticipantsRegisterTutorialFieldTip: If there is no course with this name, a new one will be created. If there is a course with this name, the course type participants will be registered for it. +CourseParticipantsRegisterTutorialFieldTip: If there is no course with this name, a new one will be created. If there is a course with this name, the course category participants will be registered for it. CourseParticipantsRegisterNoneGiven: No persons given to register! CourseParticipantsRegisterNotFoundInAvs n: For #{n} #{pluralEN n "entry no corresponding person" "entries no corresponding persons"} could be found in AVS CourseParticipantsRegisterTutorialFirstDayTip: If a new course is created and a template exists, its dates are adjusted according to the start date CourseParticipantsRegisterUnnecessary: All requested registrations have already been saved. No actions have been performed. +CourseParticipantsTutorialType: Template type +CourseParticipantsTutorialTypeTooltip: A new course creation copies a course named "Template_[typ]", preferably from the same course category or another having the same name, the most recent being preferred. CourseParticipantsInvited n: #{n} #{pluralEN n "invitation" "invitations"} sent via email -CourseParticipantsAlreadyRegistered n: #{n} #{pluralEN n "participant is" "participants are"} already course type #{pluralEN n "member" "members"} +CourseParticipantsAlreadyRegistered n: #{n} #{pluralEN n "participant is" "participants are"} already course category #{pluralEN n "member" "members"} CourseParticipantsAlreadyTutorialMember n: #{n} #{pluralEN n "participant is" "participants are"} already registered for this course CourseParticipantsRegistered n: Successfully registered #{n} #{pluralEN n "participant" "participants"} for course CourseParticipantsRegisteredTutorial n: Successfully registered #{n} #{pluralEN n "participant" "participants"} for course @@ -110,9 +112,9 @@ CourseRegistrationFiles: Registration file(s) CourseRegistrationFilesNeedReupload: Registration files need to be reuploaded every time the registration is changed CourseRegistrationFile: Registration file CourseRegistrationArchive: Zip archive of registration files -CourseDeregistrationNoShow: If you deregister from this course type “no show” will be recorded as your exam achievement for all exams associated with this course. If you have good reasons why you shold not be held accountable for leaving the course, please contact a course administrator. Course administrators can deregister you without incurring a permanent record. -CourseDeregistrationFromInvisibleCourse: This course type is only visible to enrolled participants and applicants. If you deregister now, you will not be able to access the course type again! -CourseDeregistrationNoReRegistration: If you deregister from the course type now, you will not be able to re-register yourself. +CourseDeregistrationNoShow: If you deregister from this course category “no show” will be recorded as your exam achievement for all exams associated with this course. If you have good reasons why you shold not be held accountable for leaving the course, please contact a course administrator. Course administrators can deregister you without incurring a permanent record. +CourseDeregistrationFromInvisibleCourse: This course category is only visible to enrolled participants and applicants. If you deregister now, you will not be able to access the course category again! +CourseDeregistrationNoReRegistration: If you deregister from the course category now, you will not be able to re-register yourself. LoginNecessary: Please log in first! RegisterRetry: You haven't been enrolled. Press "Enrol for course" to enrol CourseRegisterOk: Successfully enrolled for course @@ -135,6 +137,9 @@ CourseUserTutorialsDeregistered count: Sucessfully deregistered participant from CourseUserNoTutorialsDeregistered: Participant is not registered for any of the selected courses CourseUserTutorials: Registered courses CourseUserExams: Registered exams +CourseUserExamOccurrences: Exam occurrence +CourseUserExamOccurrenceOverride: Override other registrations for this exam, if any +CourseUserExamOccurrenceAgainExaminer: Possibly allow previous examiners again CourseUserSheets: Exercise sheets CsvColumnUserName: Participant's full name CsvColumnUserMatriculation: Participant's AVS number @@ -148,7 +153,7 @@ CsvColumnUserExam: Exams which the user is registered for, separated by semicolo CsvColumnUserSubmissionGroup: Registered submission group CsvColumnUserSurname: Participant's surname CsvColumnUserFirstName: Participant's given name -CsvColumnUserNote: Course type notes for the participant +CsvColumnUserNote: Course category notes for the participant CourseUserCsvName tid ssh csh: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-participants CourseUserTutorial: Registered course CourseUserExam: Registered exam @@ -175,21 +180,21 @@ AssistantsFor: Assistants CourseAdminFor: Course administration TutorsFor n: #{pluralENs n "Instructor" } CorrectorsFor n: #{pluralEN n "Corrector" "Correctors"} -CourseParticipantsHeading: Course type participants +CourseParticipantsHeading: Course category participants CourseParticipantsCount n: #{n} CourseParticipantsCountOf n m: #{n} of #{m} CourseVisibility: Visibility -CourseInvisible: This course type is currently only visible to course administrators, assistants, instructors, correctors, enrolled participants and applicants. +CourseInvisible: This course category is currently only visible to course administrators, assistants, instructors, correctors, enrolled participants and applicants. CourseRegistrationInterval: Enrolment CourseDirectRegistrationInterval: Direct enrolment CourseDeregisterUntil time: Deregistration only until #{time} NotRegistered: Note enrolled for this course CourseMaterial: Material -CourseMaterialNotFree: Course type material is only accessible to members of the course, e.g. for participants, instructors, correctors or administratiors. -CourseMaterialsFoundHere: Material for this course type is available here -CourseMaterialsNoneVisible: Currently there is no material for this course type or only material to which you don't have access (e.g. because of visibility settings) -CourseSheetsFoundHere: Exercise sheets for this course type are available here -CourseSheetsNoneVisible: Currently there are no exercise sheets for this course type or only exercise sheets to which you don't have access (e.g. because of visibility settings) +CourseMaterialNotFree: Course category material is only accessible to members of the course, e.g. for participants, instructors, correctors or administratiors. +CourseMaterialsFoundHere: Material for this course category is available here +CourseMaterialsNoneVisible: Currently there is no material for this course category or only material to which you don't have access (e.g. because of visibility settings) +CourseSheetsFoundHere: Exercise sheets for this course category are available here +CourseSheetsNoneVisible: Currently there are no exercise sheets for this course category or only exercise sheets to which you don't have access (e.g. because of visibility settings) SheetListCourse: Exercise sheets CourseExams: Exams CourseTutorials: Courses @@ -232,13 +237,13 @@ TutorialRegisterFrom: Register from TutorialRegisterTo: Register to CourseDeleteQuestion: Are you sure you want to delete the below-mentioned course? -CourseDeleted: Course type deleted +CourseDeleted: Course category deleted UtilEditedBy name time: #{time} by #{name} CourseDate: Date MailSubjectLecturerInvitation tid ssh csh: [#{tid}-#{ssh}-#{csh}] Invitation to be a course administrator LecturerInvitationAccepted lType csh: You were registered as #{lType} for #{csh} -CourseExamRegistrationTime: Registered since +CourseExamRegistrationTime: Registered on CourseParticipantStateIsActiveFilter: View CourseApply: Apply for course CourseAdministrator: Course administrator diff --git a/messages/uniworx/categories/courses/courses/event/de-de-formal.msg b/messages/uniworx/categories/courses/courses/event/de-de-formal.msg index e6ef8820f..a5351eddf 100644 --- a/messages/uniworx/categories/courses/courses/event/de-de-formal.msg +++ b/messages/uniworx/categories/courses/courses/event/de-de-formal.msg @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later CourseEvents: Termine -CourseEventType: Art +CourseEventType: Kategorie CourseEventTypePlaceholder: Vorlesung, Zentralübung, ... CourseEventTime: Zeit CourseEventRoom: Regulärer Raum diff --git a/messages/uniworx/categories/courses/courses/event/en-eu.msg b/messages/uniworx/categories/courses/courses/event/en-eu.msg index 0984a73c0..2f9568c3c 100644 --- a/messages/uniworx/categories/courses/courses/event/en-eu.msg +++ b/messages/uniworx/categories/courses/courses/event/en-eu.msg @@ -8,17 +8,17 @@ CourseEventTypePlaceholder: Lecture, Exercise discussion, ... CourseEventTime: Time CourseEventRoom: Regular room CourseEventRoomHidden: Room only for participants -CourseEventRoomHiddenTip: Should the room only be displayde to course type participants? +CourseEventRoomHiddenTip: Should the room only be displayde to course category participants? CourseEventRoomIsUnset: — -CourseEventRoomIsHidden: Room is only displayed to course type associated persons (participants, instructors, correctors, etc.) +CourseEventRoomIsHidden: Room is only displayed to course category associated persons (participants, instructors, correctors, etc.) CourseEventNote: Note CourseEventActions: Actions CourseEventsActionEdit: Edit CourseEventsActionDelete: Delete CourseEventsActionCreate: New occurrence -CourseEventCreated: Successfully created course type occurrence -CourseEventEdited: Successfully edited course type occurrence -CourseEventDeleteQuestion: Are you sure you want to delete the course type occurrence mentioned below? -CourseEventDeleted: Successfully deleted course type occurrence -CourseEventEdit: Edit course type occurrence -CourseEventNew: New course type occurrence \ No newline at end of file +CourseEventCreated: Successfully created course category occurrence +CourseEventEdited: Successfully edited course category occurrence +CourseEventDeleteQuestion: Are you sure you want to delete the course category occurrence mentioned below? +CourseEventDeleted: Successfully deleted course category occurrence +CourseEventEdit: Edit course category occurrence +CourseEventNew: New course category occurrence \ No newline at end of file diff --git a/messages/uniworx/categories/courses/courses/news/en-eu.msg b/messages/uniworx/categories/courses/courses/news/en-eu.msg index e1a0297a6..6aa05c529 100644 --- a/messages/uniworx/categories/courses/courses/news/en-eu.msg +++ b/messages/uniworx/categories/courses/courses/news/en-eu.msg @@ -9,17 +9,17 @@ CourseNewsLastEdited time: Last changed: #{time} CourseNewsActionEdit: Edit CourseNewsActionDelete: Delete CourseNewsActionCreate: Create new item -CourseNewsVisibleFromEditWarning: This item of course type news has already been published and should no longer be changed sind this might confuse participants. +CourseNewsVisibleFromEditWarning: This item of course category news has already been published and should no longer be changed sind this might confuse participants. CourseNewsVisibleFromTip: If left empty this item is never visible. Leave empty for unfinished items CourseNewsTitle: Title CourseNewsSummary: Summary -CourseNewsSummaryTip: If specified this only the summary will be shown on the course type page, saving space. The content will be shown in a popup +CourseNewsSummaryTip: If specified this only the summary will be shown on the course category page, saving space. The content will be shown in a popup CourseNewsContent: Content -CourseNewsParticipantsOnly: Only for course type participants +CourseNewsParticipantsOnly: Only for course category participants CourseNewsVisibleFrom: Visible from -CourseNewsCreated: Successfully created item of course type news -CourseNewsEdited: Successfully edited item of course type news -CourseNewsDeleteQuestion: Are you sure you want to delete the item of course type news listed below? -CourseNewsDeleted: Successfully deleted item of course type news -CourseNewsNew: Add course type news -CourseNewsEdit: Edit item of course type news +CourseNewsCreated: Successfully created item of course category news +CourseNewsEdited: Successfully edited item of course category news +CourseNewsDeleteQuestion: Are you sure you want to delete the item of course category news listed below? +CourseNewsDeleted: Successfully deleted item of course category news +CourseNewsNew: Add course category news +CourseNewsEdit: Edit item of course category news diff --git a/messages/uniworx/categories/courses/exam/exam/de-de-formal.msg b/messages/uniworx/categories/courses/exam/exam/de-de-formal.msg index bd87559c7..0bd1d6775 100644 --- a/messages/uniworx/categories/courses/exam/exam/de-de-formal.msg +++ b/messages/uniworx/categories/courses/exam/exam/de-de-formal.msg @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022 Gregor Kleen ,Sarah Vaupel ,Winnie Ros +# SPDX-FileCopyrightText: 2022-25 Gregor Kleen ,Sarah Vaupel ,Winnie Ros ,Steffen Jost # # SPDX-License-Identifier: AGPL-3.0-or-later @@ -35,7 +35,7 @@ ExamEditHeading examn@ExamName: #{examn} bearbeiten ExamNameTip: Muss innerhalb der Veranstaltung eindeutig sein ExamDescription: Beschreibung ExamFormTimes: Zeiten -ExamFormOccurrences: Prüfungstermine/Räume +ExamFormOccurrences: Prüfungstermine / Räume ExamFormAutomaticFunctions: Automatische Funktionen ExamFormCorrection: Korrektur ExamFormParts: Teile @@ -43,12 +43,13 @@ ExamFormMode: Ausgestaltung der Prüfung ExamFormGrades: Prüfungsleistungen ExamStart: Beginn ExamEnd: Ende -ExamTimeTip: Nur zur Information der Studierenden, die tatsächliche Zeitangabe erfolgt pro Prüfungstermin/Raum +ExamTimeTip: Nur zur Information, die tatsächliche Zeitangabe erfolgt pro Prüfungstermin/Raum. +ExamTimeFilterTip: In der Kursansicht für Kursverwaltende wird diese Zeit zur Filterung verwendet. ExamVisibleFrom: Sichtbar ab ExamVisibleFromTip: Ohne Datum nie sichtbar und keine Anmeldung möglich ExamRegisterFrom: Anmeldung ab ExamRegisterTo: Anmeldung bis -ExamRegisterFromTip: Zeitpunkt ab dem sich Kursartteilnehmer:innen selbständig zur Prüfung anmelden können; ohne Datum ist keine Anmeldung möglich +ExamRegisterFromTip: Zeitpunkt ab dem sich Kursartteilnehmer:innen selbständig zur Prüfung anmelden können; ohne Datum ist keine selbständige Anmeldung möglich ExamDeregisterUntil: Abmeldung bis ExamPublishOccurrenceAssignments: Termin- bzw. Raumzuteilung den Teilnehmer:innen mitteilen um ExamPublishOccurrenceAssignmentsTip: Ab diesem Zeitpunkt können Teilnehmer:innen einsehen zu welcher Teilprüfung bzw. welchen Raum sie angemeldet sind @@ -64,14 +65,15 @@ ExamAutomaticGradingTip: Sollen die Gesamtleistungen der Teilnehmer:innen automa ExamBonus: Bonuspunkte-System ExamGradingMode: Bewertungsmodus ExamGradingModeTip: In welcher Form werden Prüfungsleistungen für diese Prüfung eingetragen? -ExamStaff: Prüfer:innen/Verantwortliche Hochschullehrer:innen -ExamStaffTip: Geben Sie bitte in jedem Fall einen Namen an, der Prüfer:in/Veranstalter:in/Hochschullehrer:in eindeutig identifiziert! Sollte der Name des Prüfers/der Prüferin allein womöglich nicht eindeutig sein, so geben Sie bitte eindeutig identifizierende Zusatzinfos, wie beispielsweise den Lehrstuhl bzw. die LFE o.Ä., an. +ExamStaff: Hauptverantworliche:r +ExamStaffTip: Hauptverantwortliche:r Prüfer:in, Textfeld zur reinen Information der Teilnehmenden. ExamExamOfficeSchools: Zusätzliche Bereiche ExamExamOfficeSchoolsTip: Prüfungsbeauftragte von Bereichen, die Sie hier angeben, erhalten im System (zusätzlich zum primären Bereich der zugehörigen Kursart) volle Einsicht in sämtliche für diese Prüfung hinterlegten Leistungen, unabhängig von den Studiendaten der Teilnehmer:innen. ExamCorrectorEmail: E-Mail -ExamCorrectors: Korrektor:innen -ExamCorrectorsTip: Hier eingetragene Korrektor:innen können zwischen Beginn der Prüfung und "Bewertung abgeschlossen ab" Ergebnisse für alle Teilprüfungen und alle Teilnehmer:innen im System hinterlegen. -ExamCorrectorAlreadyAdded: Ein Korrektor/eine Korrektorin mit dieser E-Mail ist bereits für diese Prüfung eingetragen +ExamCorrectors: Prüfer:innen +ExamCorrectorsTip: Hier eingetragene Prüfer:innen können zwischen Beginn der Prüfung und "Bewertung abgeschlossen ab" Ergebnisse für alle Teilprüfungen und alle Teilnehmer:innen im System hinterlegen. +ExamCorrectorAlreadyAdded: Ein Prüfer:innen mit dieser E-Mail ist bereits für diese Prüfung eingetragen +ExamParticipant: Prüfungsteilnehmer:in ExamRoom: Raum ExamRoomManual': Keine automatische bzw. selbstständige Zuteilung ExamRoomSurname': Nach Nachname @@ -86,6 +88,7 @@ ExamRoomAlreadyExists: Prüfung ist bereits eingetragen ExamRoomName: Interne Bezeichnung ExamRoomCapacity: Kapazität ExamRoomCapacityNegative: Kapazität darf nicht negativ sein +ExamRoomCapacityInsufficient n@Int: Kapazität reicht nicht aus, #{noneOneMoreDE n "keine Plätze" "nur noch ein Platz" ("nur noch " <> tshow n <> " Plätze")} verfügbar ExamRoomTime: Termin ExamRoomStart: Beginn ExamRoomEnd: Ende @@ -123,6 +126,8 @@ ExamOccurrenceStartMustBeAfterExamStart eoName@ExamOccurrenceName: Beginn des Te ExamOccurrenceEndMustBeBeforeExamEnd eoName@ExamOccurrenceName: Ende des Termins #{eoName} liegt nach dem Ende der Prüfung ExamOccurrenceDuplicate eoRoom@Text eoRange@Text: Raum #{eoRoom}, Termin #{eoRange} kommt mehrfach mit der selben Beschreibung vor ExamOccurrenceDuplicateName eoName@ExamOccurrenceName: Interne Terminbezeichnung #{eoName} kommt mehrfach vor +ExamOccurrenceExaminerIsUnset !ident-ok: — +ExamOccurrenceExaminerIsHidden: Prüfer wird nur Teilnehmer:innen angezeigt ExamOccurrenceRoomIsUnset !ident-ok: — ExamOccurrenceRoomIsHidden: Raum wird nur Teilnehmer:innen angezeigt ExamOccurrenceCannotBeDeletedDueToRegistrations eoName@ExamOccurrenceName: Termin #{eoName} kann nicht gelöscht werden, da noch Teilnehmer:innen diesem Termin zugewiesen sind. Über die Liste von Prüfungsteilnehmern können Sie zunächst die entsprechenden Terminzuweisungen entfernen. @@ -217,6 +222,13 @@ ExamOccurrenceRuleParticipant: Termin- bzw. Raumzuteilungsverfahren ExamRegisteredCount: Anmeldungen ExamRegisteredCountOf num@Int64 count@Int64 !ident-ok: #{num}/#{count} ExamOccurrences: Termine +ExamOccurrencesCopied num@Int: #{pluralDEeN num "Prüfungstermin"} kopiert +ExamOccurrencesEdited num@Int del@Int: #{pluralENsN num "Prüfungstermin"} geändert #{guardMonoid (del > 0) ("und " <> pluralENsN num "Prüfungstermin" <> " gelöscht")} +ExamOccurrenceCopyNoStartDate: Dieser Kurs hat noch keine eigene Termine um Prüfungstermine zeitlich damit zu assoziieren +ExamOccurrenceCopyFail: Keine passenden Prüfungstermine zum Kopieren gefunden +ExaminerReocurrence examiner@Text: Mehrfache Prüfung durch #{examiner}! +ExamProblemReoccurrence: Prüfungen mit wiederholt gleichem Prüfer +ExamNoProblemReoccurrence: Heute keine Prüfungen mit wiederholtem Prüfer. GradingFrom: Ab ExamNoShow: Nicht erschienen ExamVoided: Entwertet @@ -264,6 +276,7 @@ ExamAutoOccurrenceExceptionRoomTooSmall: Automatische Verteilung gescheitert. Ei ExamBonusInfoPoints: Zur Berechnung von Bonuspunkten werden nur jene Blätter herangezogen, deren Aktivitätszeitraum vor Start des jeweiligen Termin/Prüfung begonnen hat ExamUserCsvSheetName tid@TermId ssh@SchoolId csh@CourseShorthand examn@ExamName: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-#{foldedCase examn} Teilnehmer +ExamRoomExaminerTip: Nur bereits eingetragene Prüfer:innen sind hier erlaubt ExamRoomCapacityTip: Maximale Anzahl an Prüfungsteilnehmern für diesen Termin/Raum; leer lassen für unbeschränkte Teilnehmeranzahl ExamRoomMappingRandom: Verteilung ExamFinishHeading: Prüfungsergebnisse sichtbar schalten diff --git a/messages/uniworx/categories/courses/exam/exam/en-eu.msg b/messages/uniworx/categories/courses/exam/exam/en-eu.msg index 32ffda98e..8fa6275e2 100644 --- a/messages/uniworx/categories/courses/exam/exam/en-eu.msg +++ b/messages/uniworx/categories/courses/exam/exam/en-eu.msg @@ -1,11 +1,11 @@ -# SPDX-FileCopyrightText: 2022 Gregor Kleen ,Sarah Vaupel ,Winnie Ros +# SPDX-FileCopyrightText: 2022-25 Gregor Kleen ,Sarah Vaupel ,Winnie Ros ,Steffen Jost # # SPDX-License-Identifier: AGPL-3.0-or-later ExamRegistrationInviteDeadline: Invitation valid until ExamRegistrationEnlistDirectly: Register known users directly ExamRegistrationEnlistDirectlyTip: Should users whose email addresses are known to the system be registered for the exam directly? Otherwise invitations will be sent to alle users, which they will have to accept first in order to be registered. Unknown users always receive an invitation. -ExamRegistrationRegisterCourse: Also enroll users in course type +ExamRegistrationRegisterCourse: Also enroll users in course category ExamRegistrationRegisterCourseTip: Users that aren't enrolled already won't be registered for the exam otherwise. ExamRegistrationInviteField: Email addresses ExamParticipantsRegisterHeading: Add exam participants @@ -35,7 +35,7 @@ ExamEditHeading examn: Edit #{examn} ExamNameTip: Needs to be unique within the course ExamDescription: Description ExamFormTimes: Times -ExamFormOccurrences: Occurrences/rooms +ExamFormOccurrences: Occurrences / Rooms ExamFormAutomaticFunctions: Automatic functions ExamFormCorrection: Correction ExamFormParts: Exam parts @@ -43,12 +43,13 @@ ExamFormMode: Exam design ExamFormGrades: Exam achievements ExamStart: Start ExamEnd: End -ExamTimeTip: Only for informational purposes. The actual times are set for each occurrence/room +ExamTimeTip: Only for informational purposes. The actual times are set for each occurrence/room. +ExamTimeFilterTip: Also used for Filtering in the course administrator course view. ExamVisibleFrom: Visible from -ExamVisibleFromTip: If left empty the exam is never visible and course type participants may not register. +ExamVisibleFromTip: If left empty the exam is never visible and course category participants may not register. ExamRegisterFrom: Register from ExamRegisterTo: Register to -ExamRegisterFromTip: Start of the period in which course type participants may register themselves for the exam. If left empty participants are never allowed to register. +ExamRegisterFromTip: Start of the period in which course category participants may register themselves for the exam. If left empty participants are never allowed to register themselves. ExamDeregisterUntil: Deregister until ExamPublishOccurrenceAssignments: Publish occurrence/room-assignments ExamPublishOccurrenceAssignmentsTip: At this time participants can find out to which occurrence/room they are assigned @@ -64,14 +65,15 @@ ExamAutomaticGradingTip: Should the exam achievement be automatically computed f ExamBonus: Bonus point system ExamGradingMode: Grading mode ExamGradingModeTip: In which format should grades for this exam be entered? -ExamStaff: Examiner/Responsible university teacher -ExamStaffTip: Please always specify a name that uniquely identifies the examiner/organiser/repsonsible university teacher! If there is a possibility that the name alone is ambiguous please also specify some additional information e.g. the professorial chair or the educational and research unit. +ExamStaff: Chief examiner +ExamStaffTip: Primary responsible examiner, arbirary text field for pure informational purposes. ExamExamOfficeSchools: Additional departments ExamExamOfficeSchoolsTip: 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. ExamCorrectorEmail: Email -ExamCorrectors: Correctors -ExamCorrectorsTip: Correctors configured here may, after the start of the exam and until "Results visible from", enter exam part results for all exam parts and participants. -ExamCorrectorAlreadyAdded: A corrector with this email address already exists +ExamCorrectors: Examiner +ExamCorrectorsTip: Examiners configured here may, after the start of the exam and until "Results visible from", enter exam part results for all exam parts and participants. +ExamCorrectorAlreadyAdded: An examiner with this email address already exists +ExamParticipant: Examinee ExamRoom: Room ExamRoomManual': No automatic or autonomous assignment ExamRoomSurname': By surname @@ -86,6 +88,7 @@ ExamRoomAlreadyExists: Occurrence already configured ExamRoomName: Internal name ExamRoomCapacity: Capacity ExamRoomCapacityNegative: Capacity may not be negative +ExamRoomCapacityInsufficient n@Int: Insufficient capacity, #{noneOneMoreEN n "none" "just one" ("only " <> tshow n)} remaining ExamRoomTime: Time ExamRoomStart: Start ExamRoomEnd: End @@ -123,8 +126,10 @@ ExamOccurrenceStartMustBeAfterExamStart eoName: Start of the occurrence #{eoName ExamOccurrenceEndMustBeBeforeExamEnd eoName: End of the occurrence #{eoName} must be before the exam end ExamOccurrenceDuplicate eoRoom eoRange: Combination of room #{eoRoom} and occurrence #{eoRange} occurs multiple times ExamOccurrenceDuplicateName eoName: Internal name #{eoName} occurs multiple times +ExamOccurrenceExaminerIsUnset !ident-ok: — +ExamOccurrenceExaminerIsHidden: Examiner only displayed to participants registered for this occurrence ExamOccurrenceRoomIsUnset: — -ExamOccurrenceRoomIsHidden: Room is only displayed to participants registered for this occurrence/room +ExamOccurrenceRoomIsHidden: Room only displayed to participants registered for this occurrence ExamOccurrenceCannotBeDeletedDueToRegistrations eoName: Occurrence #{eoName} cannot be deleted because participants are registered for it. You can remove the offending registrations via the list of exam participants. ExamRegistrationMustFollowSchoolSeparationFromStart dayCount: As per school rules there #{pluralEN dayCount "needs" "need"} to be at least #{dayCount} #{pluralEN dayCount "day" "days"} between "Register from" and "Start". ExamRegistrationMustFollowSchoolDuration dayCount: As per school rules there #{pluralEN dayCount "needs" "need"} to be at least #{dayCount} #{pluralEN dayCount "day" "days"} between "Register from" and "Register to". @@ -159,7 +164,7 @@ CsvColumnExamUserExercisePassesMax: Maximum number of exercise sheets the partic CsvColumnExamUserBonus: Exam bonus points CsvColumnExamUserParts: Number of points the participant achieved per exam part. One column per exam part if applicable. CsvColumnExamUserResult: Exam achievement; "passed", "failed", "no-show", "voided", or any number grade ("1.0", "1.3", "1.7", ..., "4.0", "5.0") -CsvColumnExamUserCourseNote: Course type notes for the participant +CsvColumnExamUserCourseNote: Course category notes for the participant CsvColumnExamOfficeExamUserOccurrenceStart: Exam occurrence (ISO 8601) CsvColumnUserStudyFeatures: All relevant features of study for the participant, separated by semicolon (;) ExamUserCsvName tid ssh csh examn: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-#{foldedCase examn}-participants @@ -176,7 +181,7 @@ ExamAction: Action ExamUsersExamDataRequiresRegistration: If exam data (part-/result, occurrence/room, bonus) is to be modified/set, the relenvant participant needs to be registered for the exam. ExamNoOccurrence: No occurrence/room ExamBonusNone: No bonus points -ExamUserCsvCourseNoteDeleted: Course type note will be deleted +ExamUserCsvCourseNoteDeleted: Course category note will be deleted ExamUsersDeregistered count: Successfully deregistered #{show count} #{pluralEN count "participant" "participants"} ExamUsersOccurrenceUpdated count: Successfully assigned occurrence/room for #{show count} #{pluralEN count "participant" "participants"} ExamUsersResultsAccepted count: Successfully accepted computed result for #{show count} #{pluralEN count "participant" "participants"} @@ -217,6 +222,13 @@ ExamOccurrenceRuleParticipant: Occurrence/room assignment procedure ExamRegisteredCount: Registrations ExamRegisteredCountOf num count: #{num}/#{count} ExamOccurrences: Exams +ExamOccurrencesCopied num: #{pluralENsN num "exam occurrence"} copied +ExamOccurrencesEdited num del: #{pluralENsN num "exam occurrence"} edited #{guardMonoid (del > 0) ("and " <> pluralENsN num "exam occurrence" <> " deleted")} +ExamOccurrenceCopyNoStartDate: This course needs its own occurrence to copy associated exam occurrences. +ExamOccurrenceCopyFail: No suitable exam occurrences found to copy from. +ExaminerReocurrence examiner: Multiple examinations by #{examiner}! +ExamProblemReoccurrence: Exams with reoccurring examiner +ExamNoProblemReoccurrence: Today there are no exams with a reoccurring examiner. GradingFrom: From #templates widgets/bonus-rule @@ -262,6 +274,8 @@ ExamAutoOccurrenceExceptionNoUsers: No participants can be distributed with the ExamAutoOccurrenceExceptionRoomTooSmall: Automatic distribution failed. A different distribution procedure might succeed. Alternatively, minimizing rooms or removing small rooms might help. ExamBonusInfoPoints: When calculating an exam bonus only those sheets will be considered, for which the submission period started before the start of the relevant occurrence/room ExamUserCsvSheetName tid ssh csh examn: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-#{foldedCase examn} Participants + +ExamRoomExaminerTip: Only examiners allowed here, add beforehand ExamRoomCapacityTip: Maximum number of participants for this occurrence/room; leave empty for unlimited capacity ExamRoomMappingRandom: Distribution ExamFinishHeading: Make results visible @@ -302,9 +316,9 @@ ExamUserCsvOverrideResult: Override exam result in contradiction of computed val ExamUserCsvSetBonus: Set bonus points ExamUserCsvSetResult: Set exam result ExamUserCsvSetPartResult: Set result for exam part -ExamUserCsvSetCourseNote: Modify course type participant notes -ExamUserCsvExceptionNoMatchingUser: Course type participant could not be identified uniquely. All identifiers (given name(s), surname, display name, matriculation, ..) must match exactly. You can try to remove some of the identifiers for the given line (i.e. all but matriculation). Uni2work will then search for users using only the remaining identifiers. In this case special care should be taken that Uni2work correctly identifies the intended user. -ExamUserCsvExceptionMultipleMatchingUsers: Course type participant could not be identified uniquely. There are multiple users that match the given identifiers. You can try to add more identifiers for the given line to ensure that only the intended user can be identified with them. +ExamUserCsvSetCourseNote: Modify course category participant notes +ExamUserCsvExceptionNoMatchingUser: Course category participant could not be identified uniquely. All identifiers (given name(s), surname, display name, matriculation, ..) must match exactly. You can try to remove some of the identifiers for the given line (i.e. all but matriculation). Uni2work will then search for users using only the remaining identifiers. In this case special care should be taken that Uni2work correctly identifies the intended user. +ExamUserCsvExceptionMultipleMatchingUsers: Course category participant could not be identified uniquely. There are multiple users that match the given identifiers. You can try to add more identifiers for the given line to ensure that only the intended user can be identified with them. ExamUserCsvExceptionNoMatchingStudyFeatures: The specified field did not match with any of the participant's fields of study. You can try to remove the field of study for the given line. Uni2work will then automatically choose a field of study. ExamUserCsvExceptionNoMatchingOccurrence: Occurrence/room could not be identified uniquely. Please ensure that the given line only contains internal room identifiers exactly as they have been configured for this exam. ExamUserCsvExceptionMismatchedGradingMode expectedGradingMode actualGradingMode: The imported data contained an exam achievement which does not match the grading mode for this exam. The expected grading mode can be changed at "Edit exam" ("Passed/Failed", "Numeric grades", or "Mixed"). diff --git a/messages/uniworx/categories/courses/exam/external_exam/en-eu.msg b/messages/uniworx/categories/courses/exam/external_exam/en-eu.msg index 933b7acbc..b95793046 100644 --- a/messages/uniworx/categories/courses/exam/external_exam/en-eu.msg +++ b/messages/uniworx/categories/courses/exam/external_exam/en-eu.msg @@ -15,8 +15,8 @@ ExternalExamCorrectErrorNeedleTooShort: This identifier is too short. UnauthorizedExternalExamCorrectorGrade: You may not enter overall exam achievements for this exam. ExternalExamCorrectErrorMultipleMatchingUsers: This identifier matches on multiple students. ExternalExamCorrectErrorNoMatchingUsers: This identifier does not match any student. -ExternalExamEdited coursen@CourseName examn@ExamName: Succesfully edited exam “#{examn}” for course type “#{coursen}”. -ExternalExamExists coursen@CourseName examn@ExamName: Exam “#{examn}” already exists for course type “#{coursen}”. +ExternalExamEdited coursen@CourseName examn@ExamName: Succesfully edited exam “#{examn}” for course category “#{coursen}”. +ExternalExamExists coursen@CourseName examn@ExamName: Exam “#{examn}” already exists for course category “#{coursen}”. ExternalExamEdit coursen examn: Edit: #{coursen}, #{examn} ExternalExamSemester: Year ExternalExamSchool: Department @@ -41,10 +41,10 @@ ExternalExamStaffTip: The list of ssociated persons is shown to exam offices and ExternalExamStaffAlreadyAdded: Person is already associated with the exam. ExternalExamStaffEmail: Email ExternalExamUserMustBeStaff: You yourself must always be an associated person for exams you create. -ExternalExamCourseExists: This course type already exists with FRADrive. Exams for courses that exist within FRADrive should be associated with the course type directly instead of being created as an external exam. +ExternalExamCourseExists: This course category already exists with FRADrive. Exams for courses that exist within FRADrive should be associated with the course category directly instead of being created as an external exam. HeadingExternalExamList: External exams HeadingExternalExamNew: New external exam -ExternalExamCreated coursen@CourseName examn@ExamName: Succesfully created exam “#{examn}” for course type “#{coursen}”. +ExternalExamCreated coursen@CourseName examn@ExamName: Succesfully created exam “#{examn}” for course category “#{coursen}”. MailSubjectExternalExamStaffInvitation coursen examn: Invitation to act as examiner for “#{examn}” of “#{coursen}” ExternalExamOccurrenceEdited count: Successfully edited #{count} #{pluralEN count "occurrence" "occurrences"} ExternalExamResultEdited count: Successfully edited #{count} #{pluralEN count "exam result" "exam results"} diff --git a/messages/uniworx/categories/courses/material/de-de-formal.msg b/messages/uniworx/categories/courses/material/de-de-formal.msg index 92427fff9..aeb213d80 100644 --- a/messages/uniworx/categories/courses/material/de-de-formal.msg +++ b/messages/uniworx/categories/courses/material/de-de-formal.msg @@ -4,7 +4,7 @@ MaterialList !ident-ok: Material MaterialName !ident-ok: Name -MaterialType: Art +MaterialType: Typ MaterialTypePlaceholder: Folien, Code, Beispiel, ... MaterialTypeSlides: Folien MaterialTypeCode !ident-ok: Code diff --git a/messages/uniworx/categories/courses/material/en-eu.msg b/messages/uniworx/categories/courses/material/en-eu.msg index b0f2039dd..bd93eae0f 100644 --- a/messages/uniworx/categories/courses/material/en-eu.msg +++ b/messages/uniworx/categories/courses/material/en-eu.msg @@ -11,27 +11,27 @@ MaterialTypeCode: Code MaterialTypeExample: Example MaterialDescription: Description MaterialVisibleFrom: Visible to participants from -MaterialVisibleFromTip: Never visible to participants if left empty; leaving the date empty is only sensible for unfinished course type material or when course type material should be provided only to sheet correctors -MaterialVisibleFromEditWarning: This course type material has already been published and should not be edited. Doing so might confuse the participants. -MaterialInvisible: This course type material is currently invisible to participants! +MaterialVisibleFromTip: Never visible to participants if left empty; leaving the date empty is only sensible for unfinished course category material or when course category material should be provided only to sheet correctors +MaterialVisibleFromEditWarning: This course category material has already been published and should not be edited. Doing so might confuse the participants. +MaterialInvisible: This course category material is currently invisible to participants! MaterialFiles: Files MaterialHeading materialName: #{materialName} -MaterialListHeading: Course type materials -MaterialNewHeading: Publish new course type material -MaterialNewTitle: New course type material -MaterialEditHeading materialName: Edit course type material “#{materialName}” -MaterialEditTitle materialName: Edit course type material “#{materialName}” -MaterialSaveOk tid ssh csh materialName: Successfully saved “#{materialName}” for course type #{tid}-#{ssh}-#{csh} -MaterialNameDup tid ssh csh materialName: Course type material with the name “#{materialName}” already exists for course type #{tid}-#{ssh}-#{csh} -MaterialDeleteCaption: Do you really want to delete the course type material mentioned below? +MaterialListHeading: Course category materials +MaterialNewHeading: Publish new course category material +MaterialNewTitle: New course category material +MaterialEditHeading materialName: Edit course category material “#{materialName}” +MaterialEditTitle materialName: Edit course category material “#{materialName}” +MaterialSaveOk tid ssh csh materialName: Successfully saved “#{materialName}” for course category #{tid}-#{ssh}-#{csh} +MaterialNameDup tid ssh csh materialName: Course category material with the name “#{materialName}” already exists for course category #{tid}-#{ssh}-#{csh} +MaterialDeleteCaption: Do you really want to delete the course category material mentioned below? MaterialDelHasFiles count: including #{count} #{pluralEN count "file" "files"} -MaterialIsVisible: Caution, this course type material has already been published. -MaterialDeleted materialName: Successfully deleted course type material “#{materialName}” +MaterialIsVisible: Caution, this course category material has already been published. +MaterialDeleted materialName: Successfully deleted course category material “#{materialName}” MaterialArchiveName tid ssh csh materialName: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-#{foldedCase materialName} MaterialVideo materialName: #{materialName} - Video MaterialVideoUnsupported: Your browser does not seem to support embedded video MaterialVideoDownload: Download -MaterialFree: Course type material is publicly available. +MaterialFree: Course category material is publicly available. AccessibleSince: Accessible since VisibleFrom: Published FilterMaterialNameSearch !ident-ok: Name diff --git a/messages/uniworx/categories/courses/participants/en-eu.msg b/messages/uniworx/categories/courses/participants/en-eu.msg index 5c98bb4e4..ea8d67211 100644 --- a/messages/uniworx/categories/courses/participants/en-eu.msg +++ b/messages/uniworx/categories/courses/participants/en-eu.msg @@ -2,16 +2,16 @@ # # SPDX-License-Identifier: AGPL-3.0-or-later -ParticipantsList: Lists of course type participants -ParticipantsIntersect: Common course type participants +ParticipantsList: Lists of course category participants +ParticipantsIntersect: Common course category participants ParticipantsCsvName tid ssh: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-participants ParticipantsIntersectCourseOption tid@TermId ssh@SchoolId coursen@CourseName: #{tid} - #{ssh} - #{coursen} ParticipantsIntersectCourses: Courses CourseParticipantsRegisteredWithoutField n: #{n} #{pluralEN n "participant was" "participants were"} registered without #{pluralEN n "an associated field of study" "associated fields of study"}, because #{pluralEN n "it" "they"} could not be determined uniquely. ParticipantsCsvSheetName tid ssh: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)} Participants -CourseParticipants n: Currently #{n} course type #{pluralEN n "participant" "participants"} +CourseParticipants n: Currently #{n} course category #{pluralEN n "participant" "participants"} ParticipantsIntersectNotOne: Intersection AllUsersUnion: Union of all participants AllUsersIntersection: Intersection of all participants -CourseDeleteActiveParticipants: This course type still has active participants. Remove all active participants first if you really want to delete this course. -CourseDeleteExistExams: This course type cannot be deleted, for as long as associated exams exist. \ No newline at end of file +CourseDeleteActiveParticipants: This course category still has active participants. Remove all active participants first if you really want to delete this course. +CourseDeleteExistExams: This course category cannot be deleted, for as long as associated exams exist. \ No newline at end of file diff --git a/messages/uniworx/categories/courses/sheet/en-eu.msg b/messages/uniworx/categories/courses/sheet/en-eu.msg index a71932447..17dbe54cf 100644 --- a/messages/uniworx/categories/courses/sheet/en-eu.msg +++ b/messages/uniworx/categories/courses/sheet/en-eu.msg @@ -13,8 +13,8 @@ SheetDeleteQuestion: Do you really want to delete the below-mentioned exercise s SheetDeleted: Successfully deleted exercise sheet SheetArchiveName tid ssh csh shn: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-#{foldedCase shn} SheetTypeArchiveName tid ssh csh shn renderedSft: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-#{foldedCase shn}-#{foldCase renderedSft} -SheetEditOk tid ssh csh sheetName: Successfully saved exercise sheet #{sheetName} in course type #{tid}-#{ssh}-#{csh} -SheetNameDup tid ssh csh sheetName: There already is an exercise sheet #{sheetName} in course type #{tid}-#{ssh}-#{csh} +SheetEditOk tid ssh csh sheetName: Successfully saved exercise sheet #{sheetName} in course category #{tid}-#{ssh}-#{csh} +SheetNameDup tid ssh csh sheetName: There already is an exercise sheet #{sheetName} in course category #{tid}-#{ssh}-#{csh} SheetVisibleFrom: Visible from (for participants) SheetVisibleFromTip: Always invisible for participants and no submission possible if left empty; only leave this field empty for temporary/unfinished sheets SheetActiveFrom: Active from/Submission period start @@ -24,7 +24,7 @@ SheetSolutionFromTip: Always invisible for participants if left empty; corrector SheetName: Name SheetDescription: Description SheetRequireExam: Require registration for an exam? -SheetRequireExamTip: If registration for an exam is required, only course type participants that are registered for that exam at the time of submission will be allowed to create submission. Download of sheet files will also be restricted to course type participants registered for the exam. +SheetRequireExamTip: If registration for an exam is required, only course category participants that are registered for that exam at the time of submission will be allowed to create submission. Download of sheet files will also be restricted to course category participants registered for the exam. SheetRequiredExam: Exam SheetFormType: Valuation & submission SheetFormTimes: Times @@ -37,15 +37,15 @@ SheetMarkingFiles: Correction SheetMarkingTip: Instructions for correction, visible only to correctors SheetPersonalisedFilesDownload: Download personalised sheet files SheetPersonalisedFiles: Personalised sheet files -SheetPersonalisedFilesTip: Should course type participants be assigned personalised sheet files in addition to the files configured above? Only the user to which a file has been assigned may view it. +SheetPersonalisedFilesTip: Should course category participants be assigned personalised sheet files in addition to the files configured above? Only the user to which a file has been assigned may view it. SheetPersonalisedFilesUpload: Personalised sheet files SheetPersonalisedFilesUploadTip: Download the template for a ZIP-archive of personalised sheet files, move files into the directories corresponding to the desired users and upload the archive again. If the name of a personalised file matches the name of an unpersonalised file, the personalised file replaces the unpersonalised one from the respective participants' point of view. SheetPersonalisedFilesKeepExisting: Keep existing files SheetPersonalisedFilesKeepExistingTip: Should the personalised files you upload be added to the already existing ones, if applicable? Otherwise the files you upload will completely replace any existing files. SheetPersonalisedFilesAllowNonPersonalisedSubmission: Allow non-personalised submission -SheetPersonalisedFilesAllowNonPersonalisedSubmissionTip: Should course type participants with no assigned personalised files be allowed to submit anyway? +SheetPersonalisedFilesAllowNonPersonalisedSubmissionTip: Should course category participants with no assigned personalised files be allowed to submit anyway? SheetPersonalisedFilesDownloadTemplateHere: You can download a template for a ZIP-archive of personalised sheet files with the structure that Uni2work expects here: -SheetPersonalisedFilesUsersList: List of course type participants who have personalised sheet files +SheetPersonalisedFilesUsersList: List of course category participants who have personalised sheet files SheetPersonalisedFilesMetaYAMLSeedComment: This string was generated cryptographically from data uniquely identifying the user and exercise sheet. You can use it as a seed for a pseudorandom generator for generating (parts of) the personalised files. SheetPersonalisedFilesMetaYAMLNoSeedComment: There is not enough information available to generate a seed. You will have to create the exercise sheet in Uni2work first. Once seeds can be generated they will be generated cryptographically and you may use them to generate (parts of) the personalised files. SheetActiveFromTip: The exercise sheet's assignment will only be available for download and submission starting at this time. If left empty no submission or download of assignment is ever allowed @@ -63,7 +63,7 @@ SheetErrDeadlineEarly: "Submission period end" must be after "Submission period SheetErrHintEarly: "Hint from" must be after "Submission period start" SheetErrSolutionEarly: "Solution from" must be after "Submission period end" SheetErrVisibleWithoutActive: If “Visible from (for participants)” is specified “Active from/Submission period start” must also be specified -SheetSubmissionModeNoneWithoutNotGraded: The sheet was configured to be "No submission" but not "Not marked". Course type participants will not be able to submit. +SheetSubmissionModeNoneWithoutNotGraded: The sheet was configured to be "No submission" but not "Not marked". Course category participants will not be able to submit. SheetWarnNoActiveTo: “Active to/Submission period end” should always be specified CountTutProp: Courses count against proportion CountTutPropTip: If submissions are assigned by course, do those assignments count with regard to the set proportion? @@ -82,13 +82,13 @@ RatingPercent: Achieved IsRated: Marked SheetTypeIsExam: Rating „as an exam part“ SheetGradingSummaryTitle intgr: #{intgr} #{pluralEN intgr "sheet" "sheets"} -PersonalisedSheetFilesIgnored count: #{count} uploaded #{pluralEN count "file was" "files were"} ignored because #{pluralEN count "it" "they"} could not be associated with both a sheet file type and a course type participant. +PersonalisedSheetFilesIgnored count: #{count} uploaded #{pluralEN count "file was" "files were"} ignored because #{pluralEN count "it" "they"} could not be associated with both a sheet file type and a course category participant. PersonalisedSheetFilesIgnoredIntro: The following files were ignored: PersonalisedSheetFilesDownloadRestrictByExamNone: No restriction PersonalisedSheetFilesDownloadRestrictByExam: Restrict to exam participants PersonalisedSheetFilesDownloadRestrictByExamTip: Only download personalised sheet files for participants also registered to a certain exam? PersonalisedSheetFilesDownloadAnonymousField: Anonymisation -PersonalisedSheetFilesDownloadAnonymousFieldTip: Should the ZIP-archive of personalised files be anonymised (it would then contain no immediately identifiable information regard the course type participants) or should directory names be decorated with an identifiable feature of the user and the files of meta information contain additional personal data? +PersonalisedSheetFilesDownloadAnonymousFieldTip: Should the ZIP-archive of personalised files be anonymised (it would then contain no immediately identifiable information regard the course category participants) or should directory names be decorated with an identifiable feature of the user and the files of meta information contain additional personal data? PersonalisedSheetFilesMetaFilename uid: meta-information_#{toPathPiece uid}.yaml PersonalisedSheetFilesArchiveName tid ssh csh shn: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-#{foldedCase shn}-personalised_files SheetGeneratePseudonym: Generate diff --git a/messages/uniworx/categories/courses/submission/en-eu.msg b/messages/uniworx/categories/courses/submission/en-eu.msg index 0467e4d20..5cd8a1330 100644 --- a/messages/uniworx/categories/courses/submission/en-eu.msg +++ b/messages/uniworx/categories/courses/submission/en-eu.msg @@ -49,10 +49,10 @@ SubmissionArchive: Zip-archive of submission files SubmissionArchiveCorrected: Zip-archive of submission files including corrections SubmissionFile: Submission file SubmissionFiles: Submitted files -EmailInvitationWarningPrevCoSubmittors: This address could not be matched to any course type participant with whom you have submitted for this course type before. An Invitation will be sent via email. -EmailInvitationWarningCourseParticipants: This address coulde not be matched to any course type participant. An Invitation will be sent via email. -MultiUserFieldExplanationPrevCoSubmittors: This input searches through the addresses of all course type participants for whom it could be determined, that you have already submitted with that person for this course. -MultiUserFieldExplanationCourseParticipants: This input searches through the addresses of all course type participants. +EmailInvitationWarningPrevCoSubmittors: This address could not be matched to any course category participant with whom you have submitted for this course category before. An Invitation will be sent via email. +EmailInvitationWarningCourseParticipants: This address coulde not be matched to any course category participant. An Invitation will be sent via email. +MultiUserFieldExplanationPrevCoSubmittors: This input searches through the addresses of all course category participants for whom it could be determined, that you have already submitted with that person for this course. +MultiUserFieldExplanationCourseParticipants: This input searches through the addresses of all course category participants. SubmissionAlreadyExistsFor email: #{email} already has a submission for this sheet. SubmissionUsersEmpty: Submissions may not be created without submittors. SubmissionUserAlreadyAdded: This user is already configured as a submittor @@ -155,7 +155,7 @@ SubmissionSomeUsersDuplicateWarning: Some submittors are also submittors for a d EMailUnknown email: Email #{email} does not belong to any known user. CorDeficitProportion: Deficit (proportion) -CosubmittorTip: Invitations are sent via email to exactly those addresses for which it cannot be determined, that you have already submitted for this course type with the associated person, at least once. If one of the specified addresses can be matched to a person with whom you have submitted at least once for this course type already, the name of that person will be shown and the submission will immediately be made in their name as well. +CosubmittorTip: Invitations are sent via email to exactly those addresses for which it cannot be determined, that you have already submitted for this course category with the associated person, at least once. If one of the specified addresses can be matched to a person with whom you have submitted at least once for this course category already, the name of that person will be shown and the submission will immediately be made in their name as well. CorrDownload: Download SubmissionDownloadAnonymous: Anonymized SubmissionDownloadSurnames: With surnames @@ -237,9 +237,9 @@ SubmissionFilterAuthorshipStatementCurrent: Current wording SubmissionNoUsers: This submission has no associated users! -CsvColumnCorrectionTerm: Term of the course type of the submission -CsvColumnCorrectionSchool: School of the course type of the submission -CsvColumnCorrectionCourse: Shorthand of the course type of the submission +CsvColumnCorrectionTerm: Term of the course category of the submission +CsvColumnCorrectionSchool: School of the course category of the submission +CsvColumnCorrectionCourse: Shorthand of the course category of the submission CsvColumnCorrectionSheet: Name of the sheet of the submission CsvColumnCorrectionSubmission: Number of the submission (uwa…) CsvColumnCorrectionSurname: Submittor's surnames, separated by semicolon (;) diff --git a/messages/uniworx/categories/courses/tutorial/de-de-formal.msg b/messages/uniworx/categories/courses/tutorial/de-de-formal.msg index 5a4cef6b6..21d9960f2 100644 --- a/messages/uniworx/categories/courses/tutorial/de-de-formal.msg +++ b/messages/uniworx/categories/courses/tutorial/de-de-formal.msg @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022 Winnie Ros +# SPDX-FileCopyrightText: 2022-25 Winnie Ros ,Steffen Jost # # SPDX-License-Identifier: AGPL-3.0-or-later @@ -36,6 +36,7 @@ TutorialDelete: Löschen TutorialsHeading: Kurse TutorialNew: Neuer Kurs TutorialRegisteredSuccess tutn@TutorialName: Erfolgreich zum Kurs #{tutn} angemeldet +TutorialRegisteredFail tutn@TutorialName: Anmeldung zum Kurs #{tutn} fehlgeschlagen. Existiert bereits eine Anmeldung? TutorialDeregisteredSuccess tutn@TutorialName: Erfolgreich vom Kurs #{tutn} abgemeldet MailSubjectTutorInvitation tid@TermId ssh@SchoolId csh@CourseShorthand tutn@TutorialName: [#{tid}-#{ssh}-#{csh}] Einladung zum Ausbilder für #{tutn} TutorInviteHeading tutn@TutorialName: Einladung zum Ausbilder/zur Ausbilderin für #{tutn} @@ -49,4 +50,21 @@ TutorialUserGrantQualification: Qualifikation vergeben TutorialUserRenewQualification: Qualifikation regulär verlängern TutorialUserRenewedQualification n@Int: Qualifikation für #{tshow n} Kurs-#{pluralDE n "Teilnehmer:in" "Teilnehmer:innen"} regulär verlängert TutorialUserGrantedQualification n@Int: Qualifikation erfolgreich an #{tshow n} Kurs-#{pluralDE n "Teilnehmer:in" "Teilnehmer:innen"} vergeben -CommTutorial: Kursmitteilung \ No newline at end of file +TutorialUserAssignExam: Zur Prüfung einteilen +TutorialUserExamAssignedFor n@Int m@Int p@Text: #{n}/#{m} zur Prüfung #{p} eingeteilt +CommTutorial: Kursmitteilung +TutorialDrivingPermit: Führerschein +TutorialEyeExam: Sehtest +TutorialNote: Kursnotiz +TutorialDayAttendance day@Text: Anwesenheit #{day} +TutorialDayNote day@Text: Anwesenheitsnotiz #{day} +TutorialParticipantsDayEdits day@Text: Kursteilnehmer-Tagesnotizen aktualisiert für #{day} + +PossibleCheckResults: Mögliche Prüfungsergebnisse +CheckEyePermitMissing: Sehtest oder Führerschein fehlen noch +CheckEyePermitIncompatible: Sehtest und Führerschein passen nicht zusammen + +GenTutActOccCopyLast: Prüfungstermine von früherem Kurs kopieren +GenTutActOccCopyWeek: Prüfungstermine von früherer Woche kopieren +GenTutActOccEdit: Relevante Prüfungstermine bearbeiten +GenTutActShowExam: Prüfungsergebnisse der Kursteilnehmer anzeigen \ No newline at end of file diff --git a/messages/uniworx/categories/courses/tutorial/en-eu.msg b/messages/uniworx/categories/courses/tutorial/en-eu.msg index 20df36d50..7d3a8468d 100644 --- a/messages/uniworx/categories/courses/tutorial/en-eu.msg +++ b/messages/uniworx/categories/courses/tutorial/en-eu.msg @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022 Winnie Ros +# SPDX-FileCopyrightText: 2022-25 Winnie Ros ,Steffen Jost # # SPDX-License-Identifier: AGPL-3.0-or-later @@ -12,16 +12,16 @@ TutorialEdited tutn: Successfully edited course #{tutn} TutorialEditHeading tutn: Edit #{tutn} TutorEmail: Email TutorialTutorAlreadyAdded: An user with this email address is already registered as instructor -TutorialNameTip: Needs to be unique within the course type +TutorialNameTip: Needs to be unique within the course category TutorialTypePlaceholder: Tutorial, Driving lesson, ... TutorialTypeTip: Only for informational purposes -TutorialRegGroupTip: Course type participants may only register for a maximum of one course per registration group. Courses that do not have a registration group are treated as being in different registration groups +TutorialRegGroupTip: Course category participants may only register for a maximum of one course per registration group. Courses that do not have a registration group are treated as being in different registration groups TutorialRegGroup: Registration group TutorialTutorControlled: Instructors may edit course TutorialTutorControlledTip: Should instructors be allowed to edit arbitrary aspects of this course (name, registration group, room, time, other instructors, ...) at will? TutorialCapacity: Capacity TutorialCapacityNonPositive: Capacity may not be negative -TutorialCapacityTip: Limits how many course type participants may register for this course +TutorialCapacityTip: Limits how many course category participants may register for this course TutorialRoomHiddenTip: Should the room only be displayed to course participants? RegisterFrom: Enrolment starts RegisterTo: Enrolment ends @@ -36,6 +36,7 @@ TutorialDelete: Delete TutorialsHeading: Courses TutorialNew: New course TutorialRegisteredSuccess tutn: Successfully registered for the course #{tutn} +TutorialRegisteredFail tutn: Registering for the course #{tutn} failed. Probably already registered? TutorialDeregisteredSuccess tutn: Successfully de-registered for the course #{tutn} MailSubjectTutorInvitation tid ssh csh tutn: [#{tid}-#{ssh}-#{csh}] Invitation to be a instructor for #{tutn} TutorInviteHeading tutn: Invitation to be instructor for #{tutn} @@ -50,4 +51,21 @@ TutorialUserGrantQualification: Grant qualification TutorialUserRenewQualification: Renew qualification TutorialUserRenewedQualification n@Int: Successfully renewed qualification #{tshow n} course #{pluralEN n "user" "users"} TutorialUserGrantedQualification n: Successfully granted qualification #{tshow n} course #{pluralEN n "user" "users"} +TutorialUserAssignExam: Register for examination +TutorialUserExamAssignedFor n@Int m@Int p@Text: #{n}/#{m} enrolled for exam #{p} CommTutorial: Course message +TutorialDrivingPermit: Driving permit +TutorialEyeExam: Eye exam +TutorialNote: Course note +TutorialDayAttendance day: Attendance #{day} +TutorialDayNote day: Attendance note #{day} +TutorialParticipantsDayEdits day: course participant day notes updated for #{day} + +PossibleCheckResults: Possible results +CheckEyePermitMissing: Eye exam or driving permit missing +CheckEyePermitIncompatible: Eye exam and driving permit are incompatible + +GenTutActOccCopyLast: Copy exam occurrences from previous course +GenTutActOccCopyWeek: Copy exam occurrences from course on previous week +GenTutActOccEdit: Edit relevant exam occurrences +GenTutActShowExam: Show exam results for course participants \ No newline at end of file diff --git a/messages/uniworx/categories/firm/de-de-formal.msg b/messages/uniworx/categories/firm/de-de-formal.msg index 8f27c24c4..f6af62a89 100644 --- a/messages/uniworx/categories/firm/de-de-formal.msg +++ b/messages/uniworx/categories/firm/de-de-formal.msg @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2023-24 Steffen Jost +# SPDX-FileCopyrightText: 2023-25 Steffen Jost # # SPDX-License-Identifier: AGPL-3.0-or-later @@ -21,8 +21,11 @@ FirmActResetSupersKeepAll: Alle behalten FirmActResetSupersRemoveAps: Nur Standardansprechpartner entfernen FirmActResetSupersRemoveAll: Alle entfernen FirmActAddSupervisors: Ansprechpartner hinzufügen -FirmActAddSupersEmpty: Es konnten keine Ansprechpartner hinzugefügt werden +FirmActAddAssociates: Firmenangehörige hinzufügen +FirmActAddSupersEmpty: Es konnten keine neuen Ansprechpartner hinzugefügt werden! FirmActAddSupersSet n@Int64 postal@(Maybe Bool): #{n} Standardansprechpartner geändert #{maybeBoolMessage postal "" "und auf Briefversand geschaltet" "und Benachrichtigungen per Email gesetzt"}, aber nicht nicht aktiviert. +FirmActAddAssocsEmpty: Es konnten keine neuen Firmenangehörige hinzugefügt werden! +FirmActAddAssocs n@Int64: #{n} Firmenangehörige hinzugefügt. RemoveSupervisors ndef@Int64: #{ndef} Standardansprechpartner entfernt. FirmActChangeContactUser: Kontaktinformationen von allen Firmenangehörigen ändern FirmActChangeContactFirm: Kontaktinformationen der Firma ändern @@ -38,7 +41,7 @@ FirmUserActMkSuper: Zum Firmenansprechpartner ernennen FirmUserActChangeDetailsResult n@Int64 t@Int64: Firmenassoziation von #{n}/#{t} #{pluralDE n "Firmenangehörigen" "Firmenangehörige"} wurden aktualisiert FirmUserActChangeResult n@Int64 t@Int64: Benachrichtigungseinstellung für #{n}/#{t} #{pluralDE n "Firmenangehörigen" "Firmenangehörige"} wurden geändert FirmUserActRemoveResult uc@Int64: #{uc} #{pluralDE uc "Firmenassoziation" "Firmenassoziationen"} entfernt. -FirmRemoveSupervision sup@Int64 sub@Int64: #{noneMoreDE sup "" (tshow sup <> " Ansprechpartnerbeziehungen wegen entferntem Ansprechpartner gelöscht. ")} #{noneOneMoreDE sub "Keine Ansprechpartnerbeziehung" "Eine Ansprechpartnerbeziehung" (tshow sup <> " Ansprechpartnerbeziehungen")} wegen entferntem Angesprochenem gelöscht. +FirmRemoveSupervision sup@Int64 sub@Int64: #{noneMoreDE sup "" (tshow sup <> " Ansprechpartnerbeziehungen wegen entferntem Ansprechpartner gelöscht. ")} #{noneOneMoreDE sub "Keine Ansprechpartnerbeziehung" "Eine Ansprechpartnerbeziehung" (tshow sup <> " Ansprechpartnerbeziehungen")} wegen entferntem Klienten gelöscht. FirmNewSupervisor: Neue individuelle Ansprechpartner hinzufügen FirmSetSupervisor: Existierende Ansprechpartner hinzufügen FirmSetSupersReport nusr@Int64 nspr@Int64 nrem@Int64: Für #{nusr} Firmenangehörige wurden #{nspr} individuelle Ansprechpartner eingetragen#{bool "." (" und " <> tshow nrem <> " individuelle Ansprechpartnerbeziehungen gelöscht.") (nrem >0)} @@ -70,9 +73,25 @@ TableSuperior: Vorgesetzter TableIsDefaultReroute: Standardumleitung FormFieldPostal: Benachrichtigungseinstellung FormFieldPostalTip: Gilt für alle Benachrichtigungen an diese Person, nicht nur für Umleitungen an diesen Ansprechpartner +FormFieldPinPass: Sensible PDF-E-Mail-Anhänge mit Passwort schützen? +FormFieldPinPassRemove: Passwortschutz für PDF-E-Mail-Anhänge entfernen? FirmSupervisionKeyData: Kennzahlen Ansprechpartner CompanyUserPriority: Firmenpriorität CompanyUserPriorityTip: Firmenpriorität ist lediglich relativ zu anderen Firmenassoziation der Person CompanyUserUseCompanyAddress: Verwendet Firmenkontaktaddresse CompanyUserUseCompanyAddressTip: sofern im Benutzer keine Postanschrift hinterlegt ist CompanyUserUseCompanyPostalError: Postalische Adresse muss leer bleiben, damit die Firmenanschrift genutzt wird! +CompanySupervisorCompanyMissing fsh@CompanyShorthand: Empfänger ist nicht mit Firma #{fsh} aus Ansprechpartnerbeziehung assoziiert +CompanySuperviseeCompanyMissing fsh@CompanyShorthand: Klient ist nicht mit Firma #{fsh} aus Ansprechpartnerbeziehung assoziiert +FirmSupervisionRInfo: In folgenden Ansprechpartnerbeziehungen gehören entweder der Ansprechpartner oder der Klient nicht mehr der Firma an, welche als Begründung für die Beziehung eingetragen ist. +SupervisionViolationChoice: Firmenassoziation fehlt für +SupervisionViolationEither: Egal +SupervisionViolationSupervisor: Ansprechpartner +SupervisionViolationClient: Klient +SupervisionViolationBoth: Beide +SupervisionsRemoved n@Int64 m@Int64: #{n}/#{m} #{pluralDE n "Ansprechpartnerbeziehung" "Ansprechpartnerbeziehungen"} entfernt. +SupervisionsEdited n@Int64 m@Int64: #{n}/#{m} #{pluralDE n "Ansprechpartnerbeziehung" "Ansprechpartnerbeziehungen"} geändert. +ASChangeCompany: Begründungen für Ansprechpartnerbeziehung abändern +ASRemoveAssociation: Ansprechpartnerbeziehung löschen +FirmNameNotFound: Keine Firma mit diesen Namen/Kürzel/AVS-Nr gefunden. +FirmNameAmbiguous: Firmenname/-kürzel oder AVS-Nr ist nicht eindeutig. diff --git a/messages/uniworx/categories/firm/en-eu.msg b/messages/uniworx/categories/firm/en-eu.msg index fe4dbc045..d918c3809 100644 --- a/messages/uniworx/categories/firm/en-eu.msg +++ b/messages/uniworx/categories/firm/en-eu.msg @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2023-24 Steffen Jost +# SPDX-FileCopyrightText: 2023-25 Steffen Jost # # SPDX-License-Identifier: AGPL-3.0-or-later @@ -15,14 +15,17 @@ FirmActionInfo: Affects alle company associates under your supervision. FirmActNotify: Send message FirmActResetSupervision: Reset supervisors for all company associates FirmActResetSuperKeep: Additionally keep existing supervisors of company associates? -FirmActRemoveSupers: Terminate all company related supervisonships? +FirmActRemoveSupers: Terminate all company related supervisionships? FirmActResetMutualSupervision: Supervisors supervise each other FirmActResetSupersKeepAll: Keep all FirmActResetSupersRemoveAps: Remove default supervisors only FirmActResetSupersRemoveAll: Remove all FirmActAddSupervisors: Add supervisors -FirmActAddSupersEmpty: No supervisors added +FirmActAddAssociates: Associate users with company +FirmActAddSupersEmpty: No new supervisors added! FirmActAddSupersSet n postal: #{n} default company supervisors changed #{maybeBoolMessage postal "" "and switched to postal notifications" "and switched to email notifications"}, but not yet activated. +FirmActAddAssocsEmpty: No new company associated users added! +FirmActAddAssocs n: #{pluralENsN n "company associated user"} added. RemoveSupervisors ndef: #{ndef} default supervisors removed. FirmActChangeContactUser: Change contact data for all company associates FirmActChangeContactFirm: Change company contact data @@ -70,9 +73,25 @@ TableSuperior: Superior TableIsDefaultReroute: Default reroute FormFieldPostal: Notification type FormFieldPostalTip: Affects all notifications to this person, not just reroutes to this supervisor +FormFieldPinPass: Protect sensitive PDF e-mail attachments by password? +FormFieldPinPassRemove: Remove password protection for PDF e-mail attachments? FirmSupervisionKeyData: Supervision key data CompanyUserPriority: Company priority CompanyUserPriorityTip: Company priority is relative to other company associations for a user CompanyUserUseCompanyAddress: Use company postal address CompanyUserUseCompanyAddressTip: if and only if the postal address of the user is empty CompanyUserUseCompanyPostalError: Individual postal address must left empty for the company address to be used! +CompanySupervisorCompanyMissing fsh: Receiver is not associated with #{fsh} given as reroute reason +CompanySuperviseeCompanyMissing fsh: Supervisee is not associated with #{fsh} detailed as supervisionship reason +FirmSupervisionRInfo: Shown are supervisionships where either supervisor or supervisee no longer belong to the company associated with the supervisionship. +SupervisionViolationChoice: Company association missing for +SupervisionViolationEither: anyone +SupervisionViolationSupervisor: Supervisor +SupervisionViolationClient: Supervisee +SupervisionViolationBoth: both +SupervisionsRemoved n m: #{n}/#{m} #{pluralENs n "Supervisionship"} removed. +SupervisionsEdited n m: #{n}/#{m} #{pluralENs n "Supervisionship"} edited. +ASChangeCompany: Change supervisionship annotations +ASRemoveAssociation: Delete supervisionship +FirmNameNotFound: No company found with this name/shorthand or AVS number. +FirmNameAmbiguous: Company name/shorthand or AVS number is amiguous. \ No newline at end of file diff --git a/messages/uniworx/categories/info/en-eu.msg b/messages/uniworx/categories/info/en-eu.msg index a370481a8..bb14ddb88 100644 --- a/messages/uniworx/categories/info/en-eu.msg +++ b/messages/uniworx/categories/info/en-eu.msg @@ -6,7 +6,7 @@ HeadingLegal: Legal InfoSupervisorTitle: Information for supervisors InfoLecturerTitle: Information for course administrators InfoLecturerCourses: Courses -InfoLecturerExercises: Course type exercises +InfoLecturerExercises: Course category exercises InfoLecturerTutorials: Courses InfoLecturerExams: Exams LecturerInfoTooltipNew: New feature @@ -20,8 +20,8 @@ KnownBugs: Known bugs ImplementationDetails: Implementation Clone: Cloning Administrator: Administrator -CommCourse: Course type message +CommCourse: Course category message Corrector: Corrector -DefinitionCourseEvents: Course type occurrences -DefinitionCourseNews: Course type news +DefinitionCourseEvents: Course category occurrences +DefinitionCourseNews: Course category news Invitations: Invitations diff --git a/messages/uniworx/categories/print/de-de-formal.msg b/messages/uniworx/categories/print/de-de-formal.msg index f14def9d8..df4a729fb 100644 --- a/messages/uniworx/categories/print/de-de-formal.msg +++ b/messages/uniworx/categories/print/de-de-formal.msg @@ -17,7 +17,7 @@ PrintJobReprint n@Int m@Int: #{n}/#{m} #{pluralDE n "Druckauftrag" "Druckaufräg PrintJobAcknowledgeFailed: Keine Druckaufträge bestätigt aufgrund zwischenzeitlicher Änderungen. Bitte die Seite im Browser aktualisieren! PrintJobAcknowledgeQuestion n@Int d@Text: #{n} #{pluralDE n "Druckauftrag" "Druckaufräge"} vom #{d} als gedruckt und versendet bestätigen? PrintJobAcknowledgements: Versanddatum von Briefen an -PrintRecipient: Empfänger +PrintRecipient: Empfänger:innen PrintAffected: Betroffener PrintSender !ident-ok: Sender PrintCourse: Kursarten @@ -28,5 +28,8 @@ PrintLmsUser: E‑Learning Id PrintJobs: Druckaufräge PrintLetterType: Brieftypkürzel -MCActDummy: Platzhalter +MCActResendEmail: E‑Mail Kopie versenden +MCActResendEmailTooltip: Eine unveränderte Kopie der E‑Mail erneut versenden. Nur die vorherigen Empfänger werden offiziell aufgeführt, sie erhalten jedoch keine neue Kopie. +MCActResendEmailInfo n@Int recv@Text: #{pluralDEnN n "E‑Mail Kopie"} wurden an #{recv} versandt. + CCActDummy: Platzhalter \ No newline at end of file diff --git a/messages/uniworx/categories/print/en-eu.msg b/messages/uniworx/categories/print/en-eu.msg index d757cf2cf..11b58c159 100644 --- a/messages/uniworx/categories/print/en-eu.msg +++ b/messages/uniworx/categories/print/en-eu.msg @@ -20,7 +20,7 @@ PrintJobAcknowledgements: Sent-dates for Letter to PrintRecipient: Recipient PrintAffected: Affetcted PrintSender: Sender -PrintCourse: Course type +PrintCourse: Course category PrintQualification: Qualification PrintPDF: PDF PrintManualRenewal: Manual sending of an apron driver's licence renewal letter @@ -28,5 +28,8 @@ PrintLmsUser: E‑learning id PrintJobs: Print jobs PrintLetterType: Letter type shorthand -MCActDummy: Placeholder +MCActResendEmail: Resend email copy +MCActResendEmailTooltip: Resend an unchanged copy of the email. Only previous recipients will officially be listed, but they will not receive another copy. +MCActResendEmailInfo n recv: #{n} #{noneOneMoreEN n "email copy" "email copy" "email copies"} were sent to #{recv} only. + CCActDummy: Placeholder \ No newline at end of file diff --git a/messages/uniworx/categories/qualification/de-de-formal.msg b/messages/uniworx/categories/qualification/de-de-formal.msg index b5d3a36bc..e5e196fb5 100644 --- a/messages/uniworx/categories/qualification/de-de-formal.msg +++ b/messages/uniworx/categories/qualification/de-de-formal.msg @@ -1,26 +1,30 @@ -# SPDX-FileCopyrightText: 2022 Steffen Jost ,Steffen Jost +# SPDX-FileCopyrightText: 2022-25 Steffen Jost ,Steffen Jost # # SPDX-License-Identifier: AGPL-3.0-or-later QualificationShort: Kürzel QualificationName: Qualifikation QualificationDescription: Beschreibung +QualificationValidReason qsh@Text: #{qsh} Gültigkeit QualificationValidIndicator: Gültigkeit QualificationValidDuration: Gültigkeitsdauer -QualificationAuditDuration: Aufbewahrung Audit Log -QualificationAuditDurationTooltip n@Int: Optionaler Zeitraum zur Löschung von E‑Learning Daten. Hinweis: Der E‑Learning Server kann seine anonymisierten Daten schon früher löschen, aber spätestens #{n} Tage nach Abschluss. -QualificationAuditDurationReuseError: Diese Qualifikation nutzt das E‑Learning einer anderen Qualifikation, für die derzeit keinen Löschzeitraum konfiguriert wurde. +QualificationAuditDuration: Aufbewahrungszeitraum E‑Learning Log +QualificationAuditDurationTooltip: Anzahl Tage zur Löschung von E‑Learning Daten. Hinweis: Der E‑Learning Server kann seine anonymisierten Daten schon früher löschen. +QualificationAuditDurationReuseInfo: Aufbewahrungszeitraum E‑Learning Log wird ignoriert, da das E‑Learning einer anderen Qualifikation mitbenutzt wird. QualificationRefreshWithin: Erneurerungszeitraum QualificationRefreshWithinTooltip: Optionaler Zeitraum vor Ablauf für eine Benachrichtigung per Email. Bei aktiviertem automatischem E‑Learning wird dieses gestartet und die Benachrichtigung erfolgt per Brief oder Email. QualificationRefreshReminder: Zweite Erinnerung QualificationRefreshReminderTooltip: Optionaler Zeitraum vor Ablauf zur Versendung einer zweiten Erinnerung per Brief oder Email mit identischen E‑Learning Zugangsdaten, sofern die Qualifikation noch gültig und das E‑Learning noch offen ist. QualificationElearningStart: Wird das E‑Learning automatisch gestartet? QualificationElearningRenew: Verlängert ein erfolgreiches E‑Learning die Qualifikation automatisch um die reguläre Gültigkeitsdauer? -QualificationElearningLimit: Ist die Anzahl der E‑Learning Versuche limitiert? +QualificationElearningLimit: Limit Anzahl E‑Learning Versuche +QualificationElearningLimitExplain: Ist die Anzahl der E‑Learning Versuche limitiert? QualificationElearningLimitMax n@Int: Maximal #{n} Versuche QualificationElearningNoLimit: Nicht limitiert QualificationExpiryNotification: Ungültigkeitsbenachrichtigung? -QualificationExpiryNotificationTooltip: Nutzer werden benachrichtigt, wenn die Qualifikation ungültig wird, sofern der jeweilige Nutzer in seinen Benutzereinstellungen diese Art Benachrichtigung aktiviert hat. +QualificationExpiryNotificationTooltip: Nutzer werden benachrichtigt, wenn die Qualifikation ungültig wird, sofern der jeweilige Nutzer in seinen Benutzereinstellungen diese Art Benachrichtigung nicht deaktiviert hat. +QualificationAvsLicence: AVS Qualifikation +QualificationSapId: SAP Qualifikations Id TableQualificationCountActive: Aktive TableQualificationCountActiveTooltip: Anzahl Personen mit momentan gültiger Qualifikation TableQualificationCountTotal: Gesamt @@ -70,7 +74,7 @@ TableLmsNotified: Versand Benachrichtigung TableLmsNotifiedTooltip: Benachrichtigungen werden erst versendet wenn das LMS bestätigt die Eröffnung des E‑Learning für den Benutzer bestätigt hat, was ein paar Stunden dauern kann! TableLmsEnded: Beendet TableLmsStatus: Status E‑Learning -TableLmsStatusTooltip mbMonth@(Maybe Int): Zeigt #{maybeToMessage "bis zu " (fmap (flip pluralDEeN "Monat") mbMonth) " nach Abschluss"} den letzten Zustand eines E‑Learnings an: +TableLmsStatusTooltip n@Int: Zeigt bis zu #{pluralDEeN n "Tag"} nach Abschluss den letzten Zustand eines E‑Learnings an: TableLmsStatusDay: Datum letzte Statusänderung E‑Learning TableLmsSuccess: Bestanden TableLmsLock: Gesperrt @@ -80,6 +84,7 @@ LmsStatusExpired: Durchgefallen nach Fristablauf LmsStatusSuccess: E#{nonBreakableDash}Learning bestanden LmsStatusPlanned: E#{nonBreakableDash}Learning wird gerade noch eröffnet (nur für Admin sichtbar) LmsStatusDelay: Hinweis: Statusänderung können in seltenen Fällen mehrere Stunden bis zur Anzeige benötigen. +FilterLmsLongValid: Längerfristig gültig FilterLmsValid: Aktuell gültig FilterLmsRenewal: Erneuerung anstehend FilterLmsNotified: Benachrichtigt @@ -98,8 +103,8 @@ LmsReportInsert: Neues LMS Ereignis LmsReportUpdate: LMS Ereignis Aktualisierung LmsReportCsvExceptionDuplicatedKey: CSV-Import LmsReport fand uneindeutigen Schlüssel LmsDirectUpload: Direkter Upload für automatisierte Systeme -LmsErrorNoRefreshElearning: Fehler: E‑Learning wird nicht automatisch gestartet, da die Zeitspanne für den Erneurerungszeitraum nicht festgelegt wurde! -LmsErrorNoRenewElearning: Fehler: Erfoglreiches E‑Learning verlängert die Qualifikation nicht automatisch, da die Gültigkeitsdauer nicht festgelegt wurde! +LmsErrorNoRefreshElearning: Fehler: E‑Learning wird nicht automatisch gestartet, da die Zeitspanne für den Erneuerungszeitraum nicht festgelegt wurde! +LmsErrorNoRenewElearning: Fehler: Erfolgreiches E‑Learning verlängert die Qualifikation nicht automatisch, da die Gültigkeitsdauer nicht festgelegt wurde! MailSubjectQualificationRenewal qname@Text: Qualifikation #{qname} muss demnächst erneuert werden MailSubjectQualificationExpiry qname@Text: Qualifikation #{qname} läuft demnächst ab MailSubjectQualificationExpired qname@Text: Qualifikation #{qname} ist ab sofort ungültig @@ -116,7 +121,7 @@ QualificationActBlock: Entziehen QualificationActUnblock: Entzug aufheben QualificationActRenew: Qualifikation regulär verlängern QualificationActGrant: Qualifikation vergeben -QualificationActGrantWarning: Diese Funktion ist nur für seltene Ausnahmefälle vorgesehen! Ein Entzug wird ggf. aufgehoben. +QualificationActGrantWarning: Diese Funktion ist nur für seltene Ausnahmefälle vorgesehen! Ein Entzug wird ggf. aufgehoben; E‑Learning wird ggf. beendet. QualificationActStartELearning: E‑Learning für gültige Inhaber (neu) starten QualificationActStartELearningStatus l@QualificationShorthand n@Int m@Int: E‑Learning #{l} für #{n}/#{m} Teilnehmer (neu) gestartet. Hinweis: Es kann länger dauern, bis das LMS tatsächlich startet. QualificationStatusBlock l@QualificationShorthand n@Int m@Int: #{n}/#{m} #{l} entzogen @@ -126,8 +131,7 @@ LmsRenewalInstructions: Weitere Anweisungen zur Verlängerung finden Sie im ange LmsNoRenewal: Leider kann diese Qualifikation nicht alleine durch E‑Learning verlängert werden. Bitte setzen Sie sich mit uns in Verbindung, wenn Sie die Qualifikation verlängern möchten und noch nicht wissen, wie Sie das tun können. Ignorieren Sie diese automatisch generierte Erinnerung, falls Sie sich bereits um die Verlängerung gekümmert haben LmsRenewalReminder: Erinnerung LmsActNotify: Benachrichtigung E‑Learning erneut per Post oder E-Mail versenden -LmsActRenewPin: Neues zufällige E‑Learning Passwort zuweisen -LmsActRenewNotify: Neue zufällige E‑Learning Passwort zuweisen und Benachrichtigung per Post oder E-Mail versenden +LmsActRenewNotify: Neues zufälliges E‑Learning Passwort zuweisen und Benachrichtigung per Post oder E-Mail versenden LmsActReset: E‑Learning Fehlversuche zurücksetzen und entsperren LmsActResetInfo: E‑Learning Login, Passwort und Fortschritt bleiben unverändert, eine neue Benachrichtigung ist nicht notwendig. Nur möglich für bereits gesperrte Lerner. Es kann bis zu 2 Stunden dauern, bis das LMS die Anfrage umgesetzt hat. LmsActResetFeedback n@Int m@Int: Für #{n}/#{m} E‑Learning Nutzer wurden alle Fehlversuche zurückgesetzt. @@ -136,6 +140,11 @@ LmsActRestartWarning: Das vorhandene E‑Learning wird komplett gelöscht! Für LmsActRestartFeedback n@Int m@Int: #{n}/#{m} E‑Learning Nutzer wurden komplett neu gestartet mit neuem Login und Passwort. LmsActRestartExtend: Gültig bis ggf. erhöhen für die nächsten # Tage LmsActRestartUnblock: Entzug ggf. aufheben +LmsActTerminate: E‑Learning abbrechen +LmsActTerminateInfo: Ein späterer automatischer Neustart des E‑Learning wird dadurch nicht verhindert, wenn eine gültige Qualifikation bald abläuft und E‑Learning für diese Qualifikation generell automatisch startet. +LmsActTerminateFeedback n@Int m@Int: #{n}/#{m} E‑Learning Nutzer wurden zur Löschung freigegeben. +LmsActTerminated n@Int: E‑Learning für #{n} Nutzer wurde beendet. +LmsActTerminateWarning: ACHTUNG: Ein Ergebnis würde ohne Warnung verworfen, sollte ein Nutzer sein E‑Learning absolvieren, bevor die Löschung beim E‑learning Server effektiv wurde. LmsStateOpen: E‑Learning offen LmsStatusLocked: E‑Learning gesperrt, wird ggf. bald geöffnet LmsStatusUnlocked: E‑Learning offen, wird ggf. bald gesperrt @@ -147,3 +156,18 @@ LmsActionFailed n@Int: Aktion nicht durchgeführt für #{n} #{pluralDE n "Person LmsStarted: E‑Learning eröffnet BtnLmsEnqueue: Nutzer mit ablaufenden Qualifikationen zum E‑Learning anmelden und benachrichtigen BtnLmsDequeue: Nutzer mit beendetem E‑Learning aufräumen und ggf. benachrichtigen + +QualificationEditNote: Hinweis: Die Änderungen treten sofort in Kraft. Bitte vergewissern Sie sich vorher, dass alles korrekt eingestellt wurde! +QualificationCreated qsh@Text: Qualifikation #{qsh} wurde angelegt. +QualificationEdit qsh@Text: Qualifikation #{qsh} wurde geändert. +QualFormErrorDuplShort qsh@Text: Es gibt bereits eine Qualifikation mit Kürzel #{qsh}! +QualFormErrorDuplName qname@Text: Es gibt bereits eine Qualifikation mit Namen #{qname}! +QualFormErrorSshMismatch: Qualifikationänderungsformular enthält unglültige Bereichsangabe. Bitte versuchen Sie erneut, nachdem Sie Seite neu geladen haben. + +LmsOrphans: Verwaiste Logins +LmsOrphanNr n@Int: #{n} verwaiste E‑Learning Logins für diese Qualifikation erkannt. +LmsOrphanSeenFirst: Zuerst erkannt +LmsOrphanSeenLast: Zuletzt erhalten +LmsOrphanDeletedLast: Zuletzt Löschung beantragt +LmsOrphanReason: Bemerkung +LmsOrphanPreviewFltr: Löschungen bei nächstem Abruf anfordern? \ No newline at end of file diff --git a/messages/uniworx/categories/qualification/en-eu.msg b/messages/uniworx/categories/qualification/en-eu.msg index b8fb5c38e..077dd530c 100644 --- a/messages/uniworx/categories/qualification/en-eu.msg +++ b/messages/uniworx/categories/qualification/en-eu.msg @@ -1,26 +1,30 @@ -# SPDX-FileCopyrightText: 2022 Steffen Jost ,Steffen Jost +# SPDX-FileCopyrightText: 2022-25 Steffen Jost ,Steffen Jost # # SPDX-License-Identifier: AGPL-3.0-or-later QualificationShort: Shorthand QualificationName: Qualification QualificationDescription: Description +QualificationValidReason qsh: #{qsh} Validity QualificationValidIndicator: Validity QualificationValidDuration: Validity period QualificationAuditDuration: Audit log retention period -QualificationAuditDurationTooltip n@Int: Optional period for deletion of e‑learning data. Note that the e‑learning server may delete its anonymised data earlier, at most #{n} days after closing. -QualificationAuditDurationReuseError: This qualification reuses the e‑learning from another qualification, which has no audit duration configured. +QualificationAuditDurationTooltip: Days for deletion of e‑learning data. Note that the e‑learning server may delete its anonymised data earlier. +QualificationAuditDurationReuseInfo: E‑learning audit log retention period ignore, since the e‑learning from another qualification is reused. QualificationRefreshWithin: Refresh within QualificationRefreshWithinTooltip: Optional period before expiry to send a notification by email. If e‑learning is set to start automatically, it will be started and e‑learning credentials are send with this notification by post or email. QualificationRefreshReminder: Second reminder QualificationRefreshReminderTooltip: Optional period before expiry to send a second notification by post or email once more, including the existing credentials, provided that the e‑learning is still undecided and the qualification has not yet expired. QualificationElearningStart: Is e‑learning automatically started? QualificationElearningRenew: Does successful e‑learning automatically extend a qualification by the default validity period? -QualificationElearningLimit: Is the number of e‑learning attempts limited? +QualificationElearningLimit: Limit of e‑learning attempts +QualificationElearningLimitExplain: Is the number of e‑learning attempts limited? QualificationElearningLimitMax n: #{n} attempts maximum QualificationElearningNoLimit: No limit -QualificationExpiryNotification: Invalidity notification? -QualificationExpiryNotificationTooltip: Qualification holder are notfied upon invalidity, provided they have activated such notification in their user settings. +QualificationExpiryNotification: Notification upon invalidation? +QualificationExpiryNotificationTooltip: Qualification holder are notfied upon invalidation, provided they have not deactivated such notification in their user settings. +QualificationAvsLicence: AVS Qualification +QualificationSapId: SAP Qualification Id TableQualificationCountActive: Active TableQualificationCountActiveTooltip: Number of currently valid qualification holders TableQualificationCountTotal: Total @@ -67,10 +71,10 @@ TableLmsStaff: Staff? TableLmsStarted: Started TableLmsReceived: Last update TableLmsNotified: Notification sent -TableLmsNotifiedTooltip: Notfications are not sent before the LMS acknowledges the opening of the e‑learning course type for the user, which may take several hours! +TableLmsNotifiedTooltip: Notfications are not sent before the LMS acknowledges the opening of the e‑learning course category for the user, which may take several hours! TableLmsEnded: Ended TableLmsStatus: Status e‑learning -TableLmsStatusTooltip mbMonth: Shows #{maybeToMessage "for up to " (fmap (flip pluralENsN "month") mbMonth) " after closure"} the last e#{nonBreakableDash}learning status change: +TableLmsStatusTooltip n: Shows for up to #{pluralENsN n "day"} after closure the last e#{nonBreakableDash}learning status change: TableLmsStatusDay: Date of last e‑learning status change TableLmsSuccess: Completed TableLmsLock: Locked @@ -80,6 +84,7 @@ LmsStatusExpired: Failed due to expiry LmsStatusSuccess: Passed LmsStatusPlanned: E#{nonBreakableDash}learning is about to be opened soon (visible to Admins only) LmsStatusDelay: Note that status changes may occassionaly require more than a hour to be displayed here. +FilterLmsLongValid: Long-term valid FilterLmsValid: Currently valid FilterLmsRenewal: Renewal due FilterLmsNotified: Notified @@ -116,7 +121,7 @@ QualificationActBlock: Revoke QualificationActUnblock: Clear revocation QualificationActRenew: Renew qualification QualificationActGrant: Grant qualification -QualificationActGrantWarning: Use with caution in rare exceptional cases only! Any revocation will be undone. +QualificationActGrantWarning: Use with caution in rare exceptional cases only! Any revocation will be undone; any e‑learning terminated QualificationActStartELearning: Manually (re)start e‑learning for valid qualification holders QualificationActStartELearningStatus l n m: E‑learning #{l} (re)started for #{n}/#{m} users. Note: It may take a while, until the e‑learning is activated. QualificationStatusBlock l n m: #{n}/#{m} #{l} revoked @@ -126,7 +131,6 @@ LmsRenewalInstructions: Instruction on how to accomplish the renewal are enclose LmsNoRenewal: Unfortunately, this particular qualification cannot be renewed through e‑learning only. Please contact us, if you do not yet know how to renew this qualification. Ignore this automatically generated reminder email, if you have made arrangements for the renewal of this qualification already. LmsRenewalReminder: Reminder LmsActNotify: Resend e‑learning notification by post or email -LmsActRenewPin: Randomly replace e‑learning password LmsActRenewNotify: Randomly replace e‑learning password and re-send notification by post or email LmsActReset: Reset and unlock e‑learning LmsActResetInfo: E‑learning login, password and progress remain unchanged; a notification is thus not necessary. This is only possible for already failed learners. Note that the reset procedure may take up to 2 hours. @@ -135,7 +139,12 @@ LmsActRestart: Restart e‑learning LmsActRestartWarning: The existing e‑learning will be erased immediately! For drivers with a valid licence, user and password will later be generated anew and a notification will be queued as usual, which may take several hours. LmsActRestartExtend: Ensure validity for the next # days LmsActRestartUnblock: Undo any revocations -LmsActRestartFeedback n@Int m@Int: #{n}/#{m} e-learnings were completely restarted with new login credentials. +LmsActRestartFeedback n m: #{n}/#{m} e-learnings were completely restarted with new login credentials. +LmsActTerminate: Abort e‑learning +LmsActTerminateInfo: E‑learning may restart later, if a valid qualification is about to expire and e-learning starting automatically for this qualification. +LmsActTerminateFeedback n m: #{n}/#{m} e‑learnings marked for termination. +LmsActTerminated n: #{n} e‑learnings were terminated. +LmsActTerminateWarning: WARNING: Results will be discarded without warning if a user completes their e-learning in the meantime, before the deletion became effective on the e‑learning server. LmsStateOpen: E‑learning open LmsStatusLocked: E‑learning locked, may be opened soon LmsStatusUnlocked: E‑learning still open, may be locked soon @@ -147,3 +156,18 @@ LmsActionFailed n: No action for #{n} #{pluralENs n "person"}, since there was n LmsStarted: E‑learning open since BtnLmsEnqueue: Enqueue users with expiring qualifications for e‑learning and notify them BtnLmsDequeue: Dequeue users with finished e‑learning and notify failed users + +QualificationEditNote: Changes are effective immediately. Please double check that all settings are correct before submitting the changes! +QualificationCreated qsh@Text: Qualification #{qsh} created. +QualificationEdit qsh@Text: Qualification #{qsh} edited. +QualFormErrorDuplShort qsh@Text: There already exists a qualification with shorthand #{qsh}! +QualFormErrorDuplName qname@Text: There already exists a qualification with name #{qname}! +QualFormErrorSshMismatch: Qualification edit form department mismatch. Please try again after reloading the page. + +LmsOrphans: Orphaned logins +LmsOrphanNr n@Int: #{n} orphaned e‑learning login detected for this qualification. +LmsOrphanSeenFirst: First seen +LmsOrphanSeenLast: Last seen +LmsOrphanDeletedLast: Deletion requested +LmsOrphanReason: Note +LmsOrphanPreviewFltr: Deletion request next synch? diff --git a/messages/uniworx/categories/school/de-de-formal.msg b/messages/uniworx/categories/school/de-de-formal.msg index eedea789f..9d678454f 100644 --- a/messages/uniworx/categories/school/de-de-formal.msg +++ b/messages/uniworx/categories/school/de-de-formal.msg @@ -40,4 +40,6 @@ SchoolAuthorshipStatementSheetDefinitionTip: Bitte in sowohl deutscher als auch SchoolAuthorshipStatementSheetExamDefinition: Eigenständigkeitserklärung für prüfungszugehörige Übungsblattabgaben SchoolAuthorshipStatementSheetExamDefinitionTip: Bitte in sowohl deutscher als auch englischer Sprache angeben. SchoolAuthorshipStatementSheetAllowOther: Abweichende Eigenständigkeitserklärungen für nicht-prüfungszugehörige Übungsblätter erlauben? -SchoolAuthorshipStatementSheetExamAllowOther: Abweichende Eigenständigkeitserklärungen für prüfungszugehörige Übungsblätter erlauben? \ No newline at end of file +SchoolAuthorshipStatementSheetExamAllowOther: Abweichende Eigenständigkeitserklärungen für prüfungszugehörige Übungsblätter erlauben? + +DailyActDummy: Platzhalter ohne Funktion \ No newline at end of file diff --git a/messages/uniworx/categories/school/en-eu.msg b/messages/uniworx/categories/school/en-eu.msg index 32109bfa4..bfc8892ee 100644 --- a/messages/uniworx/categories/school/en-eu.msg +++ b/messages/uniworx/categories/school/en-eu.msg @@ -26,7 +26,7 @@ SchoolCreated ssh: Successfully created #{ssh} SchoolExists ssh: A department named „#{ssh}“ already exists SchoolAdmin: Admin SchoolLecturer: Lecturer -SchoolEvaluation: Course type evaluation +SchoolEvaluation: Course category evaluation SchoolExamOffice: Exam office SchoolAuthorshipStatementSection: Statements of Authorship @@ -40,4 +40,6 @@ SchoolAuthorshipStatementSheetDefinitionTip: Please enter both german and englis SchoolAuthorshipStatementSheetExamDefinition: Statement of Authorship for exam-related exercise sheets SchoolAuthorshipStatementSheetExamDefinitionTip: Please enter both german and english statements. SchoolAuthorshipStatementSheetAllowOther: Allow adaptations for exam-unrelated exercise sheets? -SchoolAuthorshipStatementSheetExamAllowOther: Allow adaptations for exam-related exercise sheets? \ No newline at end of file +SchoolAuthorshipStatementSheetExamAllowOther: Allow adaptations for exam-related exercise sheets? + +DailyActDummy: Placholder without function \ No newline at end of file diff --git a/messages/uniworx/categories/send/send_notifications/en-eu.msg b/messages/uniworx/categories/send/send_notifications/en-eu.msg index dc9b17327..eea2328c5 100644 --- a/messages/uniworx/categories/send/send_notifications/en-eu.msg +++ b/messages/uniworx/categories/send/send_notifications/en-eu.msg @@ -9,17 +9,17 @@ MailCorrectionsTitle: Assigned corrections #correctionsNotDistributed.hs + templates MailSubjectSubmissionsUnassigned csh sheetName: Corrections for #{sheetName} of #{csh} could not be distributed -MailSubmissionsUnassignedIntro n courseName termDesc sheetName: #{n} corrections for #{sheetName} of the course type #{courseName} (#{termDesc}) could not be automatically distributed. +MailSubmissionsUnassignedIntro n courseName termDesc sheetName: #{n} corrections for #{sheetName} of the course category #{courseName} (#{termDesc}) could not be automatically distributed. #courseRegistered.hs + templates MailSubjectCourseRegistered csh: You were enrolled for #{csh} MailSubjectCourseRegisteredOther displayName csh: #{displayName} was enrolled for #{csh} -MailCourseRegisteredIntro courseName termDesc: You were enrolled for the course type “#{courseName}” (#{termDesc}) -MailCourseRegisteredIntroOther displayName courseName termDesc: #{displayName} was enrolled for the course type “#{courseName}” (#{termDesc}). +MailCourseRegisteredIntro courseName termDesc: You were enrolled for the course category “#{courseName}” (#{termDesc}) +MailCourseRegisteredIntroOther displayName courseName termDesc: #{displayName} was enrolled for the course category “#{courseName}” (#{termDesc}). #examActive.hs + templates MailSubjectExamRegistrationActive csh examn: Registration is now allowed for #{examn} of #{csh} -MailExamRegistrationActiveIntro courseName termDesc examn: You may now register for #{examn} of the course type #{courseName} (#{termDesc}). +MailExamRegistrationActiveIntro courseName termDesc examn: You may now register for #{examn} of the course category #{courseName} (#{termDesc}). MailSubjectExamRegistrationSoonInactive csh examn: The registration period for #{examn} of #{csh} ends shortly MailExamRegistrationSoonInactiveIntro courseName termDesc examn: Soon you will no longer be allowed to register for #{examn} of #{courseName} (#{termDesc}). MailSubjectExamDeregistrationSoonInactive csh examn: Deregistration for #{examn} in #{csh} ends shortly @@ -27,15 +27,15 @@ MailExamDeregistrationSoonInactiveIntro courseName termDesc examn: Soon you will #examOffice.hs + templates MailSubjectExamOfficeExamResults coursen examn: Results for #{examn} of #{coursen} are now available -MailExamOfficeExamResultsIntro courseName termDesc examn: A course administrator has made the results for #{examn} of the course type #{courseName} (#{termDesc}) available. +MailExamOfficeExamResultsIntro courseName termDesc examn: A course administrator has made the results for #{examn} of the course category #{courseName} (#{termDesc}) available. MailSubjectExamOfficeExamResultsChanged coursen examn: Results for #{examn} of #{coursen} were changed -MailExamOfficeExamResultsChangedIntro courseName termDesc examn: A course administrator has changed exam results for #{examn} of the course type #{courseName} (#{termDesc}). +MailExamOfficeExamResultsChangedIntro courseName termDesc examn: A course administrator has changed exam results for #{examn} of the course category #{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 type {coursen} (#{termDesc}). +MailExamOfficeExternalExamResultsIntro coursen@CourseName termDesc@Text examn@ExamName: A course administrator has changed or initially made available the results for #{examn} of the course category {coursen} (#{termDesc}). #examOffice.hs + templates MailSubjectExamResult csh examn: Results for #{examn} in #{csh} are now available -MailExamResultIntro courseName termDesc examn: You may now view your result for #{examn} of the course type #{courseName} (#{termDesc}). +MailExamResultIntro courseName termDesc examn: You may now view your result for #{examn} of the course category #{courseName} (#{termDesc}). #sheetActive.hs + templates MailSubjectSheetActive csh sheetName: #{sheetName} in #{csh} was released @@ -47,10 +47,10 @@ MailSheetSolutionIntro courseName termDesc sheetName: You may now download the s #sheetInactive.hs + templates MailSubjectSheetSoonInactive csh sheetName: The submission period for #{sheetName} of #{csh} ends shortly -MailSheetSoonInactiveIntro courseName termDesc sheetName: Soon you will no longer be allowed to submit for #{sheetName} of the course type #{courseName} (#{termDesc}). +MailSheetSoonInactiveIntro courseName termDesc sheetName: Soon you will no longer be allowed to submit for #{sheetName} of the course category #{courseName} (#{termDesc}). MailSubjectSheetInactive csh sheetName: The submission period for #{sheetName} of #{csh} has ended -MailSheetInactiveIntro courseName termDesc sheetName n num: The submission period for #{sheetName} of the course type #{courseName} (#{termDesc}) has ended. #{noneOneMoreEN num "" "One participant" (toMessage num <> " participants")}#{noneOneMoreEN n "" "" (" made " <> toMessage num)}#{noneOneMoreEN n "There were no submissions" " made one submission" " submissions"}. -MailSheetInactiveIntroNoUserSubmission courseName termDesc sheetName n num: The submission period for #{sheetName} of the course type #{courseName} (#{termDesc}) has ended. #{noneOneMoreEN num "" "One participant already" (toMessage num <> " participants already")}#{noneOneMoreEN n "" "" (" made " <> toMessage num)}#{noneOneMoreEN n "" " made one submission" " submissions"}. +MailSheetInactiveIntro courseName termDesc sheetName n num: The submission period for #{sheetName} of the course category #{courseName} (#{termDesc}) has ended. #{noneOneMoreEN num "" "One participant" (toMessage num <> " participants")}#{noneOneMoreEN n "" "" (" made " <> toMessage num)}#{noneOneMoreEN n "There were no submissions" " made one submission" " submissions"}. +MailSheetInactiveIntroNoUserSubmission courseName termDesc sheetName n num: The submission period for #{sheetName} of the course category #{courseName} (#{termDesc}) has ended. #{noneOneMoreEN num "" "One participant already" (toMessage num <> " participants already")}#{noneOneMoreEN n "" "" (" made " <> toMessage num)}#{noneOneMoreEN n "" " made one submission" " submissions"}. MailSheetInactivePseudonymsCount n: The number of submissions above accounts only for the submissions already made directly in FRADrive. #{n} #{pluralEN n "pseudonym was" "pseudonyms were"} generated. MailSheetInactiveParticipantsCount n: There #{pluralEN n "is" "are"} currently #{n} #{pluralEN n "participant" "participants"} registered for the course. diff --git a/messages/uniworx/categories/settings/auth_settings/en-eu.msg b/messages/uniworx/categories/settings/auth_settings/en-eu.msg index 5ba42ba0f..db22d4a74 100644 --- a/messages/uniworx/categories/settings/auth_settings/en-eu.msg +++ b/messages/uniworx/categories/settings/auth_settings/en-eu.msg @@ -14,7 +14,7 @@ AuthTagAdmin: User is administrator AuthTagExamOffice: User is part of an exam office AuthTagSystemExamOffice: User is charged with system wide exam administration AuthTagSystemPrinter: User is responsible for system wide letter printing -AuthTagEvaluation: User is charged with course type evaluation +AuthTagEvaluation: User is charged with course category evaluation AuthTagToken: User is presenting an authorisation-token AuthTagNoEscalation: User permissions are not being expanded to other departments AuthTagDeprecated: Page is not deprecated @@ -26,7 +26,7 @@ AuthTagTutor: User is instructor AuthTagTutorControl: Instructors have control over their course AuthTagTime: Time restrictions are fulfilled AuthTagStaffTime: Time restrictions for teaching staff are fulfilled -AuthTagCourseTime: Time restrictions for course type visibility are fulfilled +AuthTagCourseTime: Time restrictions for course category visibility are fulfilled AuthTagCourseRegistered: User is enrolled in course AuthTagTutorialRegistered: User is course participant AuthTagExamRegistered: User is exam participant @@ -37,11 +37,11 @@ AuthTagParticipant: User participates in course AuthTagRegisterGroup: User is not participant in any course of the same registration group AuthTagCapacity: Capacity is sufficient AuthTagEmpty: Resource is “empty” -AuthTagMaterials: Course type material is publicly accessable +AuthTagMaterials: Course category material is publicly accessable AuthTagOwner: User is owner AuthTagPersonalisedSheetFiles: User has been assigned personalised sheet files AuthTagRated: Submission is marked -AuthTagUserSubmissions: Submissions are made by course type participants +AuthTagUserSubmissions: Submissions are made by course category participants AuthTagCorrectorSubmissions: Submissions are registered by correctors AuthTagCorrectionAnonymous: Correction is anonymised AuthTagSelf: User is only accessing their only data diff --git a/messages/uniworx/categories/settings/en-eu.msg b/messages/uniworx/categories/settings/en-eu.msg index 1a4790f5e..c4a7719e5 100644 --- a/messages/uniworx/categories/settings/en-eu.msg +++ b/messages/uniworx/categories/settings/en-eu.msg @@ -31,7 +31,7 @@ DownloadFilesTip: When set, files are automatically treated as downloads. Otherw WarningDays: Deadline-preview WarningDaysTip: How many days ahead should deadlines regarding exams etc. be displayed on the homepage? ShowSex: Show sex of other users -ShowSexTip: Should users' sex be displayed in (among others) lists of course type participants? +ShowSexTip: Should users' sex be displayed in (among others) lists of course category participants? PDFPassword: Password to lock PDF email attachments PDFPasswordTip: Please note that this password is displayed to FRADrive admins and is saved unencrypted @@ -53,14 +53,14 @@ UserSchoolsTip: You will only receive department-wide notifications for the sele NotificationSettings: Desired notifications NotificationTriggerKindAll: For all users -NotificationTriggerKindCourseParticipant: For course type participants +NotificationTriggerKindCourseParticipant: For course category participants NotificationTriggerKindExamParticipant: For exam participants NotificationTriggerKindCorrector: For correctors NotificationTriggerKindLecturer: For course administrators NotificationTriggerKindCourseLecturer: For course administrators NotificationTriggerKindAdmin: For administrators NotificationTriggerKindExamOffice: For the exam office -NotificationTriggerKindEvaluation: For course type evaluations +NotificationTriggerKindEvaluation: For course category evaluations NotificationTriggerKindSubmissionUser: For participants in an exercise sheet submission NotificationTriggerSubmissionRatedGraded: My submission for an exercise sheet was marked (not purely informational) diff --git a/messages/uniworx/categories/settings/personal_settings/de-de-formal.msg b/messages/uniworx/categories/settings/personal_settings/de-de-formal.msg index 827732551..e0946ee3a 100644 --- a/messages/uniworx/categories/settings/personal_settings/de-de-formal.msg +++ b/messages/uniworx/categories/settings/personal_settings/de-de-formal.msg @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022 Gregor Kleen ,Steffen Jost ,Winnie Ros +# SPDX-FileCopyrightText: 2022-25 Gregor Kleen ,Steffen Jost ,Winnie Ros ,Steffen Jost # # SPDX-License-Identifier: AGPL-3.0-or-later @@ -19,6 +19,7 @@ ProfileSubmissionGroups: Abgabegruppen ProfileSubmissions: Abgaben ProfileRemark: Hinweis ProfileQualifications: Eigene Qualifikationen +ProfileEnrolledExams: Angemeldete Prüfungen PersonalInfoExamAchievementsWip: Die Anzeige von Prüfungsergebnissen wird momentan an dieser Stelle leider noch nicht unterstützt. PersonalInfoOwnTutorialsWip: Die Anzeige von Kurse, zu denen Sie als Ausbilder eingetragen sind wird momentan an dieser Stelle leider noch nicht unterstützt. PersonalInfoTutorialsWip: Die Anzeige von Kurse, zu denen Sie angemeldet sind wird momentan an dieser Stelle leider noch nicht unterstützt. @@ -37,5 +38,6 @@ ProfileSuperviseeRemark n@Int m@Int: Dieser Nutzer ist Ansprechpartner für #{n} UserTelephone: Telefon UserMobile: Mobiltelefon Company: Firmenzugehörigkeit -CompanyPersonalNumber: Personalnummer (nur Fraport AG) +CompanyPersonalNumber: Personalnummer +CompanyPersonalNumberFraport: Personalnummer (nur Fraport AG) CompanyDepartment: Abteilung \ No newline at end of file diff --git a/messages/uniworx/categories/settings/personal_settings/en-eu.msg b/messages/uniworx/categories/settings/personal_settings/en-eu.msg index db67e3940..97aae6f85 100644 --- a/messages/uniworx/categories/settings/personal_settings/en-eu.msg +++ b/messages/uniworx/categories/settings/personal_settings/en-eu.msg @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022 Steffen Jost ,Winnie Ros +# SPDX-FileCopyrightText: 2022-25 Steffen Jost ,Winnie Ros ,Steffen Jost # # SPDX-License-Identifier: AGPL-3.0-or-later @@ -11,7 +11,7 @@ LastLogin: Last login NeverSet: Never ProfileCorrector: Corrector ProfileCourses: Own courses -ProfileCourseParticipations: Course type registrations +ProfileCourseParticipations: Course category registrations ProfileCourseExamResults: Exam achievements ProfileTutorials: Own courses ProfileTutorialParticipations: Courses @@ -19,6 +19,7 @@ ProfileSubmissionGroups: Submission groups ProfileSubmissions: Submissions ProfileRemark: Remarks ProfileQualifications: Owned Qualifications +ProfileEnrolledExams: Enrolled Exams PersonalInfoExamAchievementsWip: The feature to display your exam achievements has not yet been implemented. PersonalInfoOwnTutorialsWip: The feature to display courses you have been assigned to as instructor has not yet been implemented. PersonalInfoTutorialsWip: The feature to display courses you have registered for has not yet been implemented. @@ -36,6 +37,7 @@ ProfileSuperviseeRemark n m: This person supervises #{pluralENsN n "person"}#{no UserTelephone: Phone UserMobile: Mobile -Company: Company affilitaion -CompanyPersonalNumber: Personnel number (Fraport AG only) +Company: Company affiliation +CompanyPersonalNumber: Personnel number +CompanyPersonalNumberFraport: Personnel number (Fraport AG only) CompanyDepartment: Department \ No newline at end of file diff --git a/messages/uniworx/categories/user/de-de-formal.msg b/messages/uniworx/categories/user/de-de-formal.msg index 9a22cba47..276944585 100644 --- a/messages/uniworx/categories/user/de-de-formal.msg +++ b/messages/uniworx/categories/user/de-de-formal.msg @@ -43,6 +43,7 @@ SynchroniseAvsAllUsersQueued n@Int64: AVS-Synchronisation von allen #{n} #{plura SynchroniseUserdbUserQueued n@Int: Benutzerdatenbank-Synchronisation von #{n} #{pluralDE n "Benutzer:in" "Benutzer:innen"} angestoßen, die Ausführung wird mehrere Minuten benötigen! SynchroniseUserdbAllUsersQueued: Benutzerdatenbank-Synchronisation von allen Benutzer:innen angestoßen, die Ausführung kann eine Weile brauchen! UserListTitle: Komprehensive Benutzerliste +UserRecipientsTitle name@Text: Benachrichtigungsempfänger für #{name} AccessRightsSaved: Berechtigungen erfolgreich verändert AccessRightsNotChanged: Berechtigungen wurden nicht verändert AuthLDAPLookupFailed: Nutzer:in konnte aufgrund eines LDAP-Fehlers nicht nachgeschlagen werden @@ -96,7 +97,7 @@ UserHijack: Sitzung übernehmen UserAddSupervisor: Ansprechpartner hinzufügen UserSetSupervisor: Ansprechpartner ersetzen UserRemoveSupervisor: Alle Ansprechpartner entfernen -UserRemoveSubordinates: Alle Ansprechpartnerbeziehungen zu Untergebenen beenden +UserRemoveClients: Alle Ansprechpartnerbeziehungen zu Klienten beenden UserIsSupervisor: Ist Ansprechpartner UserAvsSwitchCompany: Als Primärfirma verwenden UserAvsSwitchCompanyField: Primärfirma auswählen @@ -109,11 +110,12 @@ Name !ident-ok: Name UsersChangeSupervisorsSuccess usr@Int spr@Int: #{tshow spr} Ansprechpartner für #{tshow usr} Benutzer gesetzt. UsersChangeSupervisorsWarning usr@Int spr@Int bad@Int: Nur _{MsgUsersChangeSupervisorsSuccess usr spr} #{tshow bad} Ansprechpartner #{pluralDE bad "wurde" "wurden"} nicht gefunden! UsersRemoveSupervisors usr@Int: Alle Ansprechpartner für #{tshow usr} Benutzer gelöscht. -UsersRemoveSubordinates usr@Int: Alle Ansprechpartnerbeziehungen für #{tshow usr} #{pluralDE usr "ehemaligen" "ehemalige"} Ansprechpartner gelöscht. +UsersRemoveClients usr@Int: Alle Ansprechpartnerbeziehungen für #{tshow usr} #{pluralDE usr "ehemaligen" "ehemalige"} Ansprechpartner gelöscht. UserCompanyReason: Begründung der Firmenassoziation UserCompanyReasonTooltip: Optionale Notiz für besondere Fälle. Kann ggf. autmatische Entfernung bei AVS Firmenwechsel verhindern. UserSupervisorReason: Begründung Ansprechpartner UserSupervisorReasonTooltip: Optionale Notiz für besondere Fälle. Kann ggf. autmatische Entfernung bei AVS Firmenwechsel verhindern. +UserSupervisorCompany: Ansprechpartner wegen Firma AdminUserAllNotifications: Alle Benachrichtigungen and diesen Benutzer AdminUserAuthentication: Authentification AdminUserAuthLastSync: Zuletzt synchronisiert diff --git a/messages/uniworx/categories/user/en-eu.msg b/messages/uniworx/categories/user/en-eu.msg index fb40f04b5..44715c1d7 100644 --- a/messages/uniworx/categories/user/en-eu.msg +++ b/messages/uniworx/categories/user/en-eu.msg @@ -43,6 +43,7 @@ SynchroniseAvsAllUsersQueued n: Triggered AVS synchronisation of all #{n} #{plur SynchroniseUserdbUserQueued n: Triggered user database synchronisation of #{n} #{pluralEN n "user" "users"}, which may take several minutes to complete. SynchroniseUserdbAllUsersQueued: Triggered user database synchronisation of all users, which may take quite a while to complete. UserListTitle: Comprehensive list of users +UserRecipientsTitle name: Notificationrecipients for #{name} AccessRightsSaved: Successfully updated permissions AccessRightsNotChanged: Permissions left unchanged AuthLDAPLookupFailed: User could not be looked up due to a LDAP error @@ -96,7 +97,7 @@ UserHijack: Hijack session UserAddSupervisor: Add supervisor UserSetSupervisor: Replace supervisors UserRemoveSupervisor: Set to unsupervised -UserRemoveSubordinates: Remove all subordinates +UserRemoveClients: Remove all clients UserIsSupervisor: Is supervisor UserAvsSwitchCompany: Use as primary company UserAvsSwitchCompanyField: Select primary company @@ -109,11 +110,12 @@ Name: Name UsersChangeSupervisorsSuccess usr spr: #{pluralENsN spr "supervisor"} for #{pluralENsN usr "user"} set. UsersChangeSupervisorsWarning usr spr bad: Only _{MsgUsersChangeSupervisorsSuccess usr spr} #{pluralENsN bad "supervisors"} could not be identified! UsersRemoveSupervisors usr: Removed all supervisors for #{pluralENsN usr "user"}. -UsersRemoveSubordinates usr: Removed all subordinates for #{pluralENsN usr "previous supervisor"}. +UsersRemoveClients usr: Removed all clients for #{pluralENsN usr "previous supervisor"}. UserCompanyReason: Reason for company association UserCompanyReasonTooltip: Optional note for special cases. In some case this may prevent automatic removel upon AVS user company changes. UserSupervisorReason: Reason for supervision UserSupervisorReasonTooltip: Optional note for special cases. In some case this may prevent automatic removel upon AVS user company changes. +UserSupervisorCompany: Supervisor for company AdminUserAllNotifications: All notification sent to this user AdminUserAuthentication: Authentifizierung diff --git a/messages/uniworx/misc/de-de-formal.msg b/messages/uniworx/misc/de-de-formal.msg index f7cf7a561..c34f39545 100644 --- a/messages/uniworx/misc/de-de-formal.msg +++ b/messages/uniworx/misc/de-de-formal.msg @@ -5,6 +5,7 @@ #messages or constructors that are used all over the code Logo !ident-ok: FRADrive +LdapIdentificationOrEmail: Fraport AG-Kennung / E-Mail-Adresse EmailInvitationWarning: Diese Adresse konnte keinem FRADrive-Benutzer/-Benutzerin zugeordnet werden. Es wird eine Einladung per E-Mail versandt. BoolIrrelevant !ident-ok: — FieldPrimary: Hauptfach @@ -13,7 +14,8 @@ MultiEmailFieldTip: Es sind mehrere, Komma-separierte, E-Mail-Adressen möglich MultiSelectTip: Mehrfachauswahl und Abwählen mit Strg-Klick WeekDay: Wochentag Hours: Stunden -LdapIdentificationOrEmail: Fraport AG-Kennung / E-Mail-Adresse +SomeMonths: Monate +SomeDays: Tage Months num@Int64: #{num} #{pluralDE num "Monat" "Monate"} Days num@Int64: #{num} #{pluralDE num "Tag" "Tage"} NoAutomaticUpdateTip: Dieser Wert wurde manuell editiert und wird daher nicht mehr automatisch durch as AVS aktualisiert. @@ -31,4 +33,13 @@ PaginationPage: Angzeigte Seite PaginationError: Paginierung Parameter dürfen nicht negativ sein NullDeletes: Zum Löschen NULL eingeben. -SortPriority: Sortierungspriorität \ No newline at end of file +SortPriority: Sortierungspriorität +NoProblem: Keine Probleme gefunden +Unknown: ist unbekannt +UnknownOrNotAllowed: ist unbekannt oder hier nicht erlaubt +Ambiguous: ist uneindeutig +Action: Aktion +For: für +Address: Adresse +NoContactAddress: Keinerlei Kontaktdaten bekannt! +StarKeepsEmptyDeletes: Stern zum Beibehalten, leer lassen zum Löschen \ No newline at end of file diff --git a/messages/uniworx/misc/en-eu.msg b/messages/uniworx/misc/en-eu.msg index da5d1efab..6a94c7370 100644 --- a/messages/uniworx/misc/en-eu.msg +++ b/messages/uniworx/misc/en-eu.msg @@ -5,6 +5,7 @@ #messages or constructors that are used all over the Code Logo: FRADrive +LdapIdentificationOrEmail: Fraport AG-Kennung / email address EmailInvitationWarning: This address could not be matched to any FRADrive user. An invitation will be sent via email. BoolIrrelevant: — FieldPrimary: Major @@ -13,7 +14,8 @@ MultiEmailFieldTip: Multiple emails addresses may be specified (comma-separated) MultiSelectTip: Multiple selection and desection via Ctrl-Click WeekDay: Day of the week Hours: Hours -LdapIdentificationOrEmail: Fraport AG-Kennung / email address +SomeMonths: Months +SomeDays: Days Months num: #{num} #{pluralEN num "Month" "Months"} Days num: #{num} #{pluralEN num "Day" "Days"} NoAutomaticUpdateTip: This particular value receives no automatic AVS updates, since it has been edited manually. @@ -31,4 +33,13 @@ PaginationPage: Page to show PaginationError: Pagination parameter must not be negative NullDeletes: Enter NULL to delete. -SortPriority: Sort order priority \ No newline at end of file +SortPriority: Sort order priority +NoProblem: No Probleme found +Unknown: is unknown +UnknownOrNotAllowed: is unknown or not allowed here +Ambiguous: is ambiguous +Action: Action +For: for +Address: Address +NoContactAddress: No contact details known! +StarKeepsEmptyDeletes: A star to keep unchanged, blank removes \ No newline at end of file diff --git a/messages/uniworx/utils/buttons/de-de-formal.msg b/messages/uniworx/utils/buttons/de-de-formal.msg index 8252a3a1c..7ac6b8081 100644 --- a/messages/uniworx/utils/buttons/de-de-formal.msg +++ b/messages/uniworx/utils/buttons/de-de-formal.msg @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022 Gregor Kleen ,Steffen Jost ,Winnie Ros ,Sarah Vaupel +# SPDX-FileCopyrightText: 2022-24 Gregor Kleen ,Steffen Jost ,Winnie Ros ,Sarah Vaupel ,Steffen Jost # # SPDX-License-Identifier: AGPL-3.0-or-later @@ -55,6 +55,8 @@ BtnUserAssimilate: Assimilieren BtnCloseExam: Prüfung abschließen BtnFinishExam: Prüfungsergebnisse sichtbar schalten BtnConfirm: Bestätigen +BtnPerform: Ausführen BtnCourseRegisterAdd: Personen suchen BtnCourseRegisterConfirm: Ausgewählte Personen anmelden -BtnCourseRegisterAbort: Abbrechen \ No newline at end of file +BtnCourseRegisterAbort: Abbrechen +BtnCloseReload: Schließen und aktualisieren \ No newline at end of file diff --git a/messages/uniworx/utils/buttons/en-eu.msg b/messages/uniworx/utils/buttons/en-eu.msg index a83a7b3aa..1f4a6133a 100644 --- a/messages/uniworx/utils/buttons/en-eu.msg +++ b/messages/uniworx/utils/buttons/en-eu.msg @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022 Steffen Jost ,Winnie Ros ,Sarah Vaupel +# SPDX-FileCopyrightText: 2022-24 Steffen Jost ,Winnie Ros ,Sarah Vaupel ,Steffen Jost # # SPDX-License-Identifier: AGPL-3.0-or-later @@ -55,6 +55,8 @@ BtnUserAssimilate: Assimilate BtnCloseExam: Close exam BtnFinishExam: Make results visible BtnConfirm: Confirm +BtnPerform: Perform BtnCourseRegisterAdd: Search persons BtnCourseRegisterConfirm: Register selected persons -BtnCourseRegisterAbort: Abort \ No newline at end of file +BtnCourseRegisterAbort: Abort +BtnCloseReload: Close and reload \ No newline at end of file diff --git a/messages/uniworx/utils/handler_form/occurrences/de-de-formal.msg b/messages/uniworx/utils/handler_form/occurrences/de-de-formal.msg index e70c0a30d..24119b496 100644 --- a/messages/uniworx/utils/handler_form/occurrences/de-de-formal.msg +++ b/messages/uniworx/utils/handler_form/occurrences/de-de-formal.msg @@ -20,3 +20,7 @@ ExceptionNoOccurAt: Termin ExceptionKind: Termin ... ExceptionKindOccur: Findet statt ExceptionKindNoOccur: Findet nicht statt +DayNext: Folgetag +DayPrev: Vortag +WeekNext: Nächste Woche +WeekPrev: Vorherige Woche diff --git a/messages/uniworx/utils/handler_form/occurrences/en-eu.msg b/messages/uniworx/utils/handler_form/occurrences/en-eu.msg index 1c325ea7f..62f629add 100644 --- a/messages/uniworx/utils/handler_form/occurrences/en-eu.msg +++ b/messages/uniworx/utils/handler_form/occurrences/en-eu.msg @@ -20,3 +20,7 @@ ExceptionNoOccurAt: Event ExceptionKind: Event ... ExceptionKindOccur: Does occur ExceptionKindNoOccur: Does not occur +DayNext: Next day +DayPrev: Previous day +WeekNext: Next week +WeekPrev: Previous week \ No newline at end of file diff --git a/messages/uniworx/utils/navigation/breadcrumbs/de-de-formal.msg b/messages/uniworx/utils/navigation/breadcrumbs/de-de-formal.msg index 2ed3ef04e..f84a33a5f 100644 --- a/messages/uniworx/utils/navigation/breadcrumbs/de-de-formal.msg +++ b/messages/uniworx/utils/navigation/breadcrumbs/de-de-formal.msg @@ -71,6 +71,7 @@ BreadcrumbError: Fehler BreadcrumbUpload !ident-ok: Upload BreadcrumbUserAdd: Benutzer:in anlegen BreadcrumbUserNotifications: Benachrichtigungs-Einstellungen +BreadcrumbUserRecipients: Benachrichtigungs-Empfänger BreadcrumbUserPassword: Passwort BreadcrumbAdminHeading !ident-ok: Administration BreadcrumbAdminFeaturesHeading: Studiengänge diff --git a/messages/uniworx/utils/navigation/breadcrumbs/en-eu.msg b/messages/uniworx/utils/navigation/breadcrumbs/en-eu.msg index b4defff60..a10b1a2a8 100644 --- a/messages/uniworx/utils/navigation/breadcrumbs/en-eu.msg +++ b/messages/uniworx/utils/navigation/breadcrumbs/en-eu.msg @@ -6,7 +6,7 @@ BreadcrumbCsvOptions: csv-options BreadcrumbSubmissionFile: File BreadcrumbSubmissionUserInvite: Invitation to participate in a submission BreadcrumbCryptoIDDispatch: CryptoID-redirect -BreadcrumbCourseNotes: Course type notes +BreadcrumbCourseNotes: Course category notes BreadcrumbHiWis: Correctors BreadcrumbMaterial: Material BreadcrumbSheet: Exercise sheet @@ -14,7 +14,7 @@ BreadcrumbTutorial: Course BreadcrumbExam: Exam BreadcrumbCourseRegister: Register BreadcrumbCourseFavourite: Favourite -BreadcrumbCourse: Course type +BreadcrumbCourse: Course category BreadcrumbTerm: Year BreadcrumbSchool: Department BreadcrumbUser: User @@ -28,11 +28,11 @@ BreadcrumbUserDelete: Delete user account BreadcrumbUserHijack: Hijack user session BreadcrumbSystemMessage: System message BreadcrumbSubmission: Submission -BreadcrumbCourseNews: Course type news -BreadcrumbCourseNewsDelete: Delete course type news -BreadcrumbCourseEventDelete: Delete course type occurrence +BreadcrumbCourseNews: Course category news +BreadcrumbCourseNewsDelete: Delete course category news +BreadcrumbCourseEventDelete: Delete course category occurrence BreadcrumbProfile: Settings -BreadcrumbCourseParticipantInvitation: Invitation to be a course type participant +BreadcrumbCourseParticipantInvitation: Invitation to be a course category participant BreadcrumbMaterialArchive: Archive BreadcrumbMaterialFile: File BreadcrumbMaterialVideo: Video @@ -57,8 +57,8 @@ BreadcrumbExternalExamUsers: Participants BreadcrumbExternalExamGrades: Exam results BreadcrumbExternalExamStaffInvite: Invitation BreadcrumbExternalExamCorrect: Enter exam results -BreadcrumbParticipantsList: Lists of course type participants -BreadcrumbParticipants: Course type participants +BreadcrumbParticipantsList: Lists of course category participants +BreadcrumbParticipants: Course category participants BreadcrumbExamAutoOccurrence: Automatic occurrence/room distribution BreadcrumbStorageKey: Generate storage key BreadcrumbMessageHide: Hide @@ -71,6 +71,7 @@ BreadcrumbError: Error BreadcrumbUpload: Upload BreadcrumbUserAdd: Add user BreadcrumbUserNotifications: Notification settings +BreadcrumbUserRecipients: Notification recipients BreadcrumbUserPassword: Password BreadcrumbAdminHeading: Administration BreadcrumbAdminFeaturesHeading: Features of study @@ -97,7 +98,7 @@ BreadcrumbTermShow: Years BreadcrumbTermCreate: Create new year BreadcrumbTermEdit: Edit year BreadcrumbTermCurrent: Current year -BreadcrumbParticipantsIntersect: Common course type participants +BreadcrumbParticipantsIntersect: Common course category participants BreadcrumbCourseList: Courses BreadcrumbCourseNew: Create new course BreadcrumbCourseEdit: Edit course @@ -106,14 +107,14 @@ BreadcrumbCourseAddMembers: Add participants BreadcrumbCourseExamOffice: Exam offices BreadcrumbCorrectionsAssign: Assign corrections BreadcrumbSheetList: Exercise sheets -BreadcrumbCourseCommunication: Course type message (email) +BreadcrumbCourseCommunication: Course category message (email) BreadcrumbTutorialList: Courses BreadcrumbTutorialNew: Create new course BreadcrumbCourseDelete: Delete course -BreadcrumbCourseNewsNew: Add course type news -BreadcrumbCourseNewsEdit: Edit course type news -BreadcrumbCourseEventNew: New course type occurrence -BreadcrumbCourseEventEdit: Edit course type occurrence +BreadcrumbCourseNewsNew: Add course category news +BreadcrumbCourseNewsEdit: Edit course category news +BreadcrumbCourseEventNew: New course category occurrence +BreadcrumbCourseEventEdit: Edit course category occurrence BreadcrumbExamList: Exams BreadcrumbExamNew: Create new exam BreadcrumbExamEdit: Edit exam @@ -123,7 +124,7 @@ BreadcrumbExamAddMembers: Add exam participants BreadcrumbExamCorrect: Grade exams BreadcrumbTutorialDelete: Delete course BreadcrumbTutorialEdit: Edit course -BreadcrumbTutorialComm: Send course type message +BreadcrumbTutorialComm: Send course category message BreadcrumbSheetEdit: Edit exercise sheet BreadcrumbSheetDelete: Delete exercise sheet BreadcrumbSubmissions: Submissions diff --git a/messages/uniworx/utils/navigation/menu/de-de-formal.msg b/messages/uniworx/utils/navigation/menu/de-de-formal.msg index f761e24ad..ea3f4a19c 100644 --- a/messages/uniworx/utils/navigation/menu/de-de-formal.msg +++ b/messages/uniworx/utils/navigation/menu/de-de-formal.msg @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022-2024 Sarah Vaupel , David Mosbach , Gregor Kleen ,Sarah Vaupel ,Steffen Jost ,Steffen Jost ,Winnie Ros +# SPDX-FileCopyrightText: 2022-25-2024 Sarah Vaupel , David Mosbach , Gregor Kleen ,Sarah Vaupel ,Steffen Jost ,Steffen Jost ,Winnie Ros ,Steffen Jost # # SPDX-License-Identifier: AGPL-3.0-or-later @@ -34,6 +34,7 @@ MenuCourseIcon: Kurse MenuCourseMembers: Kursartteilnehmer:innen MenuCourseAddMembers: Kursartteilnehmer:innen hinzufügen MenuTutorialAddMembers: Kursteilnehmer:innen hinzufügen +MenuTutorialExam exn@ExamName: Kursprüfung #{exn} bearbeiten MenuCourseCommunication: Kursartmitteilung (E‑Mail) MenuCourseExamOffice: Prüfungsbeauftragte MenuTermShow: Jahr @@ -88,6 +89,7 @@ MenuTutorialComm: Mitteilung an Teilnehmer:innen MenuExamList: Prüfungen MenuExamNew: Neue Prüfung anlegen MenuExamEdit: Prüfung bearbeiten +MenuExamEditComplete: Prüfung vollständig überarbeiten MenuExamUsers: Teilnehmer:innen MenuExamGrades: Prüfungsleistungen MenuExamAddMembers: Prüfungsteilnehmer hinzufügen @@ -98,6 +100,8 @@ MenuExamOfficeUsers: Benutzer:innen MenuLecturerInvite: Funktionäre hinzufügen MenuSchoolList: Bereiche MenuSchoolNew: Neuen Bereich anlegen +MenuSchoolDay ssh@SchoolId d@Text: #{d} #{unSchoolKey ssh} Tagesansicht +MenuSchoolDayCheck: Konsistenzprüfung MenuExternalExamGrades: Prüfungsleistungen MenuExternalExamUsers: Teilnehmer:innen MenuExternalExamEdit: Bearbeiten @@ -120,8 +124,9 @@ MenuCourseEventEdit: Kursarttermin bearbeiten MenuLanguage: Sprache MenuQualifications: Qualifikationen +MenuQualificationEdit: Bearbeiten +MenuQualificationNew: Neue Qualifikation erstellen MenuLms !ident-ok: E‑Learning -MenuLmsEdit: Bearbeiten E‑Learning MenuLmsUser: Benutzerqualifikationen MenuLmsUserSchool: Bereichs Benutzerqualifikationen MenuLmsUserAll: Alle Benutzerqualifikationen @@ -137,6 +142,7 @@ MenuFirms: Firmen MenuFirmUsers: Angehörige MenuFirmSupervisors: Ansprechpartner MenuFirmsComm: Mitteilung +MenuFirmsSupervision: Probleme Ansprechpartnerbeziehungen MenuInterfaces: Schnittstellen MenuSap: SAP Schnittstelle diff --git a/messages/uniworx/utils/navigation/menu/en-eu.msg b/messages/uniworx/utils/navigation/menu/en-eu.msg index 86a505027..f3cb7e366 100644 --- a/messages/uniworx/utils/navigation/menu/en-eu.msg +++ b/messages/uniworx/utils/navigation/menu/en-eu.msg @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022-2024 Sarah Vaupel , David Mosbach , Gregor Kleen ,Sarah Vaupel ,Steffen Jost ,Winnie Ros +# SPDX-FileCopyrightText: 2022-25-2024 Sarah Vaupel , David Mosbach , Gregor Kleen ,Sarah Vaupel ,Steffen Jost ,Winnie Ros ,Steffen Jost # # SPDX-License-Identifier: AGPL-3.0-or-later @@ -6,7 +6,7 @@ MenuAdminHeading: Administration MenuAdminFeaturesHeading: Features of study MenuInfoLecturerTitle: Information for course administrators MenuInfoLecturerCourses: Courses -MenuInfoLecturerExercises: Course type Exercises +MenuInfoLecturerExercises: Course category Exercises MenuInfoLecturerTutorials: Courses MenuInfoLecturerExams: Exams MenuCsvOptions: CSV-options @@ -29,12 +29,13 @@ MenuAccount: Account MenuProfile: Settings MenuLogin: Login MenuLogout: Logout -MenuCourseList: Course types +MenuCourseList: Course categorys MenuCourseIcon: Courses MenuCourseMembers: Participants -MenuCourseAddMembers: Add course type participants +MenuCourseAddMembers: Add course category participants MenuTutorialAddMembers: Add course participants -MenuCourseCommunication: Course type message (email) +MenuTutorialExam exn@ExamName: Edit course exam #{exn} +MenuCourseCommunication: Course category message (email) MenuCourseExamOffice: Exam offices MenuTermShow: Semesters MenuSubmissionDelete: Delete submission @@ -49,7 +50,7 @@ MenuAdminErrMsg: Decrypt error message MenuAdminTokens: Issue tokens MenuProfileData: Personal information MenuTermCreate: Create new year -MenuCourseNew: Create new course type +MenuCourseNew: Create new course category MenuTermEdit: Edit year MenuTermCurrent: Current year MenuCorrection: Correction @@ -84,10 +85,11 @@ MenuCorrectionsAssignSheet name: Assign corrections for #{name} MenuAuthPreds: Authorisation settings MenuTutorialDelete: Delete course MenuTutorialEdit: Edit course -MenuTutorialComm: Send course type message +MenuTutorialComm: Send course category message MenuExamList: Exams MenuExamNew: Create new exam MenuExamEdit: Edit exam +MenuExamEditComplete: Revise entire exam MenuExamUsers: Participants MenuExamGrades: Exam results MenuExamAddMembers: Add exam participants @@ -98,14 +100,16 @@ MenuExamOfficeUsers: Users MenuLecturerInvite: Add functionaries MenuSchoolList: Departments MenuSchoolNew: Create new department +MenuSchoolDay ssh d: #{d} #{unSchoolKey ssh} Agenda +MenuSchoolDayCheck: Consistence check MenuExternalExamGrades: Exam results MenuExternalExamUsers: Participants MenuExternalExamEdit: Edit MenuExternalExamNew: New external exam MenuExternalExamList: External exams MenuExternalExamCorrect: Enter exam results -MenuParticipantsList: Lists of course type participants -MenuParticipantsIntersect: Common course type participants +MenuParticipantsList: Lists of course category participants +MenuParticipantsIntersect: Common course category participants MenuFaq: FAQ MenuSheetPersonalisedFiles: Download personalised sheet files MenuCourseSheetPersonalisedFiles: Download template for personalised sheet files @@ -113,17 +117,18 @@ MenuAdminCrontab: Crontab MenuAdminJobs: Job queue MenuGlossary: Glossary MenuVersion: Version history -MenuCourseNewsNew: Add course type news -MenuCourseNewsEdit: Edit course type news -MenuCourseEventNew: New course type occurrence -MenuCourseEventEdit: Edit course type occurrence +MenuCourseNewsNew: Add course category news +MenuCourseNewsEdit: Edit course category news +MenuCourseEventNew: New course category occurrence +MenuCourseEventEdit: Edit course category occurrence MenuLanguage: Language MenuQualifications: Qualifications +MenuQualificationEdit: Edit +MenuQualificationNew: Create new qualification MenuLms: E‑learning -MenuLmsEdit: Edit e‑learning MenuLmsUser: User Qualifications -MenuLmsUserSchool: Institute User Qualifications +MenuLmsUserSchool: Department User Qualifications MenuLmsUserAll: All User Qualifications MenuLmsUsers: Legacy download e‑learning users MenuLmsUpload: Upload @@ -137,6 +142,7 @@ MenuFirms: Companies MenuFirmUsers: Associates MenuFirmSupervisors: Supervisors MenuFirmsComm: Messaging +MenuFirmsSupervision: Problems supervisionship MenuInterfaces: Interfaces MenuSap: SAP Interface diff --git a/messages/uniworx/utils/site_layout/de-de-formal.msg b/messages/uniworx/utils/site_layout/de-de-formal.msg index 34681feea..55174f07c 100644 --- a/messages/uniworx/utils/site_layout/de-de-formal.msg +++ b/messages/uniworx/utils/site_layout/de-de-formal.msg @@ -12,8 +12,8 @@ NewsHeading: Aktuelles InfoHeading: Informationen LegalHeading: Rechtliche Informationen VersionHeading: Versionsgeschichte -SystemMessageHeading: Uni2work Statusmeldung -SystemMessageListHeading: Uni2work Statusmeldungen +SystemMessageHeading: FRADrive Statusmeldung +SystemMessageListHeading: FRADrive Statusmeldungen HeadingHelpRequest: Supportanfrage/Verbesserungsvorschlag ProfileHeading: Benutzereinstellungen ProfileDataHeading: Gespeicherte Benutzerdaten diff --git a/messages/uniworx/utils/site_layout/en-eu.msg b/messages/uniworx/utils/site_layout/en-eu.msg index d263f8217..69d3ff5cd 100644 --- a/messages/uniworx/utils/site_layout/en-eu.msg +++ b/messages/uniworx/utils/site_layout/en-eu.msg @@ -12,8 +12,8 @@ NewsHeading: News InfoHeading: Information LegalHeading: Legal VersionHeading: Version history -SystemMessageHeading: Uni2work system message -SystemMessageListHeading: Uni2work system message +SystemMessageHeading: FRADrive system message +SystemMessageListHeading: FRADrive system messages HeadingHelpRequest: Support request/Suggestion ProfileHeading: Settings ProfileDataHeading: Personal information @@ -29,8 +29,8 @@ HeadingTermEditTid tid: Edit year #{tid} TermCourseListHeading tid: Courses #{tid} TermSchoolCourseListHeading tid school: Courses #{tid}, #{school} CourseListTitle: All courses -CourseNewHeading: Create new course type -CourseEditHeading tid ssh csh: Edit course type #{tid}-#{ssh}-#{csh} +CourseNewHeading: Create new course category +CourseEditHeading tid ssh csh: Edit course category #{tid}-#{ssh}-#{csh} SubmissionsCourse tid ssh csh: All submissions for Course #{tid}-#{ssh}-#{csh} SubmissionsSheet sheetName: Submissions for #{sheetName} SheetList tid ssh csh : #{tid}-#{ssh}-#{csh} Sheet Overview @@ -38,7 +38,7 @@ SheetNewHeading tid ssh csh : #{tid}-#{ssh}-#{csh} New Exercise Sheet SheetTitle tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName: #{tid}-#{ssh}-#{csh} #{sheetName} SheetTitleNew tid@TermId ssh@SchoolId csh@CourseShorthand : #{tid}-#{ssh}-#{csh}: New sheet SheetEditHead tid ssh csh sheetName: #{tid}-#{ssh}-#{csh} Edit #{sheetName} -SheetDelHead tid ssh csh sheetName: Do you really want to delete sheet #{sheetName} from course type #{tid}-#{ssh}-#{csh}? Any associated submissions and corrections will be lost! +SheetDelHead tid ssh csh sheetName: Do you really want to delete sheet #{sheetName} from course category #{tid}-#{ssh}-#{csh}? Any associated submissions and corrections will be lost! SubmissionEditHead tid ssh csh sheetName: #{tid}-#{ssh}-#{csh} #{sheetName}: Edit/Create submission CorrectionHead tid ssh csh sheetName cid: #{tid}-#{ssh}-#{csh} #{sheetName}: Marking CorrectionsTitle: Assigned corrections @@ -48,8 +48,8 @@ CorrGrade: Mark submissions TableHeadingCsvImport: CSV import TableHeadingCsvExport: CSV export FavouritesEmptyTip: Your courses and recently visited courses are shown here. -FavouritesToggleTip: The display mode for the current course type can be changed between automatic, permanent and never with a click on the star symbol. -FavouritesUnavailableTip: Quick Actions for this course type are currently not available. +FavouritesToggleTip: The display mode for the current course category can be changed between automatic, permanent and never with a click on the star symbol. +FavouritesUnavailableTip: Quick Actions for this course category are currently not available. NavigationFavourites: Favourites ErrorResponseTitleInternalError internalError: An internal error occurred ErrorResponseTitleInvalidArgs invalidArgs: Request contained invalid arguments diff --git a/messages/uniworx/utils/table_column/de-de-formal.msg b/messages/uniworx/utils/table_column/de-de-formal.msg index 8597a7c2c..d8463570c 100644 --- a/messages/uniworx/utils/table_column/de-de-formal.msg +++ b/messages/uniworx/utils/table_column/de-de-formal.msg @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022 Gregor Kleen ,Sarah Vaupel ,Steffen Jost ,Winnie Ros +# SPDX-FileCopyrightText: 2022-24 Gregor Kleen ,Sarah Vaupel ,Steffen Jost ,Winnie Ros , Steffen Jost # # SPDX-License-Identifier: AGPL-3.0-or-later @@ -47,12 +47,12 @@ TablePassed: Bestanden TableNotPassed: Nicht bestanden TableTutorialTutors: Ausbilder TableTutorialName: Bezeichnung -TableTutorialType: Art -TableTutorialRoom: Regulärer Raum +TableTutorialType: Typ +TableTutorialRoom: Raum TableTutorialRoomHidden: Raum nur für Teilnehmer TableTutorialRoomIsUnset !ident-ok: — TableTutorialRoomIsHidden: Raum wird nur Teilnehmern angezeigt -TableTutorialTime: Zeit +TableTutorialOccurrence: Termin TableTutorialDeregisterUntil: Abmeldungen bis TableTutorialFirstDay: Starttag TableActionsHead: Aktionen @@ -80,6 +80,9 @@ TableCompanyFilter: Firma oder Nummer TableCompanyShort: Firmenkürzel TableCompanies: Firmen TablePrimeCompany: Primäre Firma +TablePrimeCompanyShort: Kürzel primäre Firma +TableBookingCompany: Buchende Firma +TableBookingCompanyShort: Kürzel buchende Firma TableCompanyNo: Firmennummer TableCompanyNos: Firmennummern TableCompanyUser: Firmenangehöriger @@ -97,8 +100,10 @@ TableCompanyNrRerouteDefault: Standard Umleitungen TableCompanyNrRerouteActive: Aktive Umleitungen TableRerouteActive: Umleitung TableCompanyPostalPreference: Benachrichtigungspräferenz neue Firmenangehörige +TableCompanyPinPassword: Pin Passwort für PDF Anhänge TableSupervisor: Ansprechpartner -TableSupervisee: Ansprechpartner für +TableSupervisorActive: Aktiver Ansprechpartner +TableSupervisee: Klient TableReason: Begründung TableCreationTime: Erstellungszeit TableJob !ident-ok: Job @@ -109,10 +114,18 @@ TableJobCreationInstance: Ersteller ActJobDelete: Job entfernen ActJobDeleteForce n@Int: Auch vor #{pluralDEnN n "Minute"} gesperrte Jobs entfernen TableJobActDeleteFeedback n@Int m@Int: #{n}/#{m} Jobs entfernt +ActJobSleep: Test Job einreihen +JobSleepNr: Anzahl Jobs +JobSleepSecs: Laufzeit in Sekunden pro Job +JobSleepNow: Prioriäts-Jobs +TableJobActSleepFeedback n@Int sec@Int prio@Bool: #{n} #{bool tempty "Prioritäts-" prio}#{pluralDEx 's' n "Job"} mit #{sec}s Laufzeit eingereiht. TableFilterComma: Es können mehrere alternative Suchkriterien mit Komma getrennt angegeben werden, wovon mindestens eines erfüllt werden muss. TableFilterCommaPlus: Mehrere alternative Suchkriterien mit Komma trennen. Mindestens ein Suchkriterium muss erfüllt werden, zusätzlich zu allen Suchkriterien mit vorangestelltem Plus-Symbol. TableFilterCommaPlusShort: Unterstützt mehrere Kriterien mit Komma-Plus, siehe oben. TableFilterCommaName: Mehrere Namen mit Komma trennen. -TableFilterCommaNameNr: Mehrere Namen oder Nummern mit Komma trennen. Nummern werden nur exakt gesucht. +TableFilterCommaNameNr: Mehrere Namen oder exakte Nummern mit Komma trennen. TableUserEdit: Benutzer bearbeiten -TableRows: Zeilen \ No newline at end of file +TableRows: Zeilen +TableUserParkingToken day@Text: Parkmarke #{day} +TableFilterSentBefore: Gesendet bis +TableFilterSentAfter: Gesendet ab \ No newline at end of file diff --git a/messages/uniworx/utils/table_column/en-eu.msg b/messages/uniworx/utils/table_column/en-eu.msg index d489426c1..8f2f7927f 100644 --- a/messages/uniworx/utils/table_column/en-eu.msg +++ b/messages/uniworx/utils/table_column/en-eu.msg @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022 Sarah Vaupel ,Steffen Jost ,Winnie Ros +# SPDX-FileCopyrightText: 2022-24 Sarah Vaupel ,Steffen Jost ,Winnie Ros , Steffen Jost # # SPDX-License-Identifier: AGPL-3.0-or-later @@ -20,7 +20,7 @@ TableMatrikelNr: AVS person no TableSex: Sex TableBirthday: Birthday TableSchool: Department -TableCourse: Course +TableCourse: Course category TableCourseMembers: Participants TableExamOccurrence: Occurrence/room TableExamName: Name @@ -48,14 +48,14 @@ TableNotPassed: Failed TableTutorialTutors: Instructors TableTutorialName: Name TableTutorialType: Type -TableTutorialRoom: Regular room +TableTutorialRoom: Room TableTutorialRoomHidden: Room only for participants TableTutorialRoomIsUnset: — TableTutorialRoomIsHidden: Room is only displayed to participants TableTutorialDeregisterUntil: Deregister until TableTutorialFirstDay: Start date TableActionsHead: Actions -TableTutorialTime: Time +TableTutorialOccurrence: Session TableNoFilter: No restriction TableUserMatriculation: AVS number TableColumnStudyFeatures: Features of study @@ -80,6 +80,9 @@ TableCompanyFilter: Company/Nr TableCompanyShort: Company shorthand TableCompanies: Companies TablePrimeCompany: Primary company +TablePrimeCompanyShort: Primary company shorthand +TableBookingCompany: Booking company +TableBookingCompanyShort: Booking company shorthand TableCompanyNo: Company number TableCompanyNos: Company numbers TableCompanyUser: Associate @@ -97,8 +100,10 @@ TableCompanyNrRerouteDefault: Default reroutes TableCompanyNrRerouteActive: Active reroutes TableRerouteActive: Reroute TableCompanyPostalPreference: Default notification preference +TableCompanyPinPassword: Pin password for PDF attachments TableSupervisor: Supervisor -TableSupervisee: Supervisor for +TableSupervisorActive: Active supervisor +TableSupervisee: Supervisee TableReason: Reason TableCreationTime: Creation TableJob !ident-ok: Job @@ -109,10 +114,18 @@ TableJobCreationInstance: Creator ActJobDelete: Delete job ActJobDeleteForce n: Also delete jobs locked #{pluralENsN n "minute"} ago TableJobActDeleteFeedback n@Int m@Int: #{n}/#{m} queued jobs deleted +ActJobSleep: Enqueue sleep job +JobSleepNr: Number of jobs +JobSleepSecs: Seconds per job +JobSleepNow: Priority jobs +TableJobActSleepFeedback n@Int sec@Int prio@Bool: #{n} #{bool tempty "priority " prio} sleep #{pluralENs n "job"} for #{sec}s enqueued. TableFilterComma: Separate multiple alternative filter criteria by comma, at least one of which must be fulfilled. TableFilterCommaPlus: Separate multiple alternative filter criteria by comma, at least one of which must be fulfilled in addition to all criteria preceded by a plus symbol. TableFilterCommaPlusShort: Support multiple criteria with comma/plus, see above. TableFilterCommaName: Separate names by comma. -TableFilterCommaNameNr: Separate names and numbers by comma. Numbers have to match exact. +TableFilterCommaNameNr: Separate names and exact numbers by comma. TableUserEdit: Edit user -TableRows: Rows \ No newline at end of file +TableRows: Rows +TableUserParkingToken day: Parking token #{day} +TableFilterSentBefore: Sent before +TableFilterSentAfter: Sent after \ No newline at end of file diff --git a/messages/uniworx/utils/utils/de-de-formal.msg b/messages/uniworx/utils/utils/de-de-formal.msg index 20e7f02c9..30305149c 100644 --- a/messages/uniworx/utils/utils/de-de-formal.msg +++ b/messages/uniworx/utils/utils/de-de-formal.msg @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2023-2024 Sarah Vaupel , Steffen Jost , Gregor Kleen , Sarah Vaupel , Winnie Ros +# SPDX-FileCopyrightText: 2023-25-2024 Sarah Vaupel , Steffen Jost , Gregor Kleen , Sarah Vaupel , Winnie Ros ,Steffen Jost # # SPDX-License-Identifier: AGPL-3.0-or-later @@ -81,6 +81,7 @@ MultiUserFieldExplanationAnyUser: Dieses Eingabefeld sucht in den Adressen aller MultiUserFieldInvitationExplanation: An Adressen, die so keinem Uni2work-Benutzer/keiner Uni2work-Benutzerin zugeordnet werden können, wird eine Einladung per E-Mail versandt. MultiUserFieldInvitationExplanationAlways: Es wird an alle Adressen, die Sie hier angeben, eine Einladung per E-Mail versandt. AmbiguousEmail: E-Mail-Adresse nicht eindeutig +UnknownEmail: E-Mail-Adresse konnte keinem bekannten Benutzer zugeordnet werden InvalidEmailAddress: E-Mail-Adresse ist ungültig InvalidEmailAddressWith e@Text: E-Mail-Adresse #{show e} ist ungültig MailFileAttachment: Dateianhang @@ -91,6 +92,7 @@ UtilExamResultVoided: Entwertet CourseOption tid@TermId ssh@SchoolId csh@CourseShorthand coursen@CourseName !ident-ok: #{tid} - #{ssh} - #{csh}: #{coursen} RoomReferenceNone !ident-ok: — RoomReferenceSimple !ident-ok: Text +RoomReferenceSimpleAt r@Text: in Raum #{r} RoomReferenceLink: Link & Anweisungen RoomReferenceSimpleText: Raum RoomReferenceSimpleTextPlaceholder: Raum @@ -102,6 +104,7 @@ UtilNoneSet: Keine angegeben UtilEmptyChoice: Auswahl war leer UtilEmptyNoChangeTip: Eine leere Eingabe belässt den vorherigen Wert unverändert. MultiNoSelection: Keine Auswahl +MustBePositive: muss positiv sein #invitation.hs InvitationAction: Aktion diff --git a/messages/uniworx/utils/utils/en-eu.msg b/messages/uniworx/utils/utils/en-eu.msg index cc93f6f66..f3fba88b0 100644 --- a/messages/uniworx/utils/utils/en-eu.msg +++ b/messages/uniworx/utils/utils/en-eu.msg @@ -1,18 +1,18 @@ -# SPDX-FileCopyrightText: 2023-2024 Sarah Vaupel , Sarah Vaupel , Winnie Ros , Steffen Jost +# SPDX-FileCopyrightText: 2023-25-2024 Sarah Vaupel , Sarah Vaupel , Winnie Ros , Steffen Jost ,Steffen Jost # # SPDX-License-Identifier: AGPL-3.0-or-later #communication.hs RecipientCustom: Custom recipients -RGCourseParticipants: Course type participants +RGCourseParticipants: Course category participants RGCourseLecturers: Course administrators -RGCourseCorrectors: Course type correctors +RGCourseCorrectors: Course category correctors RGCourseTutors: Course instructors -RGCourseParticipantsInTutorial: Course type participants who are registered for at least one course +RGCourseParticipantsInTutorial: Course category participants who are registered for at least one course RGCourseUnacceptedApplicants: Applicants not accepted RecipientToggleAll: All/None CommCourseTestSubject customSubject: [TEST] #{customSubject} -UtilCommCourseSubject: Course type message +UtilCommCourseSubject: Course category message UtilCommFirmSubject: Company message CommRecipients: Recipients CommRecipientsTip: You always receive a copy of the message @@ -27,7 +27,7 @@ RGSheetSubmittor shn: Submitted for exercise sheet “#{shn}” CommSubject: Subject CommContent: Content CommAttachments: Attachments -CommAttachmentsTip: In general it is preferable to upload files as course type material instead of sending them as attachments. You can then link to the material from the message. The file is then permanently accessable to the recipients and to persons that, for example, register for the Course type at a later date. +CommAttachmentsTip: In general it is preferable to upload files as course category material instead of sending them as attachments. You can then link to the material from the message. The file is then permanently accessable to the recipients and to persons that, for example, register for the Course category at a later date. CommSuccess n: Message was sent to #{n} #{pluralEN n "recipient" "recipients"} CommTestSuccess: Message was sent only to yourself for testing purposes @@ -81,6 +81,7 @@ MultiUserFieldExplanationAnyUser: This input searches through the addresses of a MultiUserFieldInvitationExplanation: For addresses, which are not found in this way, an invitation will be sent via email. MultiUserFieldInvitationExplanationAlways: An invitation will be sent via email to all addresses you enter here. AmbiguousEmail: Email address is ambiguous +UnknownEmail: Email adresse is not associated with any registred user InvalidEmailAddress: Email address is invalid InvalidEmailAddressWith e: Email asdress #{show e} is invalid MailFileAttachment: Attached file @@ -91,6 +92,7 @@ UtilExamResultVoided: Voided CourseOption tid ssh csh coursen: #{tid} - #{ssh} - #{csh}: #{coursen} RoomReferenceNone: — RoomReferenceSimple: Text +RoomReferenceSimpleAt r: at room #{r} RoomReferenceLink: Link & Instructions RoomReferenceSimpleText: Room RoomReferenceSimpleTextPlaceholder: Room @@ -102,6 +104,7 @@ UtilNoneSet: None set UtilEmptyChoice: Empty selection UtilEmptyNoChangeTip: Existing values remain unchanged if this field is left empty. MultiNoSelection: No selection +MustBePositive: must be positive #invitation.hs InvitationAction: Action diff --git a/models/company.model b/models/company.model index 0d3d07ce9..4d3619a8c 100644 --- a/models/company.model +++ b/models/company.model @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2022-24 Sarah Vaupel ,Steffen Jost +-- SPDX-FileCopyrightText: 2022-25 Sarah Vaupel ,Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later @@ -11,8 +11,10 @@ Company prefersPostal Bool default=true -- new company users prefers letters by post instead of email postAddress StoredMarkup Maybe -- default company postal address, including company name email UserEmail Maybe -- Case-insensitive generic company eMail address + pinPassword Bool default=true -- new company users only: should sensitive PDF email attachement be protected by a password? -- UniqueCompanyName name -- Should be Unique in AVS, but we do not yet need to enforce it -- UniqueCompanyShorthand shorthand -- unnecessary, since it is the primary key already UniqueCompanyAvsId avsId -- Should be the key, is not for historical reasons and for convenience in URLs and columns Primary shorthand -- newtype Key Company = CompanyKey { unCompanyKey :: CompanyShorthand } deriving Ord Eq Show Generic Binary + diff --git a/models/courses.model b/models/courses.model index ded2013dd..594bbf48e 100644 --- a/models/courses.model +++ b/models/courses.model @@ -28,13 +28,12 @@ Course -- Information about a single course; contained info is always visible TermSchoolCourseName term school name -- name must be unique within school and semester deriving Generic CourseEvent - type (CI Text) - course CourseId OnDeleteCascade OnUpdateCascade - room RoomReference Maybe - roomHidden Bool default=false - time Occurrences - note StoredMarkup Maybe - lastChanged UTCTime default=now() + type (CI Text) + course CourseId OnDeleteCascade OnUpdateCascade + roomHidden Bool default=false + time (JSONB Occurrences) + note StoredMarkup Maybe + lastChanged UTCTime default=now() deriving Generic CourseAppInstructionFile diff --git a/models/exams.model b/models/exams.model index e7dae4212..610bac4df 100644 --- a/models/exams.model +++ b/models/exams.model @@ -37,16 +37,17 @@ ExamPart UniqueExamPartName exam name !force deriving Read Show Eq Ord Generic ExamOccurrence - exam ExamId - name ExamOccurrenceName - room RoomReference Maybe - roomHidden Bool default=false - capacity Word64 Maybe - start UTCTime - end UTCTime Maybe - description StoredMarkup Maybe + exam ExamId + name ExamOccurrenceName + examiner UserId Maybe + room RoomReference Maybe + roomHidden Bool default=false + capacity Word64 Maybe + start UTCTime + end UTCTime Maybe + description StoredMarkup Maybe UniqueExamOccurrence exam name - deriving Generic + deriving Eq Ord Show Generic Binary ExamRegistration exam ExamId user UserId diff --git a/models/lms.model b/models/lms.model index 6f556e9a0..bca2cb0cd 100644 --- a/models/lms.model +++ b/models/lms.model @@ -1,30 +1,30 @@ --- SPDX-FileCopyrightText: 2022-23 Sarah Vaupel ,Steffen Jost +-- SPDX-FileCopyrightText: 2022-25 Sarah Vaupel ,Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later Qualification -- INVARIANT: 2*refreshWithin < validDuration - school SchoolId --TODO: Ansprechpartner der Schule in Briefe erwähnen - shorthand (CI Text) - name (CI Text) - description StoredMarkup Maybe -- user-defined large Html, ought to contain full description - validDuration Int Maybe -- > 0, qualification is valid indefinitely or for a specified number of months, use with addMonthsDay - auditDuration Int Maybe -- > 0, number of months to keep audit log and LmsUserIdents; or indefinitely (dangerous, since LmsIdents may run out) - refreshWithin CalendarDiffDays Maybe -- notify users about renewal within this number of month/days before expiry; to be used with addGregorianDurationClip - refreshReminder CalendarDiffDays Maybe -- send a second notification about renewal within this number of month/days before expiry - elearningStart Bool -- automatically schedule e-refresher - elearningRenews Bool default=true -- successful e-learing automatically increases validity automatically by validDuration - elearningLimit Int Maybe -- limit of e-learning attempts, currently only for informative purposes, as it is enforced by LMS only - lmsReuses QualificationId Maybe -- if set, lms is also included within the given qualification's lms, but only for direct routes. AuditDuration is used from this Qualification instead. - expiryNotification Bool default=true -- should expiryNotification be generated for this qualification? - avsLicence AvsLicence Maybe -- if set, valid QualificationUsers are synchronized to AVS as a driving licence - sapId Text Maybe -- if set, valid QualificationUsers with userCompanyPersonalNumber are transmitted via SAP interface under this id + school SchoolId -- 1 + shorthand (CI Text) -- 2 + name (CI Text) -- 3 + description StoredMarkup Maybe -- 4 user-defined large Html, ought to contain full description + validDuration Int Maybe -- 5 if > 0, qualification is valid indefinitely or for a specified number of months, use with addMonthsDay + auditDuration Int default=366 -- 6 number of days to keep LMS audit log and LmsUserIdents -- TODO: audit period for QualificationUser/Block as well + refreshWithin CalendarDiffDays Maybe -- 7 notify users about renewal within this number of month/days before expiry; to be used with addGregorianDurationClip + refreshReminder CalendarDiffDays Maybe -- 8 send a second notification about renewal within this number of month/days before expiry + elearningStart Bool -- 9 automatically schedule e-refresher + elearningRenews Bool default=true -- 10 successful e-learing automatically increases validity automatically by validDuration + elearningLimit Int Maybe -- 11 limit of e-learning attempts, currently only for informative purposes, as it is enforced by LMS only + lmsReuses QualificationId Maybe -- 12 if set, lms is also included within the given qualification's lms, but only for direct routes. AuditDuration is used from this Qualification instead. + expiryNotification Bool default=true -- 13 should expiryNotification be generated for this qualification? + avsLicence AvsLicence Maybe -- 14 if set, valid QualificationUsers are synchronized to AVS as a driving licence + sapId Text Maybe -- 15 if set, valid QualificationUsers with userCompanyPersonalNumber are transmitted via SAP interface under this id SchoolQualificationShort school shorthand -- must be unique per school and shorthand SchoolQualificationName school name -- must be unique per school and name -- across all schools, only one qualification may be a driving licence -- NO LONGER TRUE -- UniqueQualificationAvsLicence avsLicence !force -- either empty or unique -- NOTE: two NULL values are not equal for the purpose of Uniqueness constraints! - deriving Show Eq Generic + deriving Show Eq Generic Binary -- TODOs: -- - Enstehen Kosten, wenn Teilnehmer für KnowHow eingereiht werden, aber nicht am Kurs teilnehmen? @@ -164,4 +164,16 @@ LmsReportLog lock Bool -- (0|1) timestamp UTCTime default=now() missing Bool default=false + deriving Generic Show + +-- Table to manage unknown or orphaned lms identifiers +LmsOrphan + qualification QualificationId OnDeleteCascade OnUpdateCascade + ident LmsIdent -- must be unique accross all LMS courses! + seenFirst UTCTime default=now() -- first time reported by LMS + seenLast UTCTime default=now() -- last acknowledgement by LMS, deletion uses QualificationAuditDuration + deletedLast UTCTime Maybe -- last deletion request sent to LMS + resultLast LmsState -- last received learning state + reason Text Maybe -- to mark explicit e-learning deletions, etc + UniqueLmsOrphan qualification ident -- unlike other tables, LMS Idents must only be unique within qualification, allowing orphans to be handled independently deriving Generic Show \ No newline at end of file diff --git a/models/tutorials.model b/models/tutorials.model index be27d6a87..c1e237344 100644 --- a/models/tutorials.model +++ b/models/tutorials.model @@ -6,10 +6,9 @@ Tutorial json name TutorialName course CourseId OnDeleteCascade OnUpdateCascade type (CI Text) -- "Tutorium", "Zentralübung", ... - capacity Int Maybe -- limit for enrolment in this tutorial - room RoomReference Maybe + capacity Int Maybe -- limit for enrolment in this tutorial roomHidden Bool default=false - time Occurrences + time (JSONB Occurrences) regGroup (CI Text) Maybe -- each participant may register for one tutorial per regGroup registerFrom UTCTime Maybe registerTo UTCTime Maybe @@ -25,8 +24,19 @@ Tutor UniqueTutor tutorial user deriving Generic TutorialParticipant - tutorial TutorialId OnDeleteCascade OnUpdateCascade - user UserId + tutorial TutorialId OnDeleteCascade OnUpdateCascade + user UserId + company CompanyId Maybe + drivingPermit UserDrivingPermit Maybe + eyeExam UserEyeExam Maybe + note Text Maybe UniqueTutorialParticipant tutorial user - deriving Eq Ord Show - deriving Generic \ No newline at end of file + deriving Eq Ord Show Generic +TutorialParticipantDay + tutorial TutorialId OnDeleteCascade OnUpdateCascade + user UserId OnDeleteCascade OnUpdateCascade + day Day + attendance Bool default=true + note Text Maybe + UniqueTutorialParticipantDay tutorial user day + deriving Show Generic \ No newline at end of file diff --git a/models/users.model b/models/users.model index a8a8c286d..323ef6fbd 100644 --- a/models/users.model +++ b/models/users.model @@ -114,4 +114,9 @@ UserSupervisor reason Text Maybe -- miscellaneous reason, e.g. Winterservice supervisision UniqueUserSupervisor supervisor user -- each supervisor/user combination is unique (same supervisor can superviser the same user only once) deriving Generic Show - +UserDay + user UserId OnDeleteCascade OnUpdateCascade + day Day + parkingToken Bool default=false + UniqueUserDay user day + deriving Generic Show diff --git a/package-lock.json b/package-lock.json index 45a45a5d6..34f78b599 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13577,11 +13577,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } + "dev": true }, "node_modules/package-json-from-dist": { "version": "1.0.1", @@ -17814,7 +17810,6 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD", "peer": true }, "node_modules/tty-browserify": { @@ -18963,6 +18958,29 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "param-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", + "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", + "extraneous": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "tslib": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", + "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", + "extraneous": true + }, + "tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", + "extraneous": true, + "license": "MIT" } } } diff --git a/resources/fraport_icons_übersicht_2018-11-15.pdf b/resources/fraport_icons_uebersicht_2018-11-15.pdf similarity index 100% rename from resources/fraport_icons_übersicht_2018-11-15.pdf rename to resources/fraport_icons_uebersicht_2018-11-15.pdf diff --git a/resources/fraport_icons_übersicht_2018-11-15.pdf.license b/resources/fraport_icons_uebersicht_2018-11-15.pdf.license similarity index 100% rename from resources/fraport_icons_übersicht_2018-11-15.pdf.license rename to resources/fraport_icons_uebersicht_2018-11-15.pdf.license diff --git a/routes b/routes index 5902ce5e7..53cb7e6b5 100644 --- a/routes +++ b/routes @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2022-24 Gregor Kleen ,Sarah Vaupel ,Steffen Jost ,Wolfgang Witt ,Steffen Jost +-- SPDX-FileCopyrightText: 2022-2025 Sarah Vaupel , Gregor Kleen , Sarah Vaupel , Steffen Jost , Wolfgang Witt , Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later @@ -61,6 +61,7 @@ /users/#CryptoUUIDUser/hijack AdminHijackUserR GET POST !adminANDno-escalation /users/#CryptoUUIDUser/notifications UserNotificationR GET POST !self /users/#CryptoUUIDUser/password UserPasswordR GET POST !selfANDis-pw-hash +/users/#CryptoUUIDUser/recipients UserRecipientsR GET !self !/users/functionary-invite/new AdminNewFunctionaryInviteR GET POST !/users/functionary-invite AdminFunctionaryInviteR GET POST !/users/add AdminUserAddR GET POST @@ -129,6 +130,7 @@ /firms FirmAllR GET POST -- not yet !supervisor /firms/comm/+Companies FirmsCommR GET POST +/firms/supervision FirmsSupervisionR GET POST /firm/#CompanyShorthand/comm FirmCommR GET POST /firm/#CompanyShorthand FirmUsersR GET POST -- not yet !supervisor /firm/#CompanyShorthand/supers FirmSupersR GET POST -- not yet !supervisor @@ -161,8 +163,9 @@ /school SchoolListR GET !/school/new SchoolNewR GET POST /school/#SchoolId SchoolR: - / SchoolEditR GET POST - + /edit SchoolEditR GET POST + /day/#Day SchoolDayR GET POST + /day/#Day/check SchoolDayCheckR GET /participants ParticipantsListR GET !evaluation /participants/#TermId/#SchoolId ParticipantsR GET !evaluation @@ -232,6 +235,7 @@ /delete TDeleteR GET POST /participants TUsersR GET POST !tutor /participants/add TAddUserR GET POST !tutor + /participants/exam/#ExamName TExamR GET POST !tutor /register TRegisterR POST !timeANDcapacityANDcourse-registeredANDregister-group !timeANDtutorial-registered /communication TCommR GET POST !tutor /tutor-invite TInviteR GET POST !tutorANDtutor-control @@ -281,20 +285,22 @@ !/#UUID CryptoUUIDDispatchR GET !free -- just redirect -- !/*{CI FilePath} CryptoFileNameDispatchR GET !free -- Disabled until preliminary check for valid cID exists -/qualification QualificationAllR GET !free -/qualification/#SchoolId QualificationSchoolR GET !free -/qualification/#SchoolId/#QualificationShorthand QualificationR GET POST !free +/qualification QualificationAllR GET !free +/qualification/#SchoolId QualificationSchoolR GET !free +!/qualification/#SchoolId/new QualificationNewR GET POST -- not free +/qualification/#SchoolId/#QualificationShorthand QualificationR GET POST !free +/qualification/#SchoolId/#QualificationShorthand/edit QualificationEditR GET POST -- not free -- /qualification/#SchoolId/#QualificationShorthand/#CryptoUUIDUser QualificationUserR GET -- see LmsUserR -/qualifications/sap/direct QualificationSAPDirectR GET -- !token -- SAP EXPORT -- TODO reinstate token requirement +/qualifications/sap/direct QualificationSAPDirectR GET -- !token -- SAP EXPORT -- TODO reinstate token requirement -- LMS /lms LmsAllR GET POST /lms/#SchoolId LmsSchoolR GET /lms/#SchoolId/#QualificationShorthand LmsR GET POST -/lms/#SchoolId/#QualificationShorthand/edit LmsEditR GET POST -- new V2 LMS Interface /lms/#SchoolId/#QualificationShorthand/learners LmsLearnersR GET +/lms/#SchoolId/#QualificationShorthand/learners/orphans LmsOrphansR GET /lms/#SchoolId/#QualificationShorthand/learners/direct LmsLearnersDirectR GET !token -- LMS /lms/#SchoolId/#QualificationShorthand/report LmsReportR GET POST /lms/#SchoolId/#QualificationShorthand/report/upload LmsReportUploadR GET POST diff --git a/src/Application.hs b/src/Application.hs index cb061ee56..b708b6d3f 100644 --- a/src/Application.hs +++ b/src/Application.hs @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2022-2024 Sarah Vaupel , Gregor Kleen , Sarah Vaupel , Sarah Vaupel , Steffen Jost , David Mosbach +-- SPDX-FileCopyrightText: 2022-2024 Sarah Vaupel ,-2024 Gregor Kleen , Sarah Vaupel , Sarah Vaupel , Steffen Jost , David Mosbach ,Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later @@ -114,13 +114,8 @@ import GHC.RTS.Flags (getRTSFlags) import qualified Prometheus -import qualified Data.IntervalMap.Strict as IntervalMap - import qualified Utils.Pool as Custom -import Utils.Postgresql -import Handler.Utils.Memcached (manageMemcachedLocalInvalidations) - import qualified System.Clock as Clock import Utils.Avs (mkAvsQuery) @@ -136,6 +131,7 @@ import Handler.Users.Add import Handler.Admin import Handler.Term import Handler.School +import Handler.School.DayTasks import Handler.Course import Handler.Sheet import Handler.Submission @@ -217,18 +213,6 @@ makeFoundation appSettings''@AppSettings{..} = do appJobState <- liftIO newEmptyTMVarIO appHealthReport <- liftIO $ newTVarIO Set.empty - appFileSourceARC <- for appFileSourceARCConf $ \ARCConf{..} -> do - ah <- initARCHandle arccMaximumGhost arccMaximumWeight - void . Prometheus.register $ arcMetrics ARCFileSource ah - return ah - appFileSourcePrewarm <- for appFileSourcePrewarmConf $ \PrewarmCacheConf{..} -> do - lh <- initLRUHandle precMaximumWeight - void . Prometheus.register $ lruMetrics LRUFileSourcePrewarm lh - return lh - appFileInjectInhibit <- liftIO $ newTVarIO IntervalMap.empty - for_ (guardOnM (isn't _JobsOffload appJobMode) appInjectFiles) $ \_ -> - void . Prometheus.register $ injectInhibitMetrics appFileInjectInhibit - appStartTime <- liftIO getCurrentTime -- We need a log function to create a connection pool. We need a connection -- pool to create our foundation. And we need our foundation to get a @@ -237,7 +221,7 @@ makeFoundation appSettings''@AppSettings{..} = do -- from there, and then create the real foundation. let mkFoundation :: _ -> (forall m. MonadIO m => Custom.Pool' m DBConnLabel DBConnUseState SqlBackend) -> _ - mkFoundation appSettings' appConnPool appSmtpPool appLdapPool appCryptoIDKey appSessionStore appSecretBoxKey appWidgetMemcached appJSONWebKeySet appClusterID appMemcached appMemcachedLocal appUploadCache appVerpSecret appAuthKey appAuthPlugins appPersonalisedSheetFilesSeedKey appVolatileClusterSettingsCache appAvsQuery = UniWorX{..} + mkFoundation appSettings' appConnPool appSmtpPool appLdapPool appCryptoIDKey appSessionStore appSecretBoxKey appWidgetMemcached appJSONWebKeySet appClusterID appMemcached appUploadCache appVerpSecret appAuthKey appAuthPlugins appPersonalisedSheetFilesSeedKey appVolatileClusterSettingsCache appAvsQuery = UniWorX{..} tempFoundation = mkFoundation (error "appSettings' forced in tempFoundation") (error "connPool forced in tempFoundation") @@ -250,7 +234,6 @@ makeFoundation appSettings''@AppSettings{..} = do (error "JSONWebKeySet forced in tempFoundation") (error "ClusterID forced in tempFoundation") (error "memcached forced in tempFoundation") - (error "memcachedLocal forced in tempFoundation") (error "MinioConn forced in tempFoundation") (error "VerpSecret forced in tempFoundation") (error "AuthKey forced in tempFoundation") @@ -384,12 +367,6 @@ makeFoundation appSettings''@AppSettings{..} = do $logWarnS "setup" "Clearing memcached" liftIO $ Memcached.flushAll memcachedConn return AppMemcached{..} - appMemcachedLocal <- for appMemcachedLocalConf $ \ARCConf{..} -> do - memcachedLocalARC <- initARCHandle arccMaximumGhost arccMaximumWeight - void . Prometheus.register $ arcMetrics ARCMemcachedLocal memcachedLocalARC - memcachedLocalInvalidationQueue <- newTVarIO mempty - memcachedLocalHandleInvalidations <- allocateLinkedAsync . managePostgresqlChannel appDatabaseConf ChannelMemcachedLocalInvalidation $ manageMemcachedLocalInvalidations memcachedLocalARC memcachedLocalInvalidationQueue - return AppMemcachedLocal{..} appSessionStore <- mkSessionStore appSettings'' sqlPool `customRunSqlPool` sqlPool @@ -428,7 +405,7 @@ makeFoundation appSettings''@AppSettings{..} = do $logDebugS "Runtime configuration" $ tshowCrop appSettings' -- TODO: reimplement user db failover - let foundation = mkFoundation appSettings' sqlPool smtpPool ldapPool appCryptoIDKey appSessionStore appSecretBoxKey appWidgetMemcached appJSONWebKeySet appClusterID appMemcached appMemcachedLocal appUploadCache appVerpSecret appAuthKey appAuthPlugins appPersonalisedSheetFilesSeedKey appVolatileClusterSettingsCache appAvsQuery + let foundation = mkFoundation appSettings' sqlPool smtpPool ldapPool appCryptoIDKey appSessionStore appSecretBoxKey appWidgetMemcached appJSONWebKeySet appClusterID appMemcached appUploadCache appVerpSecret appAuthKey appAuthPlugins appPersonalisedSheetFilesSeedKey appVolatileClusterSettingsCache appAvsQuery -- Return the foundation $logInfoS "setup" "*** DONE ***" diff --git a/src/Audit.hs b/src/Audit.hs index 8bff261b3..25bc3ccb5 100644 --- a/src/Audit.hs +++ b/src/Audit.hs @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2023-24 Gregor Kleen ,Steffen Jost +-- SPDX-FileCopyrightText: 2023-2024 Gregor Kleen ,Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/src/Audit/Types.hs b/src/Audit/Types.hs index f20aaed95..1535b06ea 100644 --- a/src/Audit/Types.hs +++ b/src/Audit/Types.hs @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2022-24 Gregor Kleen ,Sarah Vaupel ,Steffen Jost +-- SPDX-FileCopyrightText: 2022-2024 Gregor Kleen ,Sarah Vaupel ,Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later @@ -213,6 +213,12 @@ data Transaction , transactionNote :: Maybe Text , transactionReceived :: UTCTime -- when was the csv file received? } + | TransactionLmsTerminated + { transactionQualification :: QualificationId + , transactionLmsIdent :: LmsIdent + , transactionLmsUser :: UserId + , transactionNote :: Maybe Text + } | TransactionQualificationUserEdit -- Note that a renewal always entails unblocking as well! { transactionUser :: UserId -- qualification holder that is updated , transactionQualificationUser :: QualificationUserId -- not really necessary, maybe remove? @@ -283,7 +289,8 @@ data AdminProblem , adminProblemUserOld :: Maybe UserId -- previous superior } | AdminProblemCompanySuperiorNotFound -- a company received a new superior user through AVS, but user could not be created from email - { adminProblemEmail :: Maybe Text -- new superior user's email, not found in LDAP + { adminProblemUser :: UserId -- user who had a supervisor but no longer has, due to supervisor change + , adminProblemEmail :: Maybe Text -- new superior user's email, not found in LDAP , adminProblemCompany :: CompanyId -- affected company , adminProblemUserOld :: Maybe UserId -- previous superior } diff --git a/src/Crypto/Saltine/Instances.hs b/src/Crypto/Saltine/Instances.hs index 8bbc259e0..380ec8985 100644 --- a/src/Crypto/Saltine/Instances.hs +++ b/src/Crypto/Saltine/Instances.hs @@ -1,5 +1,4 @@ --- {-# LANGUAGE BangPatterns #-} -{-# OPTIONS_GHC -Wwarn #-} +{-# OPTIONS_GHC -Wwarn -fno-warn-orphans #-} -- SPDX-FileCopyrightText: 2024-2025 Sarah Vaupel -- @@ -64,8 +63,9 @@ foreign import ccall unsafe "sodium_bin2hex" bin2hex :: ByteString -> String bin2hex bs = let tlen = S.length bs * 2 + 1 in S8.unpack . S8.init . snd . buildUnsafeByteString tlen $ \t -> - constByteStrings [bs] $ \[(pbs, _)] -> - c_sodium_bin2hex t (fromIntegral tlen) pbs (fromIntegral $ S.length bs) + let aux [(pbs, _)] = c_sodium_bin2hex t (fromIntegral tlen) pbs (fromIntegral $ S.length bs) + aux _ = error "Crypto.Saltine.Instances.bin2hex reached an impossible computation path" + in constByteStrings [bs] aux instance Show Key where show k = "SecretBox.Key {hashesTo = \"" <> (bin2hex . shorthash nullShKey $ encode k) <> "}\"" diff --git a/src/CryptoID.hs b/src/CryptoID.hs index 9c4fdfaa1..b47bf3e6d 100644 --- a/src/CryptoID.hs +++ b/src/CryptoID.hs @@ -117,4 +117,20 @@ instance {-# OVERLAPS #-} FromJSON (E.CryptoID "PrintJob" (CI FilePath)) where instance {-# OVERLAPS #-} FromJSONKey (E.CryptoID "PrintJob" (CI FilePath)) where fromJSONKey = FromJSONKeyTextParser $ maybe (fail "Could not parse CryptoPrintJob") return . fromPathPiece instance {-# OVERLAPS #-} ToMarkup (E.CryptoID "PrintJob" (CI FilePath)) where - toMarkup = toMarkup . toPathPiece \ No newline at end of file + toMarkup = toMarkup . toPathPiece + + +-- instance PathPiece a => PathPiece [a] where +-- toPathPiece = textBracket '[' ']' . Text.intercalate "," . map toPathPiece +-- fromPathPiece (textUnbracket '[' ']' . Text.strip -> Just t) +-- | null t = Just [] +-- | otherwise = mapM fromPathPiece $ Text.split (==',') t +-- fromPathPiece _ = Nothing + +instance PathPiece [E.CryptoID "ExamOccurrence" UUID] where -- required for a form field sending multiple ids + fromPathPiece (textUnbracket '[' ']' . Text.strip -> Just t) + | null t = Just [] + | otherwise = fromPathMultiPiece $ Text.split (==',') t + fromPathPiece _ = Nothing + + toPathPiece = textBracket '[' ']' . Text.intercalate "," . toPathMultiPiece \ No newline at end of file diff --git a/src/Database/Esqueleto/Utils.hs b/src/Database/Esqueleto/Utils.hs index 3052f652f..c4763c5ba 100644 --- a/src/Database/Esqueleto/Utils.hs +++ b/src/Database/Esqueleto/Utils.hs @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2022-24 Gregor Kleen ,Steffen Jost ,Steffen Jost +-- SPDX-FileCopyrightText: 2022-2025 Gregor Kleen ,Steffen Jost ,Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later @@ -9,9 +9,12 @@ module Database.Esqueleto.Utils ( true, false , vals, justVal, justValList, toValues , isJust, alt + , isNumerical, hasLetter , isInfixOf, hasInfix , isPrefixOf_, hasPrefix_ - , strConcat, substring + , strConcat + , substring, substringRegex + , decodeBase64, encodeEscape, mailContentContains , (=?.), (?=.) , (=~.), (~=.) , (>~.), (<~.) @@ -49,10 +52,11 @@ module Database.Esqueleto.Utils , unKey , subSelectCountDistinct , selectCountRows, selectCountDistinct - , selectMaybe , str2text, str2text' + , str2citext , num2text --, text2num , day, day', dayMaybe, interval, diffDays, diffTimes + , withinPeriod , exprLift , explicitUnsafeCoerceSqlExprValue , psqlVersion_ @@ -80,6 +84,7 @@ import Database.Esqueleto.Utils.TH import qualified Data.Text as Text import qualified Data.Text.Lazy as Lazy (Text) import qualified Data.ByteString.Lazy as Lazy (ByteString) +import qualified Data.CaseInsensitive as CI import Crypto.Hash (Digest, SHA256) @@ -95,7 +100,7 @@ import Data.Monoid (Last(..)) import Utils (commaSeparatedText) -- import Utils.Set (concatMapSet) - +import Model.Types.Mail (MailContent) {-# ANN any ("HLint: ignore Use any" :: String) #-} {-# ANN all ("HLint: ignore Use all" :: String) #-} @@ -150,40 +155,51 @@ infixl 4 ?=. -- | like (=?.) but also succeeds if the right-hand side is NULL. Can often be avoided by moving from where- to join-condition! infixl 4 =~. (=~.) :: PersistField typ => E.SqlExpr (E.Value typ) -> E.SqlExpr (E.Value (Maybe typ)) -> E.SqlExpr (E.Value Bool) -(=~.) a b = E.isNothing b E.||. (E.just a E.==. b) +-- (=~.) a b = E.isNothing b E.||. (E.just a E.==. b) -- avoid expensive E.||. +(=~.) a b = a E.==. E.coalesceDefault [b] a infixl 4 ~=. (~=.) :: PersistField typ => E.SqlExpr (E.Value (Maybe typ)) -> E.SqlExpr (E.Value typ) -> E.SqlExpr (E.Value Bool) -(~=.) a b = E.isNothing a E.||. (a E.==. E.just b) +-- (~=.) a b = E.isNothing a E.||. (a E.==. E.just b) -- avoid expensive E.||. +(~=.) a b = b E.==. E.coalesceDefault [a] b --- | like (>.), but also succeeds if the right-hand side is NULL +-- | like (>=.), but also succeeds if the right-hand side is NULL infixl 4 >~. (>~.) :: PersistField typ => E.SqlExpr (E.Value typ) -> E.SqlExpr (E.Value (Maybe typ)) -> E.SqlExpr (E.Value Bool) -(>~.) a b = E.isNothing b E.||. (E.just a E.>. b) +-- (>~.) a b = E.isNothing b E.||. (E.just a E.>. b) +(>~.) a b = a E.>=. E.coalesceDefault [b] a --- | like (<.), but also succeeds if the right-hand side is NULL +-- | like (<=.), but also succeeds if the right-hand side is NULL infixl 4 <~. (<~.) :: PersistField typ => E.SqlExpr (E.Value typ) -> E.SqlExpr (E.Value (Maybe typ)) -> E.SqlExpr (E.Value Bool) -(<~.) a b = E.isNothing b E.||. (E.just a E.<. b) +-- (<~.) a b = E.isNothing b E.||. (E.just a E.<. b) +(<~.) a b = a E.<=. E.coalesceDefault [b] a infixr 2 ~., ~*., !~., !~*. -- | PostgreSQL regular expression match, case sensitive. Works, but may throw SQL error for unblanced parenthesis, etc. Not suitable for dbTable filters -(~.) :: E.SqlString s => E.SqlExpr (E.Value s) -> E.SqlExpr (E.Value s) -> E.SqlExpr (E.Value Bool) +(~.) :: (E.SqlString s, E.SqlString t) => E.SqlExpr (E.Value s) -> E.SqlExpr (E.Value t) -> E.SqlExpr (E.Value Bool) (~.) = E.unsafeSqlBinOp " ~ " -- | PostgreSQL regular expression match, case insensitive. Works, but may throw SQL errors -(~*.) :: E.SqlString s => E.SqlExpr (E.Value s) -> E.SqlExpr (E.Value s) -> E.SqlExpr (E.Value Bool) +(~*.) :: (E.SqlString s, E.SqlString t) => E.SqlExpr (E.Value s) -> E.SqlExpr (E.Value t) -> E.SqlExpr (E.Value Bool) (~*.) = E.unsafeSqlBinOp " ~* " -- | PostgreSQL regular expression does not match, case sensitive. Works, but may throw SQL error for unblanced parenthesis, etc. Not suitable for dbTable filters -(!~.) :: E.SqlString s => E.SqlExpr (E.Value s) -> E.SqlExpr (E.Value s) -> E.SqlExpr (E.Value Bool) +(!~.) :: (E.SqlString s, E.SqlString t) => E.SqlExpr (E.Value s) -> E.SqlExpr (E.Value t) -> E.SqlExpr (E.Value Bool) (!~.) = E.unsafeSqlBinOp " !~ " -- | PostgreSQL regular expression does not match, case insensitive. Works, but may throw SQL errors -(!~*.) :: E.SqlString s => E.SqlExpr (E.Value s) -> E.SqlExpr (E.Value s) -> E.SqlExpr (E.Value Bool) +(!~*.) :: (E.SqlString s, E.SqlString t) => E.SqlExpr (E.Value s) -> E.SqlExpr (E.Value t) -> E.SqlExpr (E.Value Bool) (!~*.) = E.unsafeSqlBinOp " !~* " +-- | PostgreSQL regex test if value contains only numbers +isNumerical :: E.SqlString s => E.SqlExpr (E.Value s) -> E.SqlExpr (E.Value Bool) +isNumerical = (~. E.val ("^[0-9]+$"::Text)) + +-- | PostgreSQL regex test if value contains at least one letter +hasLetter :: E.SqlString s => E.SqlExpr (E.Value s) -> E.SqlExpr (E.Value Bool) +hasLetter = (~*. E.val ("[a-z]"::Text)) -- | Negation of `isNothing` which is missing isJust :: PersistField typ => E.SqlExpr (E.Value (Maybe typ)) -> E.SqlExpr (E.Value Bool) @@ -245,6 +261,37 @@ substring (E.ERaw _m1 f1) (E.ERaw _m2 f2) (E.ERaw _m3 f3) , strVals <> fromiVals <> foriVals ) +substringRegex :: ( E.SqlString str, E.SqlString from) + => E.SqlExpr (E.Value str) + -> E.SqlExpr (E.Value from) + -> E.SqlExpr (E.Value str) +substringRegex (E.ERaw _m1 f1) (E.ERaw _m2 f2) + = E.ERaw E.noMeta $ \_nParens info -> + let (strTLB, strVals) = f1 E.Parens info + (fromiTLB, fromiVals) = f2 E.Parens info + in ( "SUBSTRING" <> E.parens (E.parens strTLB <> " FROM " <> E.parens fromiTLB) + , strVals <> fromiVals + ) + +-- useful for searching within MailContent in db +decodeBase64 :: E.SqlString str => E.SqlExpr (E.Value str) -> E.SqlExpr (E.Value str) +decodeBase64 = E.unsafeSqlFunction "decode" . (, E.val "base64" :: E.SqlExpr (E.Value Text)) + +encodeEscape :: E.SqlString str => E.SqlExpr (E.Value str) -> E.SqlExpr (E.Value str) +encodeEscape = E.unsafeSqlFunction "encode" . (, E.val "escape" :: E.SqlExpr (E.Value Text)) + +mailContentContains :: E.SqlString str => E.SqlExpr (E.Value MailContent) -> E.SqlExpr (E.Value str) -> E.SqlExpr (E.Value Bool) +mailContentContains hay needle = hasNeedle plainText E.||. hasNeedle encodedBase64 + where + hayText :: E.SqlExpr (E.Value Text) = E.unsafeSqlCastAs "text" hay + hasNeedle = isInfixOf needle + encodedBase64 = encodeEscape $ decodeBase64 $ + substringRegex hayText $ E.val reB64 + plainText = substringRegex hayText $ E.val rePlain + reB64 :: Text = ".*\\{\"type\": \"text/plain; charset=utf-8\", \"content\": \\{\"content\": \"(.*?)\", \"encoding\": \"base64\"\\}.*" + rePlain :: Text = ".*\\{\"type\": \"text/plain; charset=utf-8\", \"content\": \"(.*?)\", \"headers\": \\[\\], \"encoding\": \"quoted-printable-text\".*" + + explicitUnsafeCoerceSqlExprValue :: forall b a. Text -> E.SqlExpr (E.Value a) @@ -281,7 +328,8 @@ subSelectOr q = parens . E.subSelectUnsafe $ flip (E.unsafeSqlAggregateFunction parens :: E.SqlExpr (E.Value a) -> E.SqlExpr (E.Value a) parens = E.unsafeSqlFunction "" --- | Workaround for Esqueleto-Bug not placing parenthesis after NOT, see #155 +-- | Workaround for Esqueleto-Bug not placing parenthesis after NOT. +-- This leads to erroneous filters. For examples, see DevOps #1970 not__ :: E.SqlExpr (E.Value Bool) -> E.SqlExpr (E.Value Bool) not__ = E.not_ . parens @@ -510,10 +558,13 @@ allFilter fltrs needle criterias = F.foldr aux true fltrs where aux fltr acc = fltr needle criterias E.&&. acc --- | Descending order of this field or SqlExpression, but with NULLS at the end. +-- | Ascending order of this field or SqlExpression, but with NULLS at the end. +-- For bool, just use ASC, since false < true < null ascNullsFirst :: PersistField a => E.SqlExpr (E.Value a) -> E.SqlExpr E.OrderBy ascNullsFirst = E.orderByExpr " ASC NULLS FIRST" +-- | Descending order of this field or SqlExpression, but with NULLS at the end. +-- Use this if you want the order to be true, false, null descNullsLast :: PersistField a => E.SqlExpr (E.Value a) -> E.SqlExpr E.OrderBy descNullsLast = E.orderByExpr " DESC NULLS LAST" @@ -537,6 +588,7 @@ strip = E.unsafeSqlFunction "TRIM" infix 4 `ciEq` +-- Note that this function is unnecessary if the DB type is citext ciEq :: E.SqlString s => E.SqlExpr (E.Value s) -> E.SqlExpr (E.Value s) -> E.SqlExpr (E.Value Bool) ciEq a b = lower a E.==. lower b @@ -739,8 +791,9 @@ selectCountDistinct q = do _other -> error "E.countDistinct did not return exactly one result" -selectMaybe :: (E.SqlSelect a r, MonadIO m) => E.SqlQuery a -> E.SqlReadT m (Maybe r) -selectMaybe = fmap listToMaybe . E.select . (<* E.limit 1) +-- DEPRECATED: use Database.Esqueleto.selectOne instead +-- selectMaybe :: (E.SqlSelect a r, MonadIO m) => E.SqlQuery a -> E.SqlReadT m (Maybe r) -- aka selectFirst +-- selectMaybe = fmap listToMaybe . E.select . (<* E.limit 1) -- | convert something that is like a text to text str2text :: E.SqlString a => E.SqlExpr (E.Value a) -> E.SqlExpr (E.Value Text) @@ -749,6 +802,9 @@ str2text = E.unsafeSqlCastAs "text" str2text' :: E.SqlString a => E.SqlExpr (E.Value (Maybe a)) -> E.SqlExpr (E.Value (Maybe Text)) str2text' = E.unsafeSqlCastAs "text" +str2citext :: E.SqlString a => E.SqlExpr (E.Value a) -> E.SqlExpr (E.Value (CI.CI Text)) +str2citext = E.unsafeSqlCastAs "citext" + -- | cast numeric type to text, which is safe and allows for an inefficient but safe comparison of numbers stored as text and numbers num2text :: Num n => E.SqlExpr (E.Value n) -> E.SqlExpr (E.Value Text) num2text = E.unsafeSqlCastAs "text" @@ -767,6 +823,19 @@ day' = E.unsafeSqlCastAs "date" dayMaybe :: E.SqlExpr (E.Value (Maybe UTCTime)) -> E.SqlExpr (E.Value (Maybe Day)) dayMaybe = E.unsafeSqlCastAs "date" +-- | Given an occurrence with start-time and maybe an end-time, does it overlap with a given day interval? +-- If there is no end-time, then the start-time must be in between. +withinPeriod :: (Day, Day) -> E.SqlExpr (E.Value UTCTime) -> E.SqlExpr (E.Value (Maybe UTCTime)) -> E.SqlExpr (E.Value Bool) +withinPeriod (dbegin, dend) tfrom tto = day tfrom E.<=. E.val dend + E.&&. E.coalesceDefault [dayMaybe tto] + (day tfrom) E.>=. E.val dbegin +-- Alternative variant which SJ expected to be more efficient, if there is an index on the first argument available, +-- but FraportGPT thinks otherwise: "OR conditions may prevent the efficient use of an index. OR conditions can sometimes lead to a full table scan, whereas COALESCE is quite cheap" +-- withinPeriod (dstart, dend) tfrom tto = day tfrom E.<=. E.val dend +-- E.&&. ( day tfrom E.>=. E.val dstart +-- E.||. (isJust tto E.&&. dayMaybe tto E.>=. justVal dstart )) + + interval :: CalendarDiffDays -> E.SqlExpr (E.Value Day) -- E.+=. requires both types to be the same, so we use Day -- interval _ = E.unsafeSqlCastAs "interval" $ E.unsafeSqlValue "'P2Y'" -- tested working example interval = E.unsafeSqlCastAs "interval". E.unsafeSqlValue . wrapSqlString . Text.Builder.fromString . iso8601Show @@ -827,4 +896,4 @@ truncateTable :: (MonadIO m, BackendCompatible SqlBackend backend, PersistEntity => proxy record -> ReaderT backend m () truncateTable tbl = let tblName :: Text = P.unEntityNameDB $ P.entityDB $ P.entityDef tbl - in E.rawExecute ("TRUNCATE TABLE " <> tblName <> " RESTART IDENTITY") [] \ No newline at end of file + in E.rawExecute ("TRUNCATE TABLE " <> tblName <> " RESTART IDENTITY") [] diff --git a/src/Database/Esqueleto/Utils/TH.hs b/src/Database/Esqueleto/Utils/TH.hs index 546d85b29..7f423cc48 100644 --- a/src/Database/Esqueleto/Utils/TH.hs +++ b/src/Database/Esqueleto/Utils/TH.hs @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2022 Gregor Kleen ,Sarah Vaupel ,Steffen Jost +-- SPDX-FileCopyrightText: 2022-2024 Gregor Kleen ,Sarah Vaupel ,Steffen Jost ,Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later @@ -9,7 +9,7 @@ module Database.Esqueleto.Utils.TH , sqlInTuple, sqlInTuples , _unValue , unValueN, unValueNIs - , sqlIJproj, sqlLOJproj, sqlFOJproj + , sqlIJproj, sqlLOJproj, sqlFOJproj, sqlMIXproj, sqlMIXproj' ) where import ClassyPrelude @@ -26,6 +26,9 @@ import Data.List (foldr1, foldl) import Utils.TH import Control.Lens.Iso (Iso', iso) +{-# ANN module ("HLint: ignore Redundant bracket"::String) #-} + + class E.SqlSelect a r => SqlIn a r | a -> r, r -> a where sqlIn :: a -> [r] -> E.SqlExpr (E.Value Bool) @@ -99,7 +102,7 @@ unValueNIs arity uvIdx = do -- | Generic projections for InnerJoin-tuples -- gives I-th element of N-tuple of left-associative InnerJoin-pairs, i.e. -- --- > $(projN n m) :: (t1 `E.InnerJoin` .. `E.InnerJoin` tn) -> tm@ (for m<=n) +-- > $(sqlIJproj n m) :: (t1 `E.InnerJoin` .. `E.InnerJoin` tn) -> tm@ (for m<=n) sqlIJproj :: Int -> Int -> ExpQ sqlIJproj = leftAssociativePairProjection 'E.InnerJoin @@ -108,3 +111,23 @@ sqlLOJproj = leftAssociativePairProjection 'E.LeftOuterJoin sqlFOJproj :: Int -> Int -> ExpQ sqlFOJproj = leftAssociativePairProjection 'E.FullOuterJoin + +-- | Generic projections for Join-tuple +-- gives i-th element of n-tuple of left-associative join pairs, i.e. +-- +-- > $(sqlMIXproj "IR" 3) :: ((t1 `E.InnerJoin` t2) `E.RightOuterJoin` t3) -> t3 +sqlMIXproj :: String -> Int -> ExpQ +sqlMIXproj = leftAssociativeProjection . map decodeJoin + where + decodeJoin 'I' = 'E.InnerJoin + decodeJoin 'L' = 'E.LeftOuterJoin + decodeJoin 'R' = 'E.RightOuterJoin + decodeJoin 'F' = 'E.FullOuterJoin + decodeJoin 'O' = 'E.FullOuterJoin + decodeJoin 'X' = 'E.CrossJoin + decodeJoin 'C' = 'E.CrossJoin + decodeJoin c = error $ "Database.Esqueleto.Utils.TH.sqlMIXproj: received unknown SQL join kind \"" ++ c:"\"" -- always raised at compile time, so this is ok + +-- Alternative using `reify`; works, but may require `$(return [])` between type definition and call to workaround ghc staging problems +sqlMIXproj' :: Name -> Int -> ExpQ +sqlMIXproj' t i = extractConstructorNames t >>= flip leftAssociativeProjection i diff --git a/src/Database/Persist/Types/Instances.hs b/src/Database/Persist/Types/Instances.hs index e32ed5951..1cdf4a70a 100644 --- a/src/Database/Persist/Types/Instances.hs +++ b/src/Database/Persist/Types/Instances.hs @@ -27,7 +27,7 @@ instance Hashable LiteralType instance Binary LiteralType instance NFData LiteralType - + deriving instance Generic PersistValue instance Hashable PersistValue diff --git a/src/Foundation/Authorization.hs b/src/Foundation/Authorization.hs index 770ef64f9..23e3eae9d 100644 --- a/src/Foundation/Authorization.hs +++ b/src/Foundation/Authorization.hs @@ -38,7 +38,7 @@ import Handler.Utils.I18n import Handler.Utils.Routes import Utils.Course (courseIsVisible) import Utils.Metrics (observeAuthTagEvaluation, AuthTagEvalOutcome(..)) - + import qualified Data.Set as Set import qualified Data.Aeson as JSON import qualified Data.HashSet as HashSet @@ -95,7 +95,7 @@ instance Exception InvalidAuthTag type AuthTagsEval m = AuthDNF -> Maybe (AuthId UniWorX) -> Route UniWorX -> Bool -> WriterT (Set AuthTag) m AuthResult - + data AccessPredicate = APPure (Maybe (AuthId UniWorX) -> Route UniWorX -> Bool -> Reader MsgRenderer AuthResult) | APHandler (Maybe (AuthId UniWorX) -> Route UniWorX -> Bool -> HandlerFor UniWorX AuthResult) @@ -174,7 +174,7 @@ cacheAPDB mExp k mkV cont = APBindDB $ \mAuthId route isWrite -> do v <- mkV memcachedBySet mExp k v either (return . Left) (fmap Right . lift) $ cont mAuthId route isWrite v - + -- cacheAP' :: ( Binary k -- , Typeable v, Binary v -- ) @@ -185,7 +185,7 @@ cacheAPDB mExp k mkV cont = APBindDB $ \mAuthId route isWrite -> do -- cacheAP' mExp mkKV cont = APBind $ \mAuthId route isWrite -> case mkKV mAuthId route isWrite of -- Just (k, mkV) -> either (return . Left) (fmap Right) . cont mAuthId route isWrite . Just =<< memcachedBy mExp k mkV -- Nothing -> either (return . Left) (fmap Right) $ cont mAuthId route isWrite Nothing - + cacheAPDB' :: ( Binary k , Typeable v, Binary v, NFData v ) @@ -313,7 +313,8 @@ isDryRunDB = fmap unIsDryRun . cached . fmap MkIsDryRun $ orM dnf <- throwLeft $ routeAuthTags currentRoute let eval :: forall m''. MonadAP m'' => AuthTagsEval m'' - eval dnf' mAuthId' route' isWrite' = evalAuthTags 'isDryRun (AuthTagActive $ const True) eval (noTokenAuth dnf') mAuthId' route' isWrite' + -- eval dnf' mAuthId' route' isWrite' = evalAuthTags 'isDryRun (AuthTagActive $ const True) eval (noTokenAuth dnf') mAuthId' route' isWrite' + eval dnf' = evalAuthTags 'isDryRun (AuthTagActive $ const True) eval (noTokenAuth dnf') in guardAuthResult <=< evalWriterT $ eval dnf mAuthId currentRoute isWrite return False @@ -368,7 +369,8 @@ validateBearer mAuthId' route' isWrite' token' = $runCachedMemoT $ for4 memo val noTokenAuth = over _dnfTerms . Set.filter . noneOf (re _nullable . folded) $ (== AuthToken) . plVar eval :: forall m'. MonadAP m' => AuthTagsEval m' - eval dnf' mAuthId'' route'' isWrite'' = evalAuthTags 'validateBearer (AuthTagActive $ const True) eval (noTokenAuth dnf') mAuthId'' route'' isWrite'' + -- eval dnf' mAuthId'' route'' isWrite'' = evalAuthTags 'validateBearer (AuthTagActive $ const True) eval (noTokenAuth dnf') mAuthId'' route'' isWrite'' + eval dnf' = evalAuthTags 'validateBearer (AuthTagActive $ const True) eval (noTokenAuth dnf') bearerAuthority' <- hoist apRunDB $ do bearerAuthority' <- flip foldMapM bearerAuthority $ \case @@ -538,14 +540,14 @@ tagAccessPredicate AuthAdmin = cacheAPSchoolFunction SchoolAdmin (Just $ Right d guardMExceptT (isJust adrights) (unauthorizedI MsgUnauthorizedSiteAdmin) return Authorized -tagAccessPredicate AuthSupervisor = APDB $ \_ _ mAuthId route _ -> case route of +tagAccessPredicate AuthSupervisor = APDB $ \_ _ mAuthId route _ -> case route of ForProfileR cID -> checkSupervisor (mAuthId, cID) ForProfileDataR cID -> checkSupervisor (mAuthId, cID) FirmAllR -> checkAnySupervisor mAuthId FirmUsersR fsh -> checkCompanySupervisor (mAuthId, fsh) FirmSupersR fsh -> checkCompanySupervisor (mAuthId, fsh) - r -> $unsupportedAuthPredicate AuthSupervisor r - where + r -> $unsupportedAuthPredicate AuthSupervisor r + where checkSupervisor sup@(mAuthId, cID) = $cachedHereBinary sup . exceptT return return $ do authId <- maybeExceptT AuthenticationRequired $ return mAuthId uid <- decrypt cID @@ -553,13 +555,13 @@ tagAccessPredicate AuthSupervisor = APDB $ \_ _ mAuthId route _ -> case route of guardMExceptT isSupervisor (unauthorizedI MsgUnauthorizedSupervisor) return Authorized checkCompanySupervisor sup@(mAuthId, fsh) = $cachedHereBinary sup . exceptT return return $ do - authId <- maybeExceptT AuthenticationRequired $ return mAuthId + authId <- maybeExceptT AuthenticationRequired $ return mAuthId -- isSupervisor <- lift . existsBy $ UniqueUserCompany authId $ CompanyKey fsh isSupervisor <- lift $ exists [UserCompanyUser ==. authId, UserCompanyCompany ==. CompanyKey fsh, UserCompanySupervisor ==. True] guardMExceptT isSupervisor (unauthorizedI $ MsgUnauthorizedCompanySupervisor fsh) return Authorized checkAnySupervisor mAuthId = $cachedHereBinary mAuthId . exceptT return return $ do - authId <- maybeExceptT AuthenticationRequired $ return mAuthId + authId <- maybeExceptT AuthenticationRequired $ return mAuthId isSupervisor <- lift $ exists [UserSupervisorSupervisor ==. authId] guardMExceptT isSupervisor (unauthorizedI MsgUnauthorizedAnySupervisor) return Authorized @@ -692,7 +694,7 @@ tagAccessPredicate AuthLecturer = cacheAPDB' (Just $ Right diffMinute) mkLecture _ | is _Nothing mAuthId' -> return AuthenticationRequired CourseR{} -> unauthorizedI MsgUnauthorizedLecturer EExamR{} -> unauthorizedI MsgUnauthorizedExternalExamLecturer - _other -> unauthorizedI MsgUnauthorizedSchoolLecturer + _other -> unauthorizedI MsgUnauthorizedSchoolLecturer | otherwise -> Left $ APDB $ \_ _ mAuthId route _ -> case route of CourseR tid ssh csh _ -> $cachedHereBinary (mAuthId, tid, ssh, csh) . exceptT return return $ do authId <- maybeExceptT AuthenticationRequired $ return mAuthId @@ -722,7 +724,7 @@ tagAccessPredicate AuthLecturer = cacheAPDB' (Just $ Right diffMinute) mkLecture return Authorized where mkLecturerList _ route _ = case route of - CourseR{} -> cacheLecturerList + CourseR{} -> cacheLecturerList EExamR{} -> Just ( AuthCacheExternalExamStaffList , fmap (setOf $ folded . _Value) . E.select . E.from $ return . (E.^. ExternalExamStaffUser) @@ -1199,7 +1201,7 @@ tagAccessPredicate AuthExamRegistered = APDB $ \_ _ mAuthId route _ -> case rout guardMExceptT hasRegistration $ unauthorizedI MsgUnauthorizedRegisteredExam return Authorized CSheetR tid ssh csh shn _ -> exceptT return return $ do - requiredExam' <- $cachedHereBinary (tid, ssh, csh, shn) . lift . E.selectMaybe . E.from $ \(course `E.InnerJoin` sheet) -> do + requiredExam' <- $cachedHereBinary (tid, ssh, csh, shn) . lift . E.selectOne . E.from $ \(course `E.InnerJoin` sheet) -> do E.on $ sheet E.^. SheetCourse E.==. course E.^. CourseId E.where_ $ course E.^. CourseTerm E.==. E.val tid E.&&. course E.^. CourseSchool E.==. E.val ssh @@ -1704,7 +1706,7 @@ evalAccessWith :: (HasCallStack, MonadThrow m, MonadAP m) => [(AuthTag, Bool)] - evalAccessWith assumptions route isWrite = do mAuthId <- liftHandler maybeAuthId evalAccessWithFor assumptions mAuthId route isWrite - + evalAccessWithDB :: (HasCallStack, MonadThrow m, MonadAP m, BackendCompatible SqlReadBackend backend) => [(AuthTag, Bool)] -> Route UniWorX -> Bool -> ReaderT backend m AuthResult evalAccessWithDB = evalAccessWith diff --git a/src/Foundation/I18n.hs b/src/Foundation/I18n.hs index ffa5778cb..dc46f00a2 100644 --- a/src/Foundation/I18n.hs +++ b/src/Foundation/I18n.hs @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2022-23 Gregor Kleen ,Sarah Vaupel ,Sarah Vaupel ,Steffen Jost ,Steffen Jost ,Winnie Ros +-- SPDX-FileCopyrightText: 2022-2025 Gregor Kleen ,Sarah Vaupel ,Sarah Vaupel ,Steffen Jost ,Steffen Jost ,Winnie Ros -- -- SPDX-License-Identifier: AGPL-3.0-or-later @@ -40,10 +40,9 @@ module Foundation.I18n , ShortStudyFieldType(..) , StudyDegreeTermType(..) , ErrorResponseTitle(..) - , UniWorXMessages(..) - , uniworxMessages + -- , UniWorXMessages(..), uniworxMessages , unRenderMessage, unRenderMessage', unRenderMessageLenient - , SomeMessages(..) + , SomeMessages(..), pattern SomeMsgs, pattern SpaceMsgs, pattern JoinMsgs , someMessages , module Foundation.I18n.TH ) where @@ -62,7 +61,7 @@ import qualified Data.Text as Text import Utils.Form -import qualified GHC.Exts (IsList(..)) +-- import qualified GHC.Exts (IsList(..)) -- for UniWorXMessages import Yesod.Form.I18n.German import Yesod.Form.I18n.English @@ -280,16 +279,31 @@ mkMessageAddition ''UniWorX "Avs" "messages/uniworx/categories/avs" "de-de-forma embedRenderMessage ''UniWorX ''LmsStatus (uncurry ((<>) . (<> "Status")) . Text.splitAt 3) +-- | Flexible variant of `UniWorXMessages` allowing custom separation +data SomeMessages master = SomeMessages Text [SomeMessage master] -newtype SomeMessages master = SomeMessages [SomeMessage master] - deriving newtype (Semigroup, Monoid) +pattern SomeMsgs :: [SomeMessage master] -> SomeMessages master +pattern SomeMsgs msgs = SomeMessages "\n " msgs + +pattern SpaceMsgs :: [SomeMessage master] -> SomeMessages master +pattern SpaceMsgs msgs = SomeMessages " " msgs + +pattern JoinMsgs :: [SomeMessage master] -> SomeMessages master +pattern JoinMsgs msgs = SomeMessages "" msgs + +-- Not yet needed: +-- instance Semigroup (SomeMessage master) where +-- (SomeMessages s1 t1) <> (SomeMessages _s2 t2) = SomeMessages s1 $ t1 ++ t2 + +-- instance Monoid (SomeMessage master) where +-- mempty = SomeMessages mempty mempty instance master ~ master' => RenderMessage master (SomeMessages master') where - renderMessage a b (SomeMessages msgs) = Text.intercalate "\n " $ renderMessage a b <$> msgs + renderMessage a b (SomeMessages sep msgs) = Text.intercalate sep $ renderMessage a b <$> msgs -- | convenienience function if all messages happen to belong to the exact same type someMessages :: RenderMessage master msg => [msg] -> SomeMessages master -someMessages msgs = SomeMessages $ SomeMessage <$> msgs +someMessages msgs = SomeMessages "\n " $ SomeMessage <$> msgs instance RenderMessage UniWorX (Maybe LmsStatus) where -- useful for Filter with optionsFinite @@ -535,22 +549,24 @@ instance HasResolution a => ToMessage (Fixed a) where newtype ErrorResponseTitle = ErrorResponseTitle ErrorResponse embedRenderMessageVariant ''UniWorX ''ErrorResponseTitle ("ErrorResponseTitle" <>) +-- -- A list of messages is a message by itself. Uses blank for separation. +-- -- Deprecated for now; replaced by the more flexibles SomeMessages. Easy to reinstate. +-- -- +-- newtype UniWorXMessages = UniWorXMessages [SomeMessage UniWorX] +-- deriving stock (Generic) +-- deriving newtype (Semigroup, Monoid) -newtype UniWorXMessages = UniWorXMessages [SomeMessage UniWorX] - deriving stock (Generic) - deriving newtype (Semigroup, Monoid) +-- instance IsList UniWorXMessages where +-- type Item UniWorXMessages = SomeMessage UniWorX +-- fromList = UniWorXMessages +-- toList (UniWorXMessages msgs) = msgs -instance IsList UniWorXMessages where - type Item UniWorXMessages = SomeMessage UniWorX - fromList = UniWorXMessages - toList (UniWorXMessages msgs) = msgs +-- instance RenderMessage UniWorX UniWorXMessages where +-- renderMessage foundation ls (UniWorXMessages msgs) = +-- Text.unwords $ map (renderMessage foundation ls) msgs -- Text.unwords uses blank for separation -instance RenderMessage UniWorX UniWorXMessages where - renderMessage foundation ls (UniWorXMessages msgs) = - Text.unwords $ map (renderMessage foundation ls) msgs - -uniworxMessages :: [UniWorXMessage] -> UniWorXMessages -uniworxMessages = UniWorXMessages . map SomeMessage +-- uniworxMessages :: [UniWorXMessage] -> UniWorXMessages +-- uniworxMessages = UniWorXMessages . map SomeMessage -- This instance is required to use forms. You can modify renderMessage to diff --git a/src/Foundation/Navigation.hs b/src/Foundation/Navigation.hs index 1a4a41892..eda5daf8c 100644 --- a/src/Foundation/Navigation.hs +++ b/src/Foundation/Navigation.hs @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2022 Gregor Kleen ,Sarah Vaupel ,Sarah Vaupel ,Steffen Jost ,Steffen Jost ,Winnie Ros +-- SPDX-FileCopyrightText: 2022-2025 Gregor Kleen ,Sarah Vaupel ,Sarah Vaupel ,Steffen Jost ,Steffen Jost ,Winnie Ros ,Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later @@ -29,10 +29,12 @@ import Foundation.Routes import Foundation.I18n import Foundation.Authorization +import Utils.Company (areThereInsaneCompanySupervisions) +import Utils.Sheet + import Handler.Utils.DateTime import Handler.Utils.Memcached import Handler.Utils.ExamOffice.Course -import Utils.Sheet import qualified Data.Set as Set import qualified Data.Map as Map @@ -105,6 +107,7 @@ breadcrumb (UserPasswordR cID) = useRunDB $ do -> i18nCrumb MsgMenuUserPassword . Just $ AdminUserR cID | otherwise -> i18nCrumb MsgMenuUserPassword $ Just ProfileR +breadcrumb (UserRecipientsR cID) = i18nCrumb MsgBreadcrumbUserRecipients . Just $ AdminUserR cID breadcrumb AdminNewFunctionaryInviteR = i18nCrumb MsgMenuLecturerInvite $ Just UsersR breadcrumb AdminFunctionaryInviteR = i18nCrumb MsgBreadcrumbFunctionaryInvite Nothing @@ -129,6 +132,7 @@ breadcrumb ConfigInterfacesR = i18nCrumb MsgConfigInterfacesHeading $ Just breadcrumb FirmAllR = i18nCrumb MsgMenuFirms Nothing breadcrumb FirmsCommR{} = i18nCrumb MsgMenuFirmsComm $ Just FirmAllR +breadcrumb FirmsSupervisionR= i18nCrumb MsgMenuFirmsSupervision $ Just FirmAllR breadcrumb FirmUsersR{} = i18nCrumb MsgMenuFirmUsers $ Just FirmAllR breadcrumb (FirmSupersR fsh)= i18nCrumb MsgMenuFirmSupervisors $ Just $ FirmUsersR fsh breadcrumb (FirmCommR fsh)= i18nCrumb MsgMenuFirmsComm $ Just $ FirmUsersR fsh @@ -146,13 +150,19 @@ breadcrumb PrintAckR{} = i18nCrumb MsgMenuPrintSend $ Just PrintCenter breadcrumb PrintAckDirectR{}= i18nCrumb MsgMenuPrintAck $ Just PrintCenterR breadcrumb PrintLogR = i18nCrumb MsgMenuPrintLog $ Just PrintCenterR -breadcrumb SchoolListR = i18nCrumb MsgMenuSchoolList $ Just AdminR -breadcrumb (SchoolR ssh sRoute) = case sRoute of - SchoolEditR -> useRunDB . maybeT (i18nCrumb MsgBreadcrumbSchool $ Just SchoolListR) $ do +breadcrumb SchoolListR = i18nCrumb MsgMenuSchoolList $ Just AdminR +breadcrumb (SchoolR ssh SchoolEditR) = + useRunDB . maybeT (i18nCrumb MsgBreadcrumbSchool $ Just SchoolListR) $ do School{..} <- MaybeT $ get ssh isAdmin <- lift $ hasReadAccessTo SchoolListR return (CI.original schoolName, bool Nothing (Just SchoolListR) isAdmin) -breadcrumb SchoolNewR = i18nCrumb MsgMenuSchoolNew $ Just SchoolListR +breadcrumb (SchoolR ssh (SchoolDayR d)) = do + dt <- formatTime SelFormatDate d + mr <- getMessageRender + return (mr $ MsgMenuSchoolDay ssh dt, Just SchoolListR) +breadcrumb (SchoolR ssh (SchoolDayCheckR d)) + = i18nCrumb MsgMenuSchoolDayCheck $ Just (SchoolR ssh (SchoolDayR d)) +breadcrumb SchoolNewR = i18nCrumb MsgMenuSchoolNew $ Just SchoolListR breadcrumb (ExamOfficeR EOExamsR) = i18nCrumb MsgMenuExamOfficeExams Nothing breadcrumb (ExamOfficeR EOFieldsR) = i18nCrumb MsgMenuExamOfficeFields . Just $ ExamOfficeR EOExamsR @@ -181,12 +191,14 @@ breadcrumb InstanceR = i18nCrumb MsgMenuInstance Nothing breadcrumb StatusR = i18nCrumb MsgMenuHealth Nothing -- never displayed breadcrumb QualificationAllR = i18nCrumb MsgMenuQualifications Nothing -breadcrumb (QualificationSchoolR ssh ) = useRunDB . maybeT (i18nCrumb MsgBreadcrumbSchool . Just $ SchoolListR) $ do -- redirect only, used in other breadcrumbs +breadcrumb (QualificationSchoolR ssh ) = useRunDB . maybeT (i18nCrumb MsgBreadcrumbSchool . Just $ SchoolListR) $ do guardM . lift . existsBy . UniqueSchoolShorthand $ unSchoolKey ssh return (CI.original $ unSchoolKey ssh, Just QualificationAllR) +breadcrumb (QualificationNewR ssh ) = i18nCrumb MsgMenuQualificationNew $ Just $ QualificationSchoolR ssh breadcrumb (QualificationR ssh qsh) =useRunDB . maybeT (i18nCrumb MsgBreadcrumbCourse . Just $ QualificationSchoolR ssh) $ do guardM . lift . existsBy $ SchoolQualificationShort ssh qsh return (CI.original qsh, Just $ QualificationSchoolR ssh) +breadcrumb (QualificationEditR ssh qsh) = i18nCrumb MsgMenuQualificationEdit $ Just $ QualificationR ssh qsh breadcrumb QualificationSAPDirectR = i18nCrumb MsgMenuSap $ Just QualificationAllR -- never displayed breadcrumb LmsAllR = i18nCrumb MsgMenuLms Nothing @@ -196,10 +208,10 @@ breadcrumb (LmsSchoolR ssh ) = useRunDB . maybeT (i18nCrumb MsgBrea breadcrumb (LmsR ssh qsh) = useRunDB . maybeT (i18nCrumb MsgBreadcrumbCourse . Just $ LmsSchoolR ssh) $ do guardM . lift . existsBy $ SchoolQualificationShort ssh qsh return (CI.original qsh, Just $ LmsSchoolR ssh) -breadcrumb (LmsEditR ssh qsh) = i18nCrumb MsgMenuLmsEdit $ Just $ LmsR ssh qsh -- v2 breadcrumb (LmsLearnersR ssh qsh) = i18nCrumb MsgMenuLmsLearners $ Just $ LmsR ssh qsh breadcrumb (LmsLearnersDirectR ssh qsh) = i18nCrumb MsgMenuLmsLearners $ Just $ LmsLearnersR ssh qsh -- never displayed, TypedContent +breadcrumb (LmsOrphansR ssh qsh) = i18nCrumb MsgLmsOrphans $ Just $ LmsLearnersR ssh qsh breadcrumb (LmsReportR ssh qsh) = i18nCrumb MsgMenuLmsReport $ Just $ LmsR ssh qsh breadcrumb (LmsReportUploadR ssh qsh) = i18nCrumb MsgMenuLmsUpload $ Just $ LmsReportR ssh qsh breadcrumb (LmsReportDirectR ssh qsh) = i18nCrumb MsgMenuLmsUpload $ Just $ LmsReportR ssh qsh -- never displayed @@ -305,6 +317,7 @@ breadcrumb (CourseR tid ssh csh (TutorialR tutn sRoute)) = case sRoute of guardM . lift . hasReadAccessTo $ CTutorialR tid ssh csh tutn TUsersR return (CI.original tutn, Just $ CourseR tid ssh csh CTutorialListR) TAddUserR -> i18nCrumb MsgMenuTutorialAddMembers . Just $ CTutorialR tid ssh csh tutn TUsersR + TExamR exn -> i18nCrumb (MsgMenuTutorialExam exn) . Just $ CTutorialR tid ssh csh tutn TUsersR 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 @@ -935,19 +948,37 @@ pageActions :: ( MonadHandler m , MonadUnliftIO m ) => Route UniWorX -> m [Nav] -pageActions NewsR = return - [ NavPageActionPrimary - { navLink = NavLink - { navLabel = MsgMenuOpenCourses - , navRoute = (CourseListR, [("courses-openregistration", toPathPiece True)]) - , navAccess' = NavAccessTrue - , navType = NavTypeLink { navModal = False } - , navQuick' = mempty - , navForceActive = False +pageActions NewsR = do + now <- liftIO getCurrentTime + let nowaday = utctDay now + nd <- formatTime SelFormatDate now + schools <- useRunDB $ selectList [] [Asc SchoolShorthand] + return $ + ( NavPageActionPrimary + { navLink = NavLink + { navLabel = MsgMenuOpenCourses + , navRoute = (CourseListR, [("courses-openregistration", toPathPiece True)]) + , navAccess' = NavAccessTrue + , navType = NavTypeLink { navModal = False } + , navQuick' = mempty + , navForceActive = False + } + , navChildren = [] } - , navChildren = [] - } - ] + ) : + [ NavPageActionPrimary + { navLink = NavLink + { navLabel = MsgMenuSchoolDay ssh nd + , navRoute = SchoolR ssh $ SchoolDayR nowaday + , navAccess' = NavAccessTrue + , navType = NavTypeLink { navModal = False } + , navQuick' = mempty + , navForceActive = False + } + , navChildren = [] + } + | sch <- schools, let ssh = sch ^. _entityKey + ] pageActions (CourseR tid ssh csh CShowR) = do materialListSecondary <- pageQuickActions NavQuickViewPageActionSecondary $ CourseR tid ssh csh MaterialListR tutorialListSecondary <- pageQuickActions NavQuickViewPageActionSecondary $ CourseR tid ssh csh CTutorialListR @@ -1177,6 +1208,18 @@ pageActions SchoolListR = return , navChildren = [] } ] +pageActions (SchoolR ssh (SchoolDayR nd)) = return $ + ( NavPageActionPrimary + { navLink = defNavLinkModal MsgMenuSchoolDayCheck $ SchoolR ssh $ SchoolDayCheckR nd + , navChildren = [] + } + ) : + [ NavPageActionPrimary + { navLink = defNavLink msg $ SchoolR ssh (SchoolDayR $ addDays n nd) + , navChildren = [] + } + | (msg, n) <- [(MsgWeekPrev, -7), (MsgDayPrev, -1), (MsgDayNext, 1), (MsgWeekNext, 7)] + ] pageActions UsersR = return [ NavPageActionPrimary { navLink = NavLink @@ -1763,6 +1806,12 @@ pageActions (CTutorialR tid ssh csh tutn TUsersR) = do } } ] +pageActions (CTutorialR tid ssh csh _tutn (TExamR ename)) = return + [ NavPageActionPrimary + { navLink = defNavLink MsgMenuExamEditComplete $ CourseR tid ssh csh $ ExamR ename EEditR + , navChildren = [] + } + ] pageActions (CourseR tid ssh csh CExamListR) = return [ NavPageActionPrimary { navLink = NavLink @@ -1957,7 +2006,7 @@ pageActions (CSheetR tid ssh csh shn SShowR) = do { navLabel = MsgMenuSheetPersonalisedFiles , navRoute = CSheetR tid ssh csh shn SPersonalFilesR , navAccess' = NavAccessDB $ - let onlyPersonalised = fmap (maybe False $ not . E.unValue) . E.selectMaybe . E.from $ \(sheet `E.InnerJoin` course) -> do + let onlyPersonalised = fmap (maybe False $ not . E.unValue) . E.selectOne . E.from $ \(sheet `E.InnerJoin` course) -> do E.on $ sheet E.^. SheetCourse E.==. course E.^. CourseId E.where_$ sheet E.^. SheetName E.==. E.val shn E.&&. course E.^. CourseTerm E.==. E.val tid @@ -2378,6 +2427,20 @@ pageActions ParticipantsListR = return , navChildren = [] } ] +pageActions QualificationAllR = do + schools <- useRunDB $ selectList [] [Asc SchoolShorthand] -- selectKeysList here mysteriously leads to runtime error: InternalError "selectKeysImpl:" School: keyFromValues failed + return [ NavPageActionSecondary { navLink = defNavLink (SomeMessage $ unSchoolKey sid) (QualificationSchoolR sid) } | Entity{entityKey=sid} <- schools ] +pageActions (QualificationSchoolR sid) = return + [ NavPageActionSecondary { + navLink = defNavLink MsgMenuQualificationNew $ QualificationNewR sid + } + ] +pageActions (QualificationR sid qsh) = return + [ NavPageActionSecondary { + navLink = defNavLink MsgMenuQualificationEdit $ QualificationEditR sid qsh + } + ] + pageActions (LmsR sid qsh) = return [ NavPageActionPrimary { navLink = defNavLink MsgMenuLmsLearners $ LmsLearnersR sid qsh @@ -2393,12 +2456,18 @@ pageActions (LmsR sid qsh) = return ] } , NavPageActionSecondary { - navLink = defNavLink MsgMenuLmsEdit $ LmsEditR sid qsh + navLink = defNavLink MsgMenuQualificationEdit $ QualificationEditR sid qsh } -- , NavPageActionSecondary { -- navLink = defNavLink MsgMenuLmsFake $ LmsFakeR sid qsh -- } ] +pageActions (LmsLearnersR sid qsh) = return + [ NavPageActionPrimary + { navLink = defNavLink MsgLmsOrphans $ LmsOrphansR sid qsh + , navChildren = [] + } + ] pageActions ApiDocsR = return [ NavPageActionPrimary { navLink = NavLink @@ -2412,6 +2481,12 @@ pageActions ApiDocsR = return , navChildren = [] } ] +pageActions FirmAllR = do + let navLink = defNavLink MsgMenuFirmsSupervision FirmsSupervisionR + navChildren = [] + (thereAre,_) <- liftHandler areThereInsaneCompanySupervisions + return [ NavPageActionPrimary{..} | thereAre ] + pageActions (FirmUsersR fsh) = return [ NavPageActionPrimary { navLink = defNavLink MsgTableCompanyNrSupers $ FirmSupersR fsh @@ -2559,7 +2634,7 @@ submissionList tid csh shn uid = withReaderT (projectBackend @SqlReadBackend) . E.on $ sheet E.^. SheetCourse E.==. course E.^. CourseId E.where_ $ submissionUser E.^. SubmissionUserUser E.==. E.val uid - E.&&. sheet E.^. SheetName E.==. E.val shn + E.&&. sheet E.^. SheetName E.==. E.val shn E.&&. course E.^. CourseShorthand E.==. E.val csh E.&&. course E.^. CourseTerm E.==. E.val tid diff --git a/src/Foundation/SiteLayout.hs b/src/Foundation/SiteLayout.hs index d6dd8d9e9..5ed506e12 100644 --- a/src/Foundation/SiteLayout.hs +++ b/src/Foundation/SiteLayout.hs @@ -258,7 +258,7 @@ siteLayout' overrideHeading widget = do forM_ authTagPivots $ \authTag -> addMessageWidget Info $ msgModal [whamlet|_{MsgUnauthorizedDisabledTag authTag}|] (Left $ SomeRoute (AuthPredsR, catMaybes [(toPathPiece GetReferer, ) . toPathPiece <$> mcurrentRoute])) getMessages - + storedReasonAndToggleRoute <- case mcurrentRoute of (Just (CourseR tid ssh csh _)) -> (, Just . SomeRoute $ CourseR tid ssh csh CFavouriteR) <$> storedFavouriteReason tid ssh csh muid _otherwise -> pure (Nothing, Nothing) @@ -270,8 +270,8 @@ siteLayout' overrideHeading widget = do , nav' , contentHeadline , mmsgs - , maybe userDefaultMaxFavouriteTerms userMaxFavouriteTerms $ view _2 <$> muid - , maybe userDefaultTheme userTheme $ view _2 <$> muid + , maybe userDefaultMaxFavouriteTerms (userMaxFavouriteTerms . view _2) muid + , maybe userDefaultTheme (userTheme . view _2) muid , storedReasonAndToggleRoute ) @@ -303,7 +303,7 @@ siteLayout' overrideHeading widget = do | otherwise = Set.drop (Set.size ts - n) ts currentTerms = toTermKeySet $ filter (views (_2 . _Value) . maybe True $ is _FavouriteCurrent) favourites' toTermKeySet = setOf $ folded . _1 . _2 . to unTermKey - + favourites <- fmap catMaybes . forM favourites' $ \(c@(_, tid, ssh, csh), E.Value mFavourite, courseVisible, mayView, mayEdit) -> let courseRoute = CourseR tid ssh csh CShowR favouriteReason = fromMaybe FavouriteCurrent mFavourite @@ -512,7 +512,7 @@ siteLayout' overrideHeading widget = do $(widgetFile "default-layout") withUrlRenderer $(hamletFile "templates/default-layout-wrapper.hamlet") - + getSystemMessageState :: (MonadHandler m, HandlerSite m ~ UniWorX, BearerAuthSite UniWorX) => SystemMessageId -> m UserSystemMessageState getSystemMessageState smId = liftHandler $ do muid <- maybeAuthId @@ -598,7 +598,7 @@ applySystemMessages = maybeT_ . catchMPlus (Proxy @CryptoIDError) $ do -- FIXME: Move headings into their respective handlers - + -- | Method for specifying page heading for handlers that call defaultLayout -- -- All handlers whose code is under our control should use diff --git a/src/Foundation/Type.hs b/src/Foundation/Type.hs index 249169879..1dcecdc08 100644 --- a/src/Foundation/Type.hs +++ b/src/Foundation/Type.hs @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2022 Gregor Kleen ,Sarah Vaupel ,Steffen Jost +-- SPDX-FileCopyrightText: 2022-26 Gregor Kleen ,Sarah Vaupel ,Steffen Jost ,Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later @@ -11,11 +11,11 @@ module Foundation.Type , _SessionStorageMemcachedSql, _SessionStorageAcid , AppMemcached(..) , _memcachedKey, _memcachedConn - , AppMemcachedLocal(..) - , _memcachedLocalARC , SMTPPool , _appSettings', _appStatic, _appConnPool, _appSmtpPool, _appLdapPool, _appWidgetMemcached, _appHttpManager, _appLogger, _appLogSettings, _appCryptoIDKey, _appClusterID, _appInstanceID, _appJobState, _appSessionStore, _appSecretBoxKey, _appJSONWebKeySet, _appHealthReport, _appMemcached, _appUploadCache, _appVerpSecret, _appAuthKey, _appPersonalisedSheetFilesSeedKey, _appVolatileClusterSettingsCache, _appAvsQuery - , DB, DBRead, Form, MsgRenderer, MailM, DBFile + , DB + , DBRead, DBRead', DBReadUq, DBReadUq' + , Form, MsgRenderer, MailM, DBFile ) where import Import.NoFoundation @@ -32,15 +32,10 @@ import qualified Jose.Jwk as Jose import qualified Database.Memcached.Binary.IO as Memcached import Network.Minio (MinioConn) -import Data.IntervalMap.Strict (IntervalMap) - import qualified Utils.Pool as Custom import Utils.Metrics (DBConnUseState) -import qualified Data.ByteString.Lazy as Lazy -import Data.Time.Clock.POSIX (POSIXTime) -import GHC.Fingerprint (Fingerprint) import Handler.Sheet.PersonalisedFiles.Types (PersonalisedSheetFilesSeedKey) import Utils.Avs (AvsQuery()) @@ -62,13 +57,6 @@ data AppMemcached = AppMemcached makeLenses_ ''AppMemcached -data AppMemcachedLocal = AppMemcachedLocal - { memcachedLocalARC :: ARCHandle (Fingerprint, Lazy.ByteString) Int (NFDynamic, Maybe POSIXTime) - , memcachedLocalHandleInvalidations :: Async () - , memcachedLocalInvalidationQueue :: TVar (Seq (Fingerprint, Lazy.ByteString)) - } deriving (Generic) - -makeLenses_ ''AppMemcachedLocal -- | The foundation datatype for your application. This can be a good place to -- keep settings and values requiring initialization before your application @@ -93,14 +81,10 @@ data UniWorX = UniWorX , appJSONWebKeySet :: Jose.JwkSet , appHealthReport :: TVar (Set (UTCTime, HealthReport)) , appMemcached :: Maybe AppMemcached - , appMemcachedLocal :: Maybe AppMemcachedLocal , appUploadCache :: Maybe MinioConn , appVerpSecret :: VerpSecret , appAuthKey :: Auth.Key , appAuthPlugins :: [AuthPlugin UniWorX] - , appFileSourceARC :: Maybe (ARCHandle (FileContentChunkReference, (Int, Int)) Int ByteString) - , appFileSourcePrewarm :: Maybe (LRUHandle (FileContentChunkReference, (Int, Int)) UTCTime Int ByteString) - , appFileInjectInhibit :: TVar (IntervalMap UTCTime (Set FileContentReference)) , appPersonalisedSheetFilesSeedKey :: PersonalisedSheetFilesSeedKey , appVolatileClusterSettingsCache :: TVar VolatileClusterSettingsCache , appStartTime :: UTCTime -- for Status Page @@ -124,9 +108,17 @@ instance HasCookieSettings RegisteredCookie UniWorX where instance (MonadHandler m, HandlerSite m ~ UniWorX) => ReadLogSettings m where readLogSettings = liftIO . readTVarIO =<< getsYesod (view _appLogSettings) - type DB = YesodDB UniWorX -type DBRead = ReaderT SqlReadBackend (HandlerFor UniWorX) + -- ~ ReaderT SqlBackend (HandlerFor UniWorX) +-- type DBRead = ReaderT SqlReadBackend (HandlerFor UniWorX) -- old, was too unflexible. Try DBRead first, then add suffixes ' or Uq until it types ;) +type DBRead a = forall backend . (PersistQueryRead backend, BackendCompatible SqlBackend backend) + => ReaderT backend (HandlerFor UniWorX) a +type DBRead' a = forall backend . (PersistQueryRead backend, BackendCompatible SqlBackend backend, BaseBackend backend ~ SqlBackend) -- ought to be redundant, but somehow isn´t. Using this everywhere give redundant constraint warnings, also undesirable + => ReaderT backend (HandlerFor UniWorX) a +type DBReadUq a = forall backend . (PersistQueryRead backend, BackendCompatible SqlBackend backend, PersistUniqueRead backend) -- adding this to DBRead would yield some unnecessary constraint warnings + => ReaderT backend (HandlerFor UniWorX) a +type DBReadUq' a = forall backend . (PersistQueryRead backend, BackendCompatible SqlBackend backend, BaseBackend backend ~ SqlBackend, PersistUniqueRead backend) + => ReaderT backend (HandlerFor UniWorX) a type Form x = Html -> MForm (HandlerFor UniWorX) (FormResult x, WidgetFor UniWorX ()) type MsgRenderer = MsgRendererS UniWorX -- see Utils type MailM a = MailT (HandlerFor UniWorX) a diff --git a/src/Handler/Admin.hs b/src/Handler/Admin.hs index f177e0611..6c874534c 100644 --- a/src/Handler/Admin.hs +++ b/src/Handler/Admin.hs @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2022-2024 Sarah Vaupel ,Gregor Kleen , Steffen Jost ,Steffen Jost +-- SPDX-FileCopyrightText: 2022-202025 Sarah Vaupel ,Gregor Kleen , Steffen Jost ,Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later @@ -25,6 +25,7 @@ import qualified Database.Esqueleto.Legacy as EL (on) -- needed for dbTable import qualified Database.Esqueleto.Utils as E import Jobs +import Utils.Company (areThereInsaneCompanySupervisions) import Handler.Utils import Handler.Utils.Avs import Handler.Utils.Users @@ -83,13 +84,18 @@ handleAdminProblems mbProblemTable = do flagNonZero n | n <= 0 = flagError True | otherwise = messageTooltip =<< handlerToWidget (messageI Error (MsgProblemsDriverSynch n)) - (usersAreReachable, driversHaveAvsIds, rDriversHaveFs, noStalePrintJobs, noBadAPCids, (interfaceOks, interfaceTable)) <- runDB $ (,,,,,) - <$> areAllUsersReachable - <*> allDriversHaveAvsId now + showDiffTime t = + let d = diffUTCTime now t + in guardMonoid (d > secondsToNominalDiffTime 30) [whamlet|_{MsgProblemLastCheckTime (formatDiffDays d)}|] + + (usersAreReachable, aurTime) <- areAllUsersReachable -- cached + (not -> thereAreInsaneFirmSupervisions, ifsTime) <- areThereInsaneCompanySupervisions -- cached + (driversHaveAvsIds, rDriversHaveFs, not -> noStalePrintJobs, not -> noBadAPCids) <- runDBRead $ (,,,) + <$> allDriversHaveAvsId now <*> allRDriversHaveFs now - <*> (not <$> exists [PrintJobAcknowledged ==. Nothing, PrintJobCreated <. cutOffOldTime]) - <*> (not <$> exists [PrintAcknowledgeProcessed ==. False]) - <*> mkInterfaceLogTable mempty + <*> exists [PrintJobAcknowledged ==. Nothing, PrintJobCreated <. cutOffOldTime] + <*> exists [PrintAcknowledgeProcessed ==. False] + (interfaceOks, interfaceTable) <- runDB $ mkInterfaceLogTable mempty let interfacesBadNr = length $ filter (not . snd) interfaceOks -- interfacesOk = all snd interfaceOks @@ -145,7 +151,7 @@ postAdminProblemsR = do getProblemUnreachableR, postProblemUnreachableR :: Handler Html getProblemUnreachableR = postProblemUnreachableR postProblemUnreachableR = do - unreachables <- runDB retrieveUnreachableUsers + unreachables <- runDBRead retrieveUnreachableUsers -- the following form is a nearly identicaly copy from Handler.Users: ((noreachUsersRes, noreachUsersWgt'), noreachUsersEnctype) <- runFormPost . identifyForm FIDUnreachableUsersAction $ buttonForm @@ -219,9 +225,13 @@ mkUnreachableUsersTable = do dbtColonnade = -} -areAllUsersReachable :: DB Bool --- areAllUsersReachable = E.selectNotExists retrieveUnreachableUsers' -- works and would be more efficient, but we cannot check proper email validity within DB alone -areAllUsersReachable = null <$> retrieveUnreachableUsers +areAllUsersReachable :: Handler (Bool, UTCTime) +areAllUsersReachable = $(memcachedByHere) (Just . Right $ 22 * diffHour) [st|isane-users-reachable|] $ do + now <- liftIO getCurrentTime + res <- runDBRead retrieveUnreachableUsers + -- res <- E.selectNotExists retrieveUnreachableUsers' -- works and would be more efficient, but we cannot check proper email validity within DB alone + $logInfoS "sanity" [st|Are there insane company supervisions: #{tshow res}|] + return (null res,now) -- retrieveUnreachableUsers' :: E.SqlQuery (E.SqlExpr (Entity User)) -- retrieveUnreachableUsers' = do @@ -232,7 +242,7 @@ areAllUsersReachable = null <$> retrieveUnreachableUsers -- E.&&. E.not_ ((user E.^. UserEmail) `E.like` E.val "%@%.%") -- return user -retrieveUnreachableUsers :: DB [Entity User] +retrieveUnreachableUsers :: DBReadUq' [Entity User] retrieveUnreachableUsers = do emailOnlyUsers <- E.select $ do user <- E.from $ E.table @User @@ -252,7 +262,7 @@ retrieveUnreachableUsers = do hasInvalidEmail = fmap isNothing . getUserEmail -allDriversHaveAvsId :: UTCTime -> DB Bool +allDriversHaveAvsId :: UTCTime -> DBReadUq Bool -- allDriversHaveAvsId = fmap isNothing . E.selectOne . retrieveDriversWithoutAvsId allDriversHaveAvsId = E.selectNotExists . retrieveDriversWithoutAvsId @@ -299,7 +309,7 @@ retrieveDriversWithoutAvsId now = do return usr -allRDriversHaveFs :: UTCTime -> DB Bool +allRDriversHaveFs :: UTCTime -> DBReadUq Bool -- allRDriversHaveFs = fmap isNothing . E.selectOne . retrieveDriversRWithoutF allRDriversHaveFs = E.selectNotExists . retrieveDriversRWithoutF @@ -368,22 +378,22 @@ mkProblemLogTable = do , sortable (Just "solved") (i18nCell MsgAdminProblemSolved) $ \( view $ resultProblem . _entityVal . _problemLogSolved -> t) -> cellMaybe dateTimeCell t , sortable (Just "solver") (i18nCell MsgAdminProblemSolver) $ \(preview resultSolver -> u) -> maybeCell u $ cellHasUserLink AdminUserR ] - dbtSorting = mconcat - [ single ("time" , SortColumn $ queryProblem >>> (E.^. ProblemLogTime)) - , single ("info" , SortColumn $ queryProblem >>> (E.^. ProblemLogInfo)) - -- , single ("firm" , SortColumn ((E.->>. "company" ).(queryProblem >>> (E.^. ProblemLogInfo)))) - , single ("firm" , SortColumn $ \r -> queryProblem r E.^. ProblemLogInfo E.->>. "company") - , single ("user" , sortUserNameBareM queryUser) - , single ("solved", SortColumn $ queryProblem >>> (E.^. ProblemLogSolved)) - , single ("solver", sortUserNameBareM querySolver) + dbtSorting = Map.fromList + [ ("time" , SortColumn $ queryProblem >>> (E.^. ProblemLogTime)) + , ("info" , SortColumn $ queryProblem >>> (E.^. ProblemLogInfo)) + -- , ("firm" , SortColumn ((E.->>. "company" ).(queryProblem >>> (E.^. ProblemLogInfo)))) + , ("firm" , SortColumn $ \r -> queryProblem r E.^. ProblemLogInfo E.->>. "company") + , ("user" , sortUserNameBareM queryUser) + , ("solved", SortColumn $ queryProblem >>> (E.^. ProblemLogSolved)) + , ("solver", sortUserNameBareM querySolver) ] - dbtFilter = mconcat - [ single ("user" , FilterColumn . E.mkContainsFilterWithCommaPlus Just $ views (to queryUser) (E.?. UserDisplayName)) - , single ("solver" , FilterColumn . E.mkContainsFilterWithCommaPlus Just $ views (to querySolver) (E.?. UserDisplayName)) - , single ("company" , FilterColumn . E.mkContainsFilter $ views (to queryProblem) ((E.->>. "company").(E.^. ProblemLogInfo))) - , single ("solved" , FilterColumn . E.mkExactFilterLast $ views (to queryProblem) (E.isJust . (E.^. ProblemLogSolved))) - -- , single ("problem" , FilterColumn . E.mkContainsFilter $ views (to queryProblem) ((E.->>. "problem").(E.^. ProblemLogInfo))) -- not stored in plaintext! - , single ("problem" , mkFilterProjectedPost $ \(getLast -> criterion) dbr -> -- falls es nicht schnell genug ist: in dbtProj den Anzeigetext nur einmal berechnen + dbtFilter = Map.fromList + [ ("user" , FilterColumn . E.mkContainsFilterWithCommaPlus Just $ views (to queryUser) (E.?. UserDisplayName)) + , ("solver" , FilterColumn . E.mkContainsFilterWithCommaPlus Just $ views (to querySolver) (E.?. UserDisplayName)) + , ("company" , FilterColumn . E.mkContainsFilter $ views (to queryProblem) ((E.->>. "company").(E.^. ProblemLogInfo))) + , ("solved" , FilterColumn . E.mkExactFilterLast $ views (to queryProblem) (E.isJust . (E.^. ProblemLogSolved))) + -- , ("problem" , FilterColumn . E.mkContainsFilter $ views (to queryProblem) ((E.->>. "problem").(E.^. ProblemLogInfo))) -- not stored in plaintext! + , ("problem" , mkFilterProjectedPost $ \(getLast -> criterion) dbr -> -- falls es nicht schnell genug ist: in dbtProj den Anzeigetext nur einmal berechnen ifNothingM criterion True $ \(crit::Text) -> do let problem = dbr ^. resultProblem . _entityVal . _problemLogAdminProblem protxt <- adminProblem2Text problem @@ -398,9 +408,9 @@ mkProblemLogTable = do , prismAForm (singletonFilter "solved" . maybePrism _PathPiece) mPrev $ aopt (boolField . Just $ SomeMessage MsgBoolIrrelevant) (fslI MsgAdminProblemSolved) ] acts :: Map ProblemTableAction (AForm Handler ProblemTableActionData) - acts = mconcat - [ singletonMap ProblemTableMarkSolved $ pure ProblemTableMarkSolvedData - , singletonMap ProblemTableMarkUnsolved $ pure ProblemTableMarkUnsolvedData + acts = Map.fromList + [ (ProblemTableMarkSolved , pure ProblemTableMarkSolvedData) + , (ProblemTableMarkUnsolved , pure ProblemTableMarkUnsolvedData) ] dbtParams = DBParamsForm { dbParamsFormMethod = POST diff --git a/src/Handler/Admin/Avs.hs b/src/Handler/Admin/Avs.hs index 038538e2a..76b04c6a5 100644 --- a/src/Handler/Admin/Avs.hs +++ b/src/Handler/Admin/Avs.hs @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2022 Sarah Vaupel ,Steffen Jost +-- SPDX-FileCopyrightText: 2022-2024 Sarah Vaupel ,Steffen Jost ,Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later @@ -38,10 +38,6 @@ import qualified Database.Esqueleto.Utils as E -- import Database.Esqueleto.Utils.TH --- avoids repetition of local definitions -single :: (k,a) -> Map k a -single = uncurry Map.singleton - exceptionWgt :: SomeException -> Widget exceptionWgt (SomeException e) = [whamlet|

Error:

#{tshow e}|] @@ -627,7 +623,7 @@ instance HasUser LicenceTableData where mkLicenceTable :: AvsPersonIdMapPersonCard -> Set AvsPersonId -> Text -> AvsLicence -> Set AvsPersonId -> DB (FormResult (LicenceTableActionData, Set AvsPersonId), Widget) mkLicenceTable apidStatus rsChanged dbtIdent aLic apids = do (currentRoute, usrHasAvsRerr) <- liftHandler $ (,) - <$> (fromMaybe (error "mkLicenceTable called from 404-handler") <$> liftHandler getCurrentRoute) + <$> fmap (fromMaybe $ error "mkLicenceTable called from 404-handler") (liftHandler getCurrentRoute) <*> (messageTooltip <$> messageI Error MsgProblemAvsUsrHadR) avsQualifications <- selectList [QualificationAvsLicence !=. Nothing] [Asc QualificationName] now <- liftIO getCurrentTime @@ -692,23 +688,23 @@ mkLicenceTable apidStatus rsChanged dbtIdent aLic apids = do ) $ \(preview $ resultQualUser . _entityVal . _qualificationUserScheduleRenewal -> b) -> cellMaybe (flip ifIconCell IconNoNotification . not) b , sortable Nothing (i18nCell MsgTableAvsActiveCards) $ \(view $ resultUserAvs . _userAvsPersonId -> apid) -> foldMap avsPersonCardCell $ Map.lookup apid apidStatus ] - dbtSorting = mconcat - [ single $ sortUserNameLink queryUser - , single ("avspersonno" , SortColumn $ queryUserAvs >>> (E.^. UserAvsNoPerson)) - , single ("qualification" , SortColumn $ queryQualification >>> (E.?. QualificationShorthand)) - , single $ sortUserCompany queryUser - , single ("valid-until" , SortColumn $ queryQualUser >>> (E.?. QualificationUserValidUntil)) - , single ("last-refresh" , SortColumn $ queryQualUser >>> (E.?. QualificationUserLastRefresh)) - , single ("first-held" , SortColumn $ queryQualUser >>> (E.?. QualificationUserFirstHeld)) - , single ("blocked" , SortColumnNeverNull $ queryQualBlock >>> (E.?. QualificationUserBlockFrom)) - , single ("schedule-renew", SortColumnNullsInv $ queryQualUser >>> (E.?. QualificationUserScheduleRenewal)) - -- , single ("validity" , SortColumn $ queryQualUser >>> (E.?. QualificationUserValidUntil)) + dbtSorting = Map.fromList + [ sortUserNameLink queryUser + , ("avspersonno" , SortColumn $ queryUserAvs >>> (E.^. UserAvsNoPerson)) + , ("qualification" , SortColumn $ queryQualification >>> (E.?. QualificationShorthand)) + , sortUserCompany queryUser + , ("valid-until" , SortColumn $ queryQualUser >>> (E.?. QualificationUserValidUntil)) + , ("last-refresh" , SortColumn $ queryQualUser >>> (E.?. QualificationUserLastRefresh)) + , ("first-held" , SortColumn $ queryQualUser >>> (E.?. QualificationUserFirstHeld)) + , ("blocked" , SortColumnNeverNull $ queryQualBlock >>> (E.?. QualificationUserBlockFrom)) + , ("schedule-renew", SortColumnNullsInv $ queryQualUser >>> (E.?. QualificationUserScheduleRenewal)) + -- , ("validity" , SortColumn $ queryQualUser >>> (E.?. QualificationUserValidUntil)) ] - dbtFilter = mconcat - [ single $ fltrUserNameEmail queryUser - , single ("validity" , FilterColumn . E.mkExactFilterLast $ views (to queryQualUser) (validQualification' now)) - , single ( "user-company", FilterColumn . E.mkExistsFilter $ \row criterion -> + dbtFilter = Map.fromList + [ fltrUserNameEmail queryUser + , ("validity" , FilterColumn . E.mkExactFilterLast $ views (to queryQualUser) (validQualification' now)) + , ( "user-company", FilterColumn . E.mkExistsFilter $ \row criterion -> E.from $ \(usrComp `E.InnerJoin` comp) -> do let testname = (E.val criterion :: E.SqlExpr (E.Value (CI Text))) `E.isInfixOf` (E.explicitUnsafeCoerceSqlExprValue "citext" (comp E.^. CompanyName) :: E.SqlExpr (E.Value (CI Text))) @@ -738,11 +734,9 @@ mkLicenceTable apidStatus rsChanged dbtIdent aLic apids = do E.orderBy [E.desc countRows'] E.limit 7 pure (qblock E.^. QualificationUserBlockReason) - mkOption :: E.Value Text -> Option Text - mkOption (E.unValue -> t) = Option{ optionDisplay = t, optionInternalValue = t, optionExternalValue = toPathPiece t } suggestionsBlock :: HandlerFor UniWorX (OptionList Text) - suggestionsBlock = mkOptionList . fmap mkOption <$> runDBRead (getBlockReasons E.not__) - suggestionsUnblock = mkOptionList . fmap mkOption <$> runDBRead (getBlockReasons id) + suggestionsBlock = mkOptionListText <$> runDBRead (getBlockReasons E.not__) + suggestionsUnblock = mkOptionListText <$> runDBRead (getBlockReasons id) acts :: Map LicenceTableAction (AForm Handler LicenceTableActionData) acts = mconcat @@ -811,7 +805,8 @@ postAdminAvsUserR uuid = do mbContact <- try $ fmap fltrIdContact $ avsQuery $ AvsQueryContact $ Set.singleton $ AvsObjPersonId userAvsPersonId -- mbStatus <- try $ fmap fltrIdStatus $ avsQuery $ AvsQueryStatus $ Set.singleton userAvsPersonId mbStatus <- try $ queryAvsFullStatus userAvsPersonId -- TODO: delete Handler.Utils.Avs.lookupAvsUser if no longer needed -- NOTE: currently needed to provide card firms that are missing in AVS status query responses - let compsUsed :: [CompanyName] = stripCI <$> mbStatus ^.. _Right . _Wrapped . folded . _avsStatusPersonCardStatus . folded . _avsDataFirm . _Just + -- $logInfoS "AVS-1" [st|Status query for #{tshow userAvsPersonId} lieferte #{tshow mbStatus} |] -- DEBUG + let compsUsed :: [CompanyName] = mbStatus ^.. _Right . _Wrapped . folded . _avsStatusPersonCardStatus . folded . _avsDataFirm . _Just . to stripCI compDict <- if 1 >= length compsUsed then return mempty -- switch company only sensible if there is more than one company to choose else do @@ -1004,15 +999,15 @@ getProblemAvsErrorR = do E.on $ usravs E.^. UserAvsUser E.==. user E.^. UserId E.where_ $ E.isJust $ usravs E.^. UserAvsLastSynchError return (usravs, user) -- , E.substring (usravs E.^. UserAvsLastSynchError) (E.val ("'#\"%#\" %'") (E.val "#")) -- needs a different type on substring - qerryUsrAvs :: (E.SqlExpr (Entity UserAvs) `E.InnerJoin` E.SqlExpr (Entity User)) -> E.SqlExpr (Entity UserAvs) - qerryUsrAvs = $(E.sqlIJproj 2 1) - qerryUser :: (E.SqlExpr (Entity UserAvs) `E.InnerJoin` E.SqlExpr (Entity User)) -> E.SqlExpr (Entity User) - qerryUser = $(E.sqlIJproj 2 2) + querryUsrAvs :: (E.SqlExpr (Entity UserAvs) `E.InnerJoin` E.SqlExpr (Entity User)) -> E.SqlExpr (Entity UserAvs) + querryUsrAvs = $(E.sqlIJproj 2 1) + querryUser :: (E.SqlExpr (Entity UserAvs) `E.InnerJoin` E.SqlExpr (Entity User)) -> E.SqlExpr (Entity User) + querryUser = $(E.sqlIJproj 2 2) reserrUsrAvs :: Lens' (DBRow (Entity UserAvs, Entity User)) (Entity UserAvs) reserrUsrAvs = _dbrOutput . _1 -- reserrUser :: Lens' (DBRow (Entity UserAvs, Entity User)) (Entity User) -- reserrUser = _dbrOutput . _2 - dbtRowKey = qerryUsrAvs >>> (E.^. UserAvsId) + dbtRowKey = querryUsrAvs >>> (E.^. UserAvsId) dbtProj = dbtProjId dbtColonnade = dbColonnade $ mconcat [ colUserNameModalHdrAdmin MsgLmsUser AdminUserR @@ -1025,15 +1020,15 @@ getProblemAvsErrorR = do , sortable (Just "avs-last-error") (i18nCell MsgLastAvsSynchError) $ cellMaybe textCell . view (reserrUsrAvs . _entityVal . _userAvsLastSynchError) ] - dbtSorting = mconcat - [ single (sortUserNameLink qerryUser) - , single ("avs-nr" , SortColumn $ qerryUsrAvs >>> (E.^. UserAvsNoPerson)) - , single ("avs-last-synch", SortColumnNullsInv $ qerryUsrAvs >>> (E.^. UserAvsLastSynch)) - , single ("avs-last-error", SortColumn $ qerryUsrAvs >>> (E.^. UserAvsLastSynchError)) + dbtSorting = Map.fromList + [ sortUserNameLink querryUser + , ("avs-nr" , SortColumn $ querryUsrAvs >>> (E.^. UserAvsNoPerson)) + , ("avs-last-synch", SortColumnNullsInv $ querryUsrAvs >>> (E.^. UserAvsLastSynch)) + , ("avs-last-error", SortColumn $ querryUsrAvs >>> (E.^. UserAvsLastSynchError)) ] - dbtFilter = mconcat - [ single $ fltrUserNameEmail qerryUser - , single ("avs-last-error", FilterColumn $ E.mkContainsFilterWithCommaPlus Just $ views (to qerryUsrAvs) (E.^. UserAvsLastSynchError)) + dbtFilter = Map.fromList + [ fltrUserNameEmail querryUser + , ("avs-last-error", FilterColumn $ E.mkContainsFilterWithCommaPlus Just $ views (to querryUsrAvs) (E.^. UserAvsLastSynchError)) ] dbtFilterUI mPrev = mconcat [ fltrUserNameEmailHdrUI MsgLmsUser mPrev diff --git a/src/Handler/Admin/Crontab.hs b/src/Handler/Admin/Crontab.hs index 4f82a3c22..2a3006b79 100644 --- a/src/Handler/Admin/Crontab.hs +++ b/src/Handler/Admin/Crontab.hs @@ -17,7 +17,7 @@ import Handler.Utils -- import Data.Aeson (fromJSON) -- import qualified Data.Aeson as Aeson -- import qualified Data.Aeson.Types as Aeson -import qualified Data.Aeson.Encode.Pretty as Pretty +import qualified Data.Aeson.Encode.Pretty as Pretty -- import qualified Data.CaseInsensitive as CI import qualified Data.Text as Text @@ -37,13 +37,13 @@ import qualified Database.Esqueleto.Utils as E -- Number of minutes a job must have been locked already to allow forced deletion jobDeleteLockMinutes :: Int -jobDeleteLockMinutes = 3 +jobDeleteLockMinutes = 3 deriveJSON defaultOptions { constructorTagModifier = camelToPathPiece' 1 } ''CronNextMatch - + getAdminCrontabR :: Handler TypedContent getAdminCrontabR = do jState <- getsYesod appJobState @@ -64,7 +64,7 @@ getAdminCrontabR = do encodeBearer =<< bearerToken (HashSet.singleton . Left $ toJSON UserGroupCrontab) Nothing (HashMap.singleton BearerTokenRouteEval $ HashSet.singleton AdminCrontabR) Nothing (Just Nothing) Nothing - + siteLayoutMsg MsgHeadingAdminCrontab $ do setTitleI MsgHeadingAdminCrontab [whamlet| @@ -114,6 +114,7 @@ getAdminCrontabR = do data JobTableAction = ActJobDelete + | ActJobSleep deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic) instance Universe JobTableAction @@ -121,9 +122,10 @@ instance Finite JobTableAction nullaryPathPiece ''JobTableAction $ camelToPathPiece' 1 embedRenderMessage ''UniWorX ''JobTableAction id -newtype JobTableActionData = ActJobDeleteData - { jobDeleteLocked :: Bool - } +data JobTableActionData + = ActJobDeleteData { jobDeleteLocked :: Bool } + | ActJobSleepData { jobSleepNr, jobSleepSecs :: Int + , jobSleepNow :: Bool } deriving (Eq, Ord, Read, Show, Generic) @@ -151,7 +153,7 @@ postAdminJobsR = do , sortable (Just "lock-instance") (i18nCell MsgTableJobLockInstance) $ \(view $ resultJob . _entityVal -> QueuedJob{..}) -> cellMaybe (stringCell . show) queuedJobLockInstance , sortable (Just "creation-instance") (i18nCell MsgTableJobCreationInstance) $ \(view $ resultJob . _entityVal -> QueuedJob{..}) -> stringCell $ show queuedJobCreationInstance ] - dbtSorting = Map.fromList + dbtSorting = Map.fromList [ ("creation-time" , SortColumnNullsInv (E.^. QueuedJobCreationTime)) , ("job" , SortColumn (\v -> v E.^. QueuedJobContent E.->>. "job")) , ("content" , SortColumn (E.^. QueuedJobContent)) @@ -168,11 +170,20 @@ postAdminJobsR = do prismAForm (singletonFilter "job" . maybePrism _PathPiece) mPrev $ aopt (hoistField lift textField) (fslI MsgTableJob) ] dbtStyle = def { dbsFilterLayout = defaultDBSFilterLayout } + + areq_posIntF msg = areq (posIntFieldI $ SomeMessages " " [SomeMessage msg, SomeMessage MsgMustBePositive]) (fslI msg) acts :: Map JobTableAction (AForm Handler JobTableActionData) - acts = Map.singleton ActJobDelete $ ActJobDeleteData - <$> areq checkBoxField (fslI $ MsgActJobDeleteForce jobDeleteLockMinutes) Nothing - dbtParams = DBParamsForm - { dbParamsFormAdditional = + acts = Map.fromList + [ (ActJobDelete, ActJobDeleteData + <$> areq checkBoxField (fslI $ MsgActJobDeleteForce jobDeleteLockMinutes) Nothing + ),(ActJobSleep, ActJobSleepData + <$> areq_posIntF MsgJobSleepNr (Just 1) + <*> areq_posIntF MsgJobSleepSecs (Just 60) + <*> areq checkBoxField (fslI MsgJobSleepNow) (Just True) + )] + + dbtParams = DBParamsForm + { dbParamsFormAdditional = renderAForm FormStandard $ (, mempty) . First . Just <$> multiActionA acts (fslI MsgTableAction) Nothing @@ -188,11 +199,11 @@ postAdminJobsR = do dbtCsvDecode = Nothing dbtExtraReps = [] -- jobsDBTableValidator :: PSValidator (MForm Handler) (FormResult (First JobTableAction, DBFormResult QueuedJobId Bool (DBRow (Entity QueuedJob)))) - jobsDBTableValidator = def + jobsDBTableValidator = def & defaultSorting [SortDescBy "creation-time"] - postprocess :: FormResult (First JobTableActionData, DBFormResult QueuedJobId Bool (DBRow (Entity QueuedJob))) + postprocess :: FormResult (First JobTableActionData, DBFormResult QueuedJobId Bool (DBRow (Entity QueuedJob))) -> FormResult (JobTableActionData, Set QueuedJobId) - postprocess inp = do + postprocess inp = do (First (Just act), jobMap) <- inp let jobSet = Map.keysSet . Map.filter id $ getDBFormResult (const False) jobMap return (act, jobSet) @@ -205,24 +216,40 @@ postAdminJobsR = do cutoff = addUTCTime (nominalMinute * fromIntegral (negate jobDeleteLockMinutes)) now jobReq = length jobIds lockCriteria - | jobDeleteLocked = + | jobDeleteLocked = [ QueuedJobLockTime ==. Nothing ] ||. [ QueuedJobLockTime <=. Just cutoff ] - | otherwise = + | otherwise = [ QueuedJobLockTime ==. Nothing , QueuedJobLockInstance ==. Nothing ] rmvd <- runDB $ fromIntegral <$> deleteWhereCount ((QueuedJobId <-. Set.toList jobIds) : lockCriteria) - + addMessageI (bool Success Warning $ rmvd < jobReq) (MsgTableJobActDeleteFeedback rmvd jobReq) reloadKeepGetParams AdminJobsR + (ActJobSleepData{..}, _) -> do + let jSleep = JobSleep jobSleepSecs + enqSleep = bool (void . queueJob) queueJob' jobSleepNow jSleep + replicateM_ jobSleepNr enqSleep + addMessageI Success (MsgTableJobActSleepFeedback jobSleepNr jobSleepSecs jobSleepNow) + reloadKeepGetParams AdminJobsR + + -- gather some data on job worles + (nrWorkers, jobStateVar) <- getsYesod (view _appJobWorkers &&& appJobState) + jState <- atomically $ tryReadTMVar jobStateVar + let running = Map.size . jobWorkers <$> jState siteLayoutMsg MsgMenuAdminJobs $ do setTitleI MsgMenuAdminJobs [whamlet| - ^{jobsTable} +
+ ^{jobsTable} +
+
    +
  • #{running} job workers currently running +
  • #{nrWorkers} job workers configured to run |] where doEnc :: ToJSON a => a -> _ @@ -232,8 +259,8 @@ postAdminJobsR = do , Text.splitOn "-" t ) } - + getJobName :: Value -> Maybe Text - getJobName (Object o) + getJobName (Object o) | Just (String s) <- HashMap.lookup "job" o = Just s -- (kebabToCamel s) getJobName _ = Nothing \ No newline at end of file diff --git a/src/Handler/Admin/Test.hs b/src/Handler/Admin/Test.hs index 30b826f7c..f835bc429 100644 --- a/src/Handler/Admin/Test.hs +++ b/src/Handler/Admin/Test.hs @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2022-2024 Sarah Vaupel , Gregor Kleen , Steffen Jost +-- SPDX-FileCopyrightText: 2022-2025 Sarah Vaupel , Gregor Kleen , Steffen Jost ,Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later @@ -32,6 +32,8 @@ import qualified Database.Esqueleto.Experimental as E (selectOne, unValue) import qualified Database.Esqueleto.PostgreSQL as E (now_) import qualified Database.Esqueleto.Utils as E (psqlVersion_) +{-# ANN module ("HLint: ignore Functor law" :: String) #-} + -- BEGIN - Buttons needed only here data ButtonCreate = CreateMath | CreateInf | CrashApp -- Dummy for Example deriving (Enum, Eq, Ord, Bounded, Read, Show, Generic) @@ -91,6 +93,7 @@ makeDemoForm n = identifyForm ("adminTestForm" :: Text) $ \html -> do getAdminTestR, postAdminTestR :: Handler Html -- Demo Page. Referenzimplementierungen sollte hier gezeigt werden! getAdminTestR = postAdminTestR postAdminTestR = do + uid <- requireAuthId -- this is an admin-only route anyway ((btnResult, btnWdgt), btnEnctype) <- runFormPost $ identifyForm ("buttons" :: Text) (buttonForm :: Form ButtonCreate) let btnForm = wrapForm btnWdgt def { formAction = Just $ SomeRoute AdminTestR @@ -99,7 +102,9 @@ postAdminTestR = do } case btnResult of (FormSuccess CreateInf) -> addMessage Info "Informatik-Knopf gedrückt" - (FormSuccess CreateMath) -> addMessage Warning "Knopf Mathematik erkannt" + (FormSuccess CreateMath) -> do + void $ queueJob $ JobUserNotification { jRecipient = uid, jNotification = NotificationUserAuthModeUpdate uid } + addMessage Warning "Knopf Mathematik erkannt" (FormSuccess CrashApp) -> addMessage Error "Crash Button Ratio 0 betätigt" >> error ("Crash Button" <> show (1 % 0)) FormMissing -> return () _other -> addMessage Warning "KEIN Knopf erkannt" diff --git a/src/Handler/CommCenter.hs b/src/Handler/CommCenter.hs index 00c688647..8a3fed551 100644 --- a/src/Handler/CommCenter.hs +++ b/src/Handler/CommCenter.hs @@ -25,11 +25,6 @@ import qualified Database.Esqueleto.PostgreSQL as E import Database.Esqueleto.Utils.TH --- avoids repetition of local definitions -single :: (k,a) -> Map k a -single = uncurry Map.singleton - - data CCTableAction = CCActDummy -- just a dummy, since we don't now yet which actions we will be needing deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic) @@ -119,17 +114,20 @@ mkCCTable = do , SomeExprValue $ E.coalesce [queryRecipientPrint row E.?. UserDisplayName, queryRecipientMail row E.?. UserDisplayName] ] ] - dbtFilter = mconcat - [ single ("sent" , FilterColumn . E.mkDayFilterTo - $ \row -> E.coalesceDefault [queryPrint row E.?. PrintJobCreated, queryMail row E.?. SentMailSentAt] E.now_) -- either one is guaranteed to be non-null, default never used - , single ("recipient" , FilterColumn . E.mkContainsFilterWithCommaPlus Just - $ \row -> E.coalesce [queryRecipientPrint row E.?. UserDisplayName, queryRecipientMail row E.?. UserDisplayName]) - , single ("subject" , FilterColumn . E.mkContainsFilterWithCommaPlus Just + dbtFilter = Map.fromList + [ ("sentTo" , FilterColumn . E.mkDayFilterTo + $ \row -> E.coalesceDefault [queryPrint row E.?. PrintJobCreated, queryMail row E.?. SentMailSentAt] E.now_) -- either one is guaranteed to be non-null, default never used + , ("sentFrom" , FilterColumn . E.mkDayFilterFrom + $ \row -> E.coalesceDefault [queryPrint row E.?. PrintJobCreated, queryMail row E.?. SentMailSentAt] E.now_) -- either one is guaranteed to be non-null, default never used + , ("recipient" , FilterColumn . E.mkContainsFilterWithCommaPlus Just + $ \row -> E.coalesce [queryRecipientPrint row E.?. UserDisplayName, queryRecipientMail row E.?. UserDisplayName]) + , ("subject" , FilterColumn . E.mkContainsFilterWithCommaPlus Just $ \row -> E.coalesce [E.str2text' $ queryPrint row E.?. PrintJobFilename ,E.str2text' $ queryMail row E.?. SentMailHeaders ]) ] dbtFilterUI mPrev = mconcat - [ prismAForm (singletonFilter "date" . maybePrism _PathPiece) mPrev $ aopt (hoistField lift dayField) (fslI MsgPrintJobCreated) + [ prismAForm (singletonFilter "sentTo" . maybePrism _PathPiece) mPrev $ aopt (hoistField lift dayField) (fslI MsgTableFilterSentBefore) + , prismAForm (singletonFilter "sentFrom" . maybePrism _PathPiece) mPrev $ aopt (hoistField lift dayField) (fslI MsgTableFilterSentAfter) , prismAForm (singletonFilter "recipient" . maybePrism _PathPiece) mPrev $ aopt (hoistField lift textField) (fslI MsgPrintRecipient & setTooltip MsgTableFilterCommaPlus) , prismAForm (singletonFilter "subject" . maybePrism _PathPiece) mPrev $ aopt (hoistField lift textField) (fslI MsgCommSubject & setTooltip MsgTableFilterCommaPlusShort) ] diff --git a/src/Handler/Course/Edit.hs b/src/Handler/Course/Edit.hs index 138fd2c6c..2c07a6dd9 100644 --- a/src/Handler/Course/Edit.hs +++ b/src/Handler/Course/Edit.hs @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2022-24 Gregor Kleen ,Sarah Vaupel ,Sarah Vaupel ,Steffen Jost ,Steffen Jost ,Winnie Ros +-- SPDX-FileCopyrightText: 2022-2024 Gregor Kleen ,Sarah Vaupel ,Sarah Vaupel ,Steffen Jost ,Steffen Jost ,Winnie Ros -- -- SPDX-License-Identifier: AGPL-3.0-or-later @@ -70,7 +70,7 @@ courseToForm (Entity cid Course{..}) lecs lecInvites qualis = CourseForm , cfDeRegUntil = courseDeregisterUntil , cfLecturers = [Right (lecturerUser, lecturerType) | Lecturer{..} <- lecs] ++ [Left (email, mType) | (email, InvDBDataLecturer mType) <- Map.toList lecInvites ] - -- TODO: Filterung nach aktueller Schule, da ansonsten ein Sicherheitleck droht! Siehe #150 + -- TODO: Filterung nach aktueller Schule, da ansonsten ein Sicherheitleck droht! Siehe auch DevOps #1878 , cfQualis = [ (courseQualificationQualification, courseQualificationSortOrder) | CourseQualification{..} <- qualis, courseQualificationCourse == cid ] } @@ -452,6 +452,7 @@ courseEditHandler miButtonAction mbCourseForm = do sinkInvitationsF lecturerInvitationConfig $ map (\(lEmail, mLty) -> (lEmail, cid, (InvDBDataLecturer mLty, InvTokenDataLecturer))) invites void $ upsertCourseQualifications aid cid $ cfQualis res insert_ $ CourseEdit aid now cid + memcachedInvalidateClass MemcachedKeyClassTutorialOccurrences memcachedByInvalidate AuthCacheLecturerList $ Proxy @(Set UserId) addMessageI Success $ MsgCourseEditOk tid ssh csh return True @@ -470,7 +471,7 @@ upsertCourseQualifications uid cid qualis = do let newQualis = Map.fromList qualis oldQualis <- Map.fromList . fmap (\Entity{entityKey=k, entityVal=CourseQualification{..}} -> (courseQualificationQualification, (k, courseQualificationSortOrder))) <$> selectList [CourseQualificationCourse ==. cid] [Asc CourseQualificationQualification] - -- NOTE: CourseQualification allow the immediate assignment of these qualifications to any enrolled user. Hence SchoolAdmins must not be allowed to assign school-foreign qualifications, see #150 + -- NOTE: CourseQualification allow the immediate assignment of these qualifications to any enrolled user. Hence SchoolAdmins must not be allowed to assign school-foreign qualifications here! Also see DevOps #1878 okSchools <- Set.fromList . fmap (userFunctionSchool . entityVal) <$> selectList [UserFunctionUser ==. uid, UserFunctionFunction <-. [SchoolAdmin, SchoolLecturer]] [Asc UserFunctionSchool] {- Some debugging due to an error caused by using fromDistinctAscList with violated precondition: diff --git a/src/Handler/Course/Events/Delete.hs b/src/Handler/Course/Events/Delete.hs index 7dfcdcba2..1931ff220 100644 --- a/src/Handler/Course/Events/Delete.hs +++ b/src/Handler/Course/Events/Delete.hs @@ -31,10 +31,8 @@ postCEvDeleteR tid ssh csh cID = do [whamlet| $newline never #{courseEventType} - $maybe room <- courseEventRoom - , #{roomReferenceText room} : - ^{occurrencesWidget courseEventTime} + ^{occurrencesWidget False courseEventTime} |] drRecordConfirmString :: Entity CourseEvent -> DB Text diff --git a/src/Handler/Course/Events/Edit.hs b/src/Handler/Course/Events/Edit.hs index ceef29fe1..fc9901031 100644 --- a/src/Handler/Course/Events/Edit.hs +++ b/src/Handler/Course/Events/Edit.hs @@ -26,9 +26,8 @@ postCEvEditR tid ssh csh cID = do replace eId CourseEvent { courseEventCourse , courseEventType = cefType - , courseEventRoom = cefRoom , courseEventRoomHidden = cefRoomHidden - , courseEventTime = cefTime + , courseEventTime = cefTime & JSONB , courseEventNote = cefNote , courseEventLastChanged = now } diff --git a/src/Handler/Course/Events/Form.hs b/src/Handler/Course/Events/Form.hs index 29a968826..c90a82bb4 100644 --- a/src/Handler/Course/Events/Form.hs +++ b/src/Handler/Course/Events/Form.hs @@ -17,7 +17,6 @@ import qualified Database.Esqueleto.Legacy as E data CourseEventForm = CourseEventForm { cefType :: CI Text - , cefRoom :: Maybe RoomReference , cefRoomHidden :: Bool , cefTime :: Occurrences , cefNote :: Maybe StoredMarkup @@ -37,14 +36,12 @@ courseEventForm template = identifyForm FIDCourseEvent . renderWForm FormStandar let courseEventTypes = optionsPairs [ (courseEventType, courseEventType) | Entity _ CourseEvent{..} <- existingEvents ] cefType' <- wreq (textField & cfStrip & cfCI & addDatalist courseEventTypes) (fslI MsgCourseEventType & addPlaceholder (mr MsgCourseEventTypePlaceholder)) (cefType <$> template) - cefRoom' <- aFormToWForm $ roomReferenceFormOpt (fslI MsgCourseEventRoom) (cefRoom <$> template) cefRoomHidden' <- wpopt checkBoxField (fslI MsgCourseEventRoomHidden & setTooltip MsgCourseEventRoomHiddenTip) (cefRoomHidden <$> template) cefTime' <- aFormToWForm $ occurrencesAForm ("time" :: Text) (cefTime <$> template) cefNote' <- wopt htmlField (fslI MsgCourseEventNote) (cefNote <$> template) return $ CourseEventForm <$> cefType' - <*> cefRoom' <*> cefRoomHidden' <*> cefTime' <*> cefNote' @@ -52,8 +49,7 @@ courseEventForm template = identifyForm FIDCourseEvent . renderWForm FormStandar courseEventToForm :: CourseEvent -> CourseEventForm courseEventToForm CourseEvent{..} = CourseEventForm { cefType = courseEventType - , cefRoom = courseEventRoom , cefRoomHidden = courseEventRoomHidden - , cefTime = courseEventTime + , cefTime = courseEventTime & unJSONB , cefNote = courseEventNote } diff --git a/src/Handler/Course/Events/New.hs b/src/Handler/Course/Events/New.hs index b43656d98..8a57706d4 100644 --- a/src/Handler/Course/Events/New.hs +++ b/src/Handler/Course/Events/New.hs @@ -24,9 +24,8 @@ postCEventsNewR tid ssh csh = do eId <- insert CourseEvent { courseEventCourse = cid , courseEventType = cefType - , courseEventRoom = cefRoom , courseEventRoomHidden = cefRoomHidden - , courseEventTime = cefTime + , courseEventTime = cefTime & JSONB , courseEventNote = cefNote , courseEventLastChanged = now } diff --git a/src/Handler/Course/ParticipantInvite.hs b/src/Handler/Course/ParticipantInvite.hs index 53eff795d..bec66ce04 100644 --- a/src/Handler/Course/ParticipantInvite.hs +++ b/src/Handler/Course/ParticipantInvite.hs @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2022-23 Sarah Vaupel , Steffen Jost +-- SPDX-FileCopyrightText: 2022-2024 Sarah Vaupel , Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later @@ -13,6 +13,7 @@ import Import import Handler.Utils import Handler.Utils.Avs +import Handler.Utils.Company import Jobs.Queue @@ -28,7 +29,7 @@ import Control.Monad.Except (MonadError(..)) import Generics.Deriving.Monoid (memptydefault, mappenddefault) --- import Database.Esqueleto.Experimental ((:&)(..)) +import Database.Esqueleto.Experimental ((:&)(..)) import qualified Database.Esqueleto.Experimental as E -- needs TypeApplications Lang-Pragma import qualified Database.Esqueleto.Utils as E @@ -44,20 +45,22 @@ defaultTutorialType = "Schulung" tutorialTypeSeparator :: TutorialType tutorialTypeSeparator = "_" -tutorialTemplateNames :: Maybe TutorialType -> [TutorialType] -tutorialTemplateNames Nothing = ["Vorlage", "Template"] -tutorialTemplateNames (Just name) = [prefixes <> suffixes | prefixes <- tutorialTemplateNames Nothing, suffixes <- [mempty, tutorialTypeSeparator <> name]] +tutorialTypeSeparators :: [TutorialType] +tutorialTypeSeparators = tutorialTypeSeparator : ["-"] + +tutorialTemplateNames :: [TutorialType] +tutorialTemplateNames = ["Vorlage", "Template"] tutorialDefaultName :: Maybe TutorialType -> Day -> TutorialName -tutorialDefaultName Nothing = formatDayForTutName -tutorialDefaultName (Just ttyp) = +tutorialDefaultName Nothing = formatDayForTutName +tutorialDefaultName (Just ttyp) = let prefix = CI.mk $ snd $ Text.breakOnEnd (CI.original tutorialTypeSeparator) $ CI.original ttyp in (<> (tutorialTypeSeparator <> prefix)) . tutorialDefaultName Nothing formatDayForTutName :: Day -> CI Text -- "%yy_%mm_%dd" -- Do not use user date display setting, since tutorial default names must be universal regardless of user -- formatDayForTutName = CI.mk . formatTime' "%y_%m_%d" -- we don't want to go monadic for this -formatDayForTutName = CI.mk . Text.map d2u . Text.drop 2 . tshow - where +formatDayForTutName = CI.mk . Text.map d2u . Text.drop 2 . tshow + where d2u '-' = '_' d2u c = c @@ -151,7 +154,7 @@ instance Monoid AddParticipantsResult where getCAddUserR, postCAddUserR :: TermId -> SchoolId -> CourseShorthand -> Handler Html getCAddUserR = postCAddUserR -postCAddUserR tid ssh csh = do +postCAddUserR tid ssh csh = do today <- localDay . TZ.utcToLocalTimeTZ appTZ <$> liftIO getCurrentTime handleAddUserR tid ssh csh (Right today) Nothing -- postTAddUserR tid ssh csh (CI.mk $ tshow today) -- Don't use user date display setting, so that tutorial default names conform to all users @@ -163,73 +166,71 @@ postTAddUserR tid ssh csh tutn = handleAddUserR tid ssh csh (Left tutn) Nothing handleAddUserR :: TermId -> SchoolId -> CourseShorthand -> Either TutorialName Day -> Maybe TutorialType -> Handler Html -handleAddUserR tid ssh csh tdesc ttyp = do - (cid, tutTypes, tutNameSuggestions) <- runDB $ do - let plainTemplates = tutorialTemplateNames Nothing - cid <- getKeyBy404 $ TermSchoolCourseShort tid ssh csh +handleAddUserR tid ssh csh tdesc ttyp = do + (cEnt@Entity{entityKey=cid}, tutTypes, tutNameSuggestions) <- runDB $ do + cEnt@Entity{entityKey=cid} <- getBy404 $ TermSchoolCourseShort tid ssh csh tutTypes <- E.select $ E.distinct $ do tutorial <- E.from $ E.table @Tutorial let tuTyp = tutorial E.^. TutorialType E.where_ $ tutorial E.^. TutorialCourse E.==. E.val cid E.orderBy [E.asc tuTyp] return tuTyp - let typeSet = Set.fromList [ maybe t CI.mk $ Text.stripPrefix temp_sep $ CI.original t - | temp <- plainTemplates - , let temp_sep = CI.original (temp <> tutorialTypeSeparator) - , E.Value t <- tutTypes + let typeSet = Set.fromList [ maybe t CI.mk $ firstJust (`Text.stripPrefix` s) [CI.original $ tpl <> sep | sep <- tutorialTypeSeparators, tpl <- tutorialTemplateNames] + | E.Value t <- tutTypes, let s = CI.original t, t `notElem` tutorialTemplateNames ] - tutNames <- E.select $ do + tutNames <- E.select $ do tutorial <- E.from $ E.table @Tutorial let tuName = tutorial E.^. TutorialName E.where_ $ tutorial E.^. TutorialCourse E.==. E.val cid E.&&. E.isJust (tutorial E.^. TutorialFirstDay) - E.&&. E.not_ (E.any (E.hasPrefix_ (tutorial E.^. TutorialType) . E.val) plainTemplates) - E.orderBy [E.desc $ tutorial E.^. TutorialFirstDay, E.asc tuName] + E.&&. E.not_ (E.any (E.hasPrefix_ (tutorial E.^. TutorialType) . E.val) tutorialTemplateNames) + E.orderBy $ [E.asc $ tutorial E.^. TutorialName `E.hasInfix` E.val tn | tn <- tutorialTemplateNames] -- avoid template names, if possible + ++ [E.desc $ tutorial E.^. TutorialFirstDay, E.asc tuName] E.limit 7 return tuName let tutNameSuggestions = return $ mkOptionList [Option tno tn tno | etn <- tutNames, let tn = E.unValue etn, let tno = CI.original tn] - return (cid, Set.toAscList typeSet, tutNameSuggestions) -- Set in order to remove duplicates and sort ascending at once + return (cEnt, Set.toAscList typeSet, tutNameSuggestions) -- Set in order to remove duplicates and sort ascending at once currentRoute <- fromMaybe (error "postCAddUserR called from 404-handler") <$> getCurrentRoute - (_ , registerConfirmResult) <- runButtonForm FIDCourseRegisterConfirm + (_ , registerConfirmResult) <- runButtonForm FIDCourseRegisterConfirm -- $logDebugS "***AbortProblem***" $ tshow registerConfirmResult - prefillUsers <- case registerConfirmResult of + prefillUsers <- case registerConfirmResult of Nothing -> return mempty - (Just BtnCourseRegisterAbort) -> do + (Just BtnCourseRegisterAbort) -> do addMessageI Warning MsgAborted -- prefill confirmed users for convenience. Note that Browser-Back may also return to the filled form, but history.back() does not in Chrome confirmedActs :: [CourseRegisterActionData] <- exceptT (const $ return mempty) return . mapMM encodedSecretBoxOpen . lookupPostParams $ toPathPiece PostCourseUserAddConfirmAction -- ignore any exception, since it is only used to prefill a form field for convenience return $ Just $ Set.fromList $ fmap crActIdent confirmedActs (Just BtnCourseRegisterConfirm) -> do - confirmedActs :: Set CourseRegisterActionData <- fmap Set.fromList . throwExceptT . mapMM encodedSecretBoxOpen . lookupPostParams $ toPathPiece PostCourseUserAddConfirmAction + confirmedActs :: Set CourseRegisterActionData <- fmap Set.fromList . throwExceptT . mapMM encodedSecretBoxOpen . lookupPostParams $ toPathPiece PostCourseUserAddConfirmAction -- $logDebugS "CAddUserR confirmedActs" . tshow $ Set.map Aeson.encode confirmedActs unless (Set.null confirmedActs) $ do -- TODO: check that all acts are member of availableActs let users = Map.fromList . fmap (\act -> (crActIdent act, Just . view _1 $ crActUser act)) $ Set.toList confirmedActs tutActs = Set.filter (is _CourseRegisterActionAddTutorialMemberData) confirmedActs - actTutorial = crActTutorial <$> Set.lookupMin tutActs -- tutorial ident must be the same for every added member! + actTutorial = crActTutorial <$> Set.lookupMin tutActs -- tutorial ident must be the same for every added member! registeredUsers <- registerUsers cid users whenIsJust actTutorial $ \(tutName,tutType,tutDay) -> do whenIsJust (tutName <|> fmap (tutorialDefaultName tutType) tutDay) $ \tName -> do - tutId <- upsertNewTutorial cid tName tutType tutDay + tutId <- upsertNewTutorial cEnt tName tutType tutDay registerTutorialMembers tutId registeredUsers -- when (Set.size tutActs == Set.size confirmedActs) $ -- not sure how this condition might be false at this point redirect $ CTutorialR tid ssh csh tName TUsersR redirect $ CourseR tid ssh csh CUsersR return mempty - + ((usersToAdd :: FormResult AddUserRequest, formWgt), formEncoding) <- runFormPost . identifyForm FIDCourseRegister . renderWForm FormStandard $ do let tutTypesMsg = [(SomeMessage tt,tt) | tt <- tutTypes] - tutDefType = ttyp >>= (\ty -> if ty `elem` tutTypes then Just ty else Nothing) + tutDefType = ttyp >>= (\ty -> if ty `elem` tutTypes then Just ty else Nothing) auReqUsers <- wreq (textField & cfAnySeparatedSet) (fslI MsgCourseParticipantsRegisterUsersField & setTooltip MsgCourseParticipantsRegisterUsersFieldTip) prefillUsers auReqTutorial <- optionalActionW - ( (,,) + ( (,,) <$> aopt (textField & cfStrip & cfCI & addDatalist tutNameSuggestions) (fslI MsgCourseParticipantsRegisterTutorialField & setTooltip MsgCourseParticipantsRegisterTutorialFieldTip) (Just $ maybeLeft tdesc) <*> aopt (selectFieldList tutTypesMsg) - (fslI MsgTableTutorialType) + (fslI MsgCourseParticipantsTutorialType & setTooltip MsgCourseParticipantsTutorialTypeTooltip) (Just tutDefType) <*> aopt dayField (fslI MsgTableTutorialFirstDay & setTooltip MsgCourseParticipantsRegisterTutorialFirstDayTip) @@ -343,22 +344,41 @@ registerUser cid (_avsIdent, Just uid) = exceptT return return $ do return $ mempty { aurRegisterSuccess = Set.singleton uid } -upsertNewTutorial :: CourseId -> TutorialName -> Maybe TutorialType -> Maybe Day -> Handler TutorialId -upsertNewTutorial cid newTutorialName newTutorialType newFirstDay = runDB $ do +upsertNewTutorial :: Entity Course -> TutorialName -> Maybe TutorialType -> Maybe Day -> Handler TutorialId +upsertNewTutorial Entity{entityKey=cid, entityVal=crse} newTutorialName newTutorialType newFirstDay = runDB $ do now <- liftIO getCurrentTime existingTut <- getBy $ UniqueTutorial cid newTutorialName - templateEnt <- selectFirst [TutorialType <-. tutorialTemplateNames newTutorialType] [Desc TutorialType, Asc TutorialName] + -- templateEnt <- selectFirst [TutorialType <-. tutorialTemplateNames newTutorialType] [Desc TutorialType, Asc TutorialName] -- current prod as of 02/2025 + templateEnt <- E.selectOne $ do + (tut :& crs :& trm) <- E.from $ E.table @Tutorial + `E.innerJoin` E.table @Course + `E.on` (\(tut :& crs) -> tut E.^. TutorialCourse E.==. crs E.^. CourseId) + `E.innerJoin` E.table @Term + `E.on` (\(_ :& crs :& trm) -> trm E.^. TermId E.==. crs E.^. CourseTerm) + E.where_ $ crs E.^. CourseSchool E.==. E.val (crse & courseSchool) -- filter by School + -- E.&&. tut E.^. TutorialName `E.in_` E.vals (tutorialTemplateNames newTutorialType) -- filter TutorialName being a template + E.orderBy $ -- NOTE: E.desc to have true before false, only works for non-nullable columns! + [ E.desc $ tut E.^. TutorialName `E.hasInfix` E.val pfx | pfx <- tutorialTemplateNames] -- prefer template names above all else + ++ mcons ((\ttyp -> E.desc $ tut E.^. TutorialName `E.hasInfix` E.val ttyp) <$> newTutorialType) -- prefer ttype, if given. + [ E.desc $ tut E.^. TutorialCourse E.==. E.val cid -- prefer current course + , E.desc $ crs E.^. CourseName E.==. E.val (crse & courseName) -- prefer courses with identical name + , E.desc $ crs E.^. CourseShorthand E.==. E.val (crse & courseShorthand) -- prefer courses with identical shortcut + , E.desc $ crs E.^. CourseTerm E.==. E.val (crse & courseTerm) -- prefer courses from current term + , E.desc $ trm E.^. TermStart -- prefer most recently started term + -- , E.desc $ tut E.^. tutorialRegisterFrom + , E.asc $ tut E.^. TutorialName -- prefer tutorial name in alpahbetical order + ] + return tut case (existingTut, newFirstDay, templateEnt) of - (Just Entity{entityKey=tid},_,_) -> return tid -- no need to update, we ignore the anchor day + (Just Entity{entityKey=tid},_,_) -> return tid -- no need to update, we ignore the anchor day (Nothing, Just moveDay, Just Entity{entityVal=Tutorial{..}}) -> do - Course{..} <- get404 cid - term <- get404 courseTerm - let oldFirstDay = fromMaybe moveDay $ tutorialFirstDay <|> fst (occurrencesBounds term tutorialTime) - newTime = normalizeOccurrences $ occurrencesAddBusinessDays term (oldFirstDay, moveDay) tutorialTime + term <- get404 $ courseTerm crse + let oldFirstDay = fromMaybe moveDay $ tutorialFirstDay <|> fst (occurrencesBounds term $ unJSONB tutorialTime) + newTime = normalizeOccurrences $ occurrencesAddBusinessDays term (oldFirstDay, moveDay) $ unJSONB tutorialTime dayDiff = maybe 0 (diffDays moveDay) tutorialFirstDay mvTime = fmap $ addLocalDays dayDiff newType0 = CI.map (snd . Text.breakOnEnd (CI.original tutorialTypeSeparator)) tutorialType - newType = if newType0 `elem` tutorialTemplateNames Nothing + newType = if newType0 `elem` tutorialTemplateNames then fromMaybe defaultTutorialType newTutorialType else newType0 Entity tutId _ <- upsert @@ -367,13 +387,13 @@ upsertNewTutorial cid newTutorialName newTutorialType newFirstDay = runDB $ do , tutorialCourse = cid , tutorialType = newType , tutorialFirstDay = newFirstDay - , tutorialTime = newTime + , tutorialTime = newTime & JSONB , tutorialRegisterFrom = mvTime tutorialRegisterFrom , tutorialRegisterTo = mvTime tutorialRegisterTo , tutorialDeregisterUntil = mvTime tutorialDeregisterUntil , tutorialLastChanged = now , .. - } [] -- update cannot happen due to previous case + } [] -- update cannot happen due to previous case audit $ TransactionTutorialEdit tutId return tutId _ -> do @@ -383,9 +403,8 @@ upsertNewTutorial cid newTutorialName newTutorialType newFirstDay = runDB $ do , tutorialCourse = cid , tutorialType = fromMaybe defaultTutorialType newTutorialType , tutorialCapacity = Nothing - , tutorialRoom = Nothing , tutorialRoomHidden = False - , tutorialTime = Occurrences mempty mempty + , tutorialTime = mempty , tutorialRegGroup = Nothing , tutorialRegisterFrom = Nothing , tutorialRegisterTo = Nothing @@ -393,7 +412,7 @@ upsertNewTutorial cid newTutorialName newTutorialType newFirstDay = runDB $ do , tutorialLastChanged = now , tutorialTutorControlled = False , tutorialFirstDay = Nothing - } [] -- update cannot happen due to previous cases + } [] -- update cannot happen due to previous cases audit $ TransactionTutorialEdit tutId return tutId @@ -401,6 +420,10 @@ registerTutorialMembers :: TutorialId -> Set UserId -> Handler () registerTutorialMembers tutId (Set.toList -> users) = runDB $ do prevParticipants <- Set.fromList . fmap entityKey <$> selectList [TutorialParticipantUser <-. users, TutorialParticipantTutorial ==. tutId] [] participants <- fmap Set.fromList . for users $ \tutorialParticipantUser -> do + tutorialParticipantCompany <- selectCompanyUserPrime' tutorialParticipantUser + let tutorialParticipantDrivingPermit = Nothing + tutorialParticipantEyeExam = Nothing + tutorialParticipantNote = Nothing Entity tutPartId _ <- upsert TutorialParticipant { tutorialParticipantTutorial = tutId, .. } [] audit $ TransactionTutorialParticipantEdit tutId tutPartId tutorialParticipantUser return tutPartId diff --git a/src/Handler/Course/Show.hs b/src/Handler/Course/Show.hs index 78ddeecd5..5d1de7131 100644 --- a/src/Handler/Course/Show.hs +++ b/src/Handler/Course/Show.hs @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2022 Gregor Kleen ,Sarah Vaupel ,Sarah Vaupel ,Winnie Ros +-- SPDX-FileCopyrightText: 2022-2024 Gregor Kleen ,Sarah Vaupel ,Sarah Vaupel ,Winnie Ros ,Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later @@ -29,7 +29,7 @@ import Handler.Exam.List (mkExamTable) getCShowR :: TermId -> SchoolId -> CourseShorthand -> Handler Html getCShowR tid ssh csh = do - mbAid <- maybeAuthId + mbAid <- maybeAuthId now <- liftIO getCurrentTime (cid,course,courseVisible,schoolName,participants,registration,lecturers,assistants,administrators,correctors,tutors,news,events,submissionGroup,_mayReRegister,(mayViewSheets, mayViewAnySheet), (mayViewMaterials, mayViewAnyMaterial),courseQualifications) <- runDB . maybeT notFound $ do [(E.Entity cid course, E.Value courseVisible, E.Value schoolName, E.Value participants, fmap entityVal -> registration)] @@ -146,7 +146,7 @@ getCShowR tid ssh csh = do | otherwise -> return . modal $(widgetFile "course/login-to-register") . Left . SomeRoute $ AuthR LoginR registrationOpen <- hasWriteAccessTo $ CourseR tid ssh csh CRegisterR - mayMassRegister <- hasWriteAccessTo $ CourseR tid ssh csh CAddUserR + mayMassRegister <- hasWriteAccessTo $ CourseR tid ssh csh CAddUserR MsgRenderer mr <- getMsgRenderer @@ -154,14 +154,14 @@ getCShowR tid ssh csh = do tutorialDBTable = DBTable{..} where resultTutorial :: Lens' (DBRow (Entity Tutorial, Bool)) (Entity Tutorial) - resultTutorial = _dbrOutput . _1 - resultShowRoom = _dbrOutput . _2 - + resultTutorial = _dbrOutput . _1 + resultHideRoom = _dbrOutput . _2 + dbtSQLQuery tutorial = do E.where_ $ tutorial E.^. TutorialCourse E.==. E.val cid - let showRoom = maybe E.false (flip showTutorialRoom tutorial . E.val) mbAid - E.||. E.not_ (tutorial E.^. TutorialRoomHidden) - return (tutorial, showRoom) + let hideRoom = maybe E.true (E.not__ . flip showTutorialRoom tutorial . E.val) mbAid + E.&&. (tutorial E.^. TutorialRoomHidden) + return (tutorial, hideRoom) dbtRowKey = (E.^. TutorialId) dbtProj = over (_dbrOutput . _2) E.unValue <$> dbtProjId dbtColonnade = dbColonnade $ mconcat @@ -180,10 +180,10 @@ getCShowR tid ssh csh = do
  • ^{nameEmailWidget' tutor} |] - , sortable (Just "room") (i18nCell MsgTableTutorialRoom) $ \res -> if - | res ^. resultShowRoom -> maybe (i18nCell MsgTableTutorialRoomIsUnset) roomReferenceCell $ views (resultTutorial . _entityVal) tutorialRoom res - | otherwise -> i18nCell MsgTableTutorialRoomIsHidden & addCellClass ("explanation" :: Text) - , sortable Nothing (i18nCell MsgTableTutorialTime) $ \(view $ resultTutorial . _entityVal -> Tutorial{..}) -> occurrencesCell tutorialTime + , sortable Nothing (i18nCell MsgTableTutorialOccurrence) $ \res -> + let roomHidden = res ^. resultHideRoom + ttime = res ^. resultTutorial . _entityVal . _tutorialTime + in occurrencesCell roomHidden ttime , sortable (Just "register-from") (i18nCell MsgTutorialRegisterFrom) $ \(view $ resultTutorial . _entityVal -> Tutorial{..}) -> maybeDateTimeCell tutorialRegisterFrom , sortable (Just "register-to") (i18nCell MsgTutorialRegisterTo) $ \(view $ resultTutorial . _entityVal -> Tutorial{..}) -> maybeDateTimeCell tutorialRegisterTo , sortable (Just "deregister-until") (i18nCell MsgTableTutorialDeregisterUntil) $ \(view $ resultTutorial . _entityVal -> Tutorial{..}) -> maybeDateTimeCell tutorialDeregisterUntil @@ -220,7 +220,6 @@ getCShowR tid ssh csh = do [ ("type", SortColumn $ \tutorial -> tutorial E.^. TutorialType ) , ("name", SortColumn $ \tutorial -> tutorial E.^. TutorialName ) , ("first-day", SortColumnNullsInv $ \tutorial -> tutorial E.^. TutorialFirstDay ) - , ("room", SortColumn $ \tutorial -> tutorial E.^. TutorialRoom ) , ("register-from", SortColumn $ \tutorial -> tutorial E.^. TutorialRegisterFrom ) , ("register-to", SortColumn $ \tutorial -> tutorial E.^. TutorialRegisterTo ) , ("deregister-until", SortColumn $ \tutorial -> tutorial E.^. TutorialDeregisterUntil ) diff --git a/src/Handler/Course/User.hs b/src/Handler/Course/User.hs index 81af8b6e4..feea4c118 100644 --- a/src/Handler/Course/User.hs +++ b/src/Handler/Course/User.hs @@ -307,7 +307,7 @@ courseUserExamsSection (Entity cid Course{..}) (Entity uid _) = do [ dbSelect (_2 . applying _2) _1 $ return . view (_dbrOutput . _1 . _entityKey) , sortable (Just "name") (i18nCell MsgTableExamName) $ tellCell (Any True, mempty) . anchorCell' (\(view $ _dbrOutput . _1 . _entityVal -> Exam{..}) -> CExamR courseTerm courseSchool courseShorthand examName EShowR) (view $ _dbrOutput . _1 . _entityVal . _examName) , sortable (Just "occurrence") (i18nCell MsgTableExamOccurrence) $ maybe mempty (cell . toWidget) . preview (_dbrOutput . _2 . _Just . _entityVal . _examOccurrenceName) - , sortable (Just "registration-time") (i18nCell MsgCourseExamRegistrationTime) $ maybe mempty (cell . formatTimeW SelFormatDateTime) . preview (_dbrOutput . _5 . _Just . _entityVal . _examRegistrationTime) + , sortable (Just "registration-time") (i18nCell MsgCourseExamRegistrationTime) $ foldMap dateTimeCell . preview (_dbrOutput . _5 . _Just . _entityVal . _examRegistrationTime) , sortable (Just "bonus") (i18nCell MsgExamBonusAchieved) $ maybe mempty i18nCell . preview (_dbrOutput . _3 . _Just . _entityVal . _examBonusBonus) , sortable (Just "result") (i18nCell MsgTableExamResult) $ maybe mempty i18nCell . preview (_dbrOutput . _4 . _Just . _entityVal . _examResultResult) ] @@ -444,13 +444,11 @@ courseUserTutorialsSection (Entity cid Course{..}) (Entity uid _) = do
  • ^{userEmailWidget usr} |] - , sortable (Just "room") (i18nCell MsgTableTutorialRoom) $ maybe (i18nCell MsgTableTutorialRoomIsUnset) roomReferenceCell . view (_dbrOutput . _1 . _entityVal . _tutorialRoom) - , sortable Nothing (i18nCell MsgTableTutorialTime) $ occurrencesCell . view (_dbrOutput . _1 . _entityVal . _tutorialTime) + , sortable Nothing (i18nCell MsgTableTutorialOccurrence) $ occurrencesCell False . view (_dbrOutput . _1 . _entityVal . _tutorialTime) ] dbtSorting = mconcat [ singletonMap "type" . SortColumn $ \(tutorial `E.InnerJoin` _) -> tutorial E.^. TutorialType , singletonMap "name" . SortColumn $ \(tutorial `E.InnerJoin` _) -> tutorial E.^. TutorialName - , singletonMap "room" . SortColumn $ \(tutorial `E.InnerJoin` _) -> tutorial E.^. TutorialRoom , singletonMap "tutors" . SortColumn $ \(tutorial `E.InnerJoin` _) -> E.subSelectMaybe . E.from $ \(tutor `E.InnerJoin` user) -> do E.on $ tutor E.^. TutorUser E.==. user E.^. UserId E.where_ $ tutorial E.^. TutorialId E.==. tutor E.^. TutorTutorial diff --git a/src/Handler/Course/Users.hs b/src/Handler/Course/Users.hs index 3fb4f0e3a..01d5df089 100644 --- a/src/Handler/Course/Users.hs +++ b/src/Handler/Course/Users.hs @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2022 Gregor Kleen ,Sarah Vaupel ,Sarah Vaupel ,Steffen Jost ,Winnie Ros +-- SPDX-FileCopyrightText: 2022-2025 Gregor Kleen ,Sarah Vaupel ,Sarah Vaupel ,Steffen Jost ,Winnie Ros ,Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later @@ -10,6 +10,7 @@ module Handler.Course.Users , postCUsersR, getCUsersR , colUserSex' , colUserQualifications, colUserQualificationBlocked + , colUserExams, colUserExamOccurrences, colUserExamOccurrencesCheck , _userQualifications ) where @@ -18,19 +19,24 @@ import Import import Utils.Form import Handler.Utils import Handler.Utils.Course +import Handler.Utils.Company + +import qualified Database.Esqueleto.Legacy as E import qualified Database.Esqueleto.Utils as E import qualified Database.Esqueleto.PostgreSQL as E +import qualified Database.Esqueleto.Experimental as X (from,on,table,leftJoin) +import Database.Esqueleto.Experimental ((:&)(..)) + import Database.Esqueleto.Utils.TH import Handler.Course.Register (deregisterParticipant) import qualified Data.Set as Set import qualified Data.Map as Map +import Data.Map ((!?)) import qualified Data.Text as Text import qualified Data.Vector as Vector -import qualified Database.Esqueleto.Legacy as E - import qualified Data.Csv as Csv import qualified Data.Conduit.List as C @@ -94,7 +100,7 @@ type UserTableData = DBRow ( Entity User , Entity CourseParticipant , Maybe CourseUserNoteId , ([Entity Tutorial], Map (CI Text) (Maybe (Entity Tutorial))) - , [Entity Exam] + , ([Entity Exam], [(Entity ExamOccurrence, Maybe UserDisplayName)]) -- Paired with ExamOccurrence is an examiner name, iff user is registered for another ExamOccurrence with this examiner, regardless of CourseId and time , Maybe (Entity SubmissionGroup) , Map SheetName (SheetType SqlBackendKey, Maybe Points) , UserTableQualifications @@ -119,7 +125,13 @@ _userTutorials :: Lens' UserTableData ([Entity Tutorial], Map (CI Text) (Maybe ( _userTutorials = _dbrOutput . _4 _userExams :: Lens' UserTableData [Entity Exam] -_userExams = _dbrOutput . _5 +_userExams = _dbrOutput . _5 . _1 + +_userExamOccsDblExaminers :: Lens' UserTableData [(Entity ExamOccurrence, Maybe UserDisplayName)] +_userExamOccsDblExaminers = _dbrOutput . _5 . _2 + +_userExamOccurrences :: Getter UserTableData [Entity ExamOccurrence] +_userExamOccurrences = _userExamOccsDblExaminers . to (map fst) _userSubmissionGroup :: Traversal' UserTableData (Entity SubmissionGroup) _userSubmissionGroup = _dbrOutput . _6 . _Just @@ -131,14 +143,12 @@ _userSheets = _dbrOutput . _7 -- _userQualifications = _dbrOutput . _8 . (traverse _1) -- last part: ([Entity Qualification] -> f [Entity Qualification]) -> UserTableQualifications -> f UserTableQualifications -_userQualifications :: Getter UserTableData [Entity Qualification] -_userQualifications = _dbrOutput . _8 . to (fmap fst3) --- _userQualifications = _dbrOutput . _8 . each . _1 -- TODO: how to make this work - - _userCourseQualifications :: Lens' UserTableData UserTableQualifications _userCourseQualifications = _dbrOutput . _8 +_userQualifications :: Getter UserTableData [Entity Qualification] +_userQualifications = _userCourseQualifications . to (map fst3) + colUserComment :: IsDBTable m c => TermId -> SchoolId -> CourseShorthand -> Colonnade Sortable UserTableData (DBCell m c) colUserComment tid ssh csh = @@ -164,6 +174,49 @@ colUserExams tid ssh csh = sortable (Just "exams") (i18nCell MsgCourseUserExams) (\(Entity _ Exam{..}) -> CExamR tid ssh csh examName EUsersR) (examName . entityVal) +colUserExamOccurrences :: IsDBTable m c => TermId -> SchoolId -> CourseShorthand -> Colonnade Sortable UserTableData (DBCell m c) +colUserExamOccurrences _tid _ssh _csh = sortable (Just "exam-occurrences") (i18nCell MsgCourseUserExamOccurrences) + $ \(view _userExamOccurrences -> exams') -> + let exams = sortOn (examOccurrenceName . entityVal) exams' + in (cellAttrs <>~ [("class", "list--inline list--comma-separated list--iconless")]) $ listCell exams examOccurrenceCell + +colUserExamOccurrencesCheck :: IsDBTable m c => TermId -> SchoolId -> CourseShorthand -> Colonnade Sortable UserTableData (DBCell m c) +colUserExamOccurrencesCheck _tid _ssh _csh = sortable (Just "exam-occurrences") (i18nCell MsgCourseUserExamOccurrences) + $ \(view _userExamOccsDblExaminers -> exams') -> + let exams = sortOn (examOccurrenceName . entityVal .fst) exams' + in (cellAttrs <>~ [("class", "list--inline list--comma-separated list--iconless")]) $ listCell exams + (\(exOcc, dblExmnr) -> + let warnExaminer :: Widget = foldMapM (messageTooltip <=< messageI Warning . MsgExaminerReocurrence) dblExmnr + in wgtCell warnExaminer <> examOccurrenceCell exOcc + ) + +{- +colUserExamOccurrencesCheckDB :: (IsDBTable (MForm Handler) c, MonadHandler (DBCell (MForm Handler)), HandlerSite (DBCell (MForm Handler)) ~ UniWorX) -- this type seems to be unusable+ + => TermId -> SchoolId -> CourseShorthand -> Colonnade Sortable UserTableData (DBCell (MForm Handler) c) +colUserExamOccurrencesCheckDB _tid _ssh _csh = sortable (Just "exam-occurrences") (i18nCell MsgCourseUserExamOccurrences) + $ \row -> do + let exams = sortOn (examOccurrenceName . entityVal) (row ^. _userExamOccurrences) + uid = row ^. hasEntity . _entityKey + (Map.fromAscList . map $(E.unValueN 2) -> dblExaminers) <- liftHandler . runDB $ E.select $ do + (reg :& occ :& usr) <- X.from $ X.table @ExamRegistration + `X.innerJoin` X.table @ExamOccurrence `X.on` (\(reg :& occ) -> occ E.^. ExamOccurrenceExam E.==. reg E.^. ExamRegistrationExam) + `X.innerJoin` X.table @User `X.on` (\(_ :& occ :& usr) -> occ E.^. ExamOccurrenceExaminer E.?=. usr E.^. UserId) + E.where_ $ reg E.^. ExamRegistrationUser E.==. E.val uid + E.&&. E.isJust (occ E.^. ExamOccurrenceExaminer) + E.&&. occ E.^. ExamOccurrenceId `E.notIn` E.valList (entityKey <$> exams) + E.&&. occ E.^. ExamOccurrenceExaminer `E.in_` E.valList (exams ^.. traverse . _entityVal . _examOccurrenceExaminer) + E.orderBy [E.asc $ usr E.^. UserId] + E.distinct $ pure (usr E.^. UserId, usr E.^. UserDisplayName) + (cellAttrs <>~ [("class", "list--inline list--comma-separated list--iconless")]) $ listCell exams + (\(Entity _ eo@ExamOccurrence{..}) -> wgtCell $ do + $logDebugS "ExOccWarning" [st|Problems: #{tshow dblExaminers}. ExamOccurrence: #{tshow eo}|] + warnExaminer <- case (dblExaminers !?) =<< examOccurrenceExaminer of + Nothing -> pure mempty + (Just exname) -> messageTooltip <$> messageI Warning (MsgExaminerReocurrence exname) + [whamlet|^{warnExaminer}#{examOccurrenceName}:^{formatTimeRangeW SelFormatDateTime examOccurrenceStart examOccurrenceEnd}|] + ) +-} + colUserSex' :: IsDBTable m c => Colonnade Sortable UserTableData (DBCell m c) colUserSex' = colUserSex $ hasUser . _userSex @@ -187,15 +240,24 @@ colUserSheets shns = cap (Sortable Nothing caption) $ foldMap userSheetCol shns colUserQualifications :: forall m c. IsDBTable m c => Day -> Colonnade Sortable UserTableData (DBCell m c) colUserQualifications cutoff = sortable (Just "qualifications") (i18nCell MsgTableQualifications) $ - let qualNamedValidCell (q,qu,qb) = textCell ((q ^. hasQualification . _qualificationShorthand . _CI) <> ": ") <> qualificationValidUntilCell cutoff qb qu - in \(view _userCourseQualifications -> qualis) -> - (cellAttrs <>~ [("class", "list--inline list--comma-separated list--iconless")]) . listCell qualis $ qualNamedValidCell + let qualNamedValidCell (q,qu,qb) = textCell ((q ^. hasQualification . _qualificationShorthand . _CI) <> ": ") <> qualificationValidUntilCell cutoff qb qu + in \(view _userCourseQualifications -> qualis) -> + (cellAttrs <>~ [("class", "list--inline list--comma-separated list--iconless")]) $ listCell qualis qualNamedValidCell + +-- colUserQualificationBlocked :: forall m c. IsDBTable m c => Bool -> Day -> Colonnade Sortable UserTableData (DBCell m c) +-- colUserQualificationBlocked isAdmin cutoff = sortable (Just "qualification-block") (i18nCell MsgQualificationValidIndicator & cellTooltip MsgTableQualificationBlockedTooltipSimple) $ +-- let qualNamedReasonCell (q,qu,qb) = textCell ((q ^. hasQualification . _qualificationShorthand . _CI) <> ": ") <> qualificationValidReasonCell isAdmin cutoff qb qu +-- in \(view _userCourseQualifications -> qualis) -> +-- (cellAttrs <>~ [("class", "list--inline list--comma-separated list--iconless")]) . listCell qualis $ qualNamedReasonCell + +colUserQualificationBlocked :: forall m c. IsDBTable m c => Bool -> Day -> Entity Qualification -> Colonnade Sortable UserTableData (DBCell m c) +colUserQualificationBlocked isAdmin cutoff Entity{entityKey=qid, entityVal=Qualification{qualificationShorthand=qsh}} + = sortable (Just "user-qualification") (i18nCell (MsgQualificationValidReason $ ciOriginal qsh) & cellTooltip MsgTableQualificationBlockedTooltipSimple) $ + let qualNamedReasonCell (_q,qu,qb) = qualificationValidReasonCell isAdmin cutoff qb qu + -- in \(view _userCourseQualifications . to (filter ((== qid) . entityKey . fst3)) -> qualis) -> + in \(view _userCourseQualifications -> qualis) -> + (cellAttrs <>~ [("class", "list--inline list--comma-separated list--iconless")]) $ listCell (filter ((== qid) . entityKey . fst3) qualis) qualNamedReasonCell -colUserQualificationBlocked :: forall m c. IsDBTable m c => Bool -> Day -> Colonnade Sortable UserTableData (DBCell m c) -colUserQualificationBlocked isAdmin cutoff = sortable (Just "qualification-block") (i18nCell MsgQualificationValidIndicator & cellTooltip MsgTableQualificationBlockedTooltipSimple) $ - let qualNamedReasonCell (q,qu,qb) = textCell ((q ^. hasQualification . _qualificationShorthand . _CI) <> ": ") <> qualificationValidReasonCell' Nothing isAdmin cutoff qb qu - in \(view _userCourseQualifications -> qualis) -> - (cellAttrs <>~ [("class", "list--inline list--comma-separated list--iconless")]) . listCell qualis $ qualNamedReasonCell data UserTableCsv = UserTableCsv { csvUserSurname :: UserSurname @@ -367,8 +429,8 @@ data CourseUserActionData = CourseUserSendMailData makeCourseUserTable :: forall h p cols act act'. - ( Functor h, ToSortable h - , Ord act, PathPiece act, RenderMessage UniWorX act + ( Functor h, ToSortable h, Ord act, PathPiece act, RenderMessage UniWorX act + -- , HandlerSite (DBCell (MForm Handler)) ~ UniWorX, MonadHandler (DBCell (MForm Handler)) -- for colUserExamOccurrencesCheckDB, but this does not work at all , AsCornice h p UserTableData (DBCell (MForm Handler) (FormResult (First act', DBFormResult UserId Bool UserTableData))) cols ) => CourseId @@ -384,8 +446,9 @@ makeCourseUserTable cid acts restrict colChoices psValidator csvColumns = do courseQualis <- getCourseQualifications cid let cqids = entityKey <$> courseQualis tutorials <- selectList [ TutorialCourse ==. cid ] [] - exams <- selectList [ ExamCourse ==. cid ] [] - sheets <- selectList [SheetCourse ==. cid] [Desc SheetActiveTo, Desc SheetActiveFrom] + exams <- selectList [ ExamCourse ==. cid ] [] + exOccs <- selectList [ ExamOccurrenceExam <-. fmap entityKey exams] [ Asc ExamOccurrenceId ] <&> Map.fromAscList . fmap (\ent -> (entityKey ent, ent)) + sheets <- selectList [ SheetCourse ==. cid] [Desc SheetActiveTo, Desc SheetActiveFrom] personalisedSheets <- E.select . E.from $ \sheet -> do let hasPersonalised = E.exists . E.from $ \psFile -> E.where_ $ psFile E.^. PersonalisedSheetFileSheet E.==. sheet E.^. SheetId @@ -404,7 +467,12 @@ makeCourseUserTable cid acts restrict colChoices psValidator csvColumns = do dbtRowKey = queryUser >>> (E.^. UserId) dbtProj = dbtProjSimple $ \(user, participant, E.Value userNoteId, subGroup) -> do tuts'' <- selectList [ TutorialParticipantUser ==. entityKey user, TutorialParticipantTutorial <-. map entityKey tutorials ] [] - exams' <- selectList [ ExamRegistrationUser ==. entityKey user ] [] + usrExams :: [(Entity ExamRegistration, E.Value (Maybe UserId), E.Value (Maybe UserDisplayName))] <- E.select $ do + (reg :& _occ :& usr) <- X.from $ X.table @ExamRegistration + `X.leftJoin` X.table @ExamOccurrence `X.on` (\(reg :& occ) -> occ E.?. ExamOccurrenceId E.==. reg E.^. ExamRegistrationOccurrence) + `X.leftJoin` X.table @User `X.on` (\(_ :& occ :& usr) -> E.joinV (occ E.?. ExamOccurrenceExaminer) E.==. usr E.?. UserId) + E.where_ $ reg E.^. ExamRegistrationUser E.==. E.val (entityKey user) + pure (reg, usr E.?. UserId, usr E.?. UserDisplayName) subs' <- E.select . E.from $ \(sheet `E.LeftOuterJoin` (submission `E.InnerJoin` submissionUser)) -> do E.on $ submissionUser E.?. SubmissionUserSubmission E.==. submission E.?. SubmissionId E.on $ E.just (sheet E.^. SheetId) E.==. submission E.?. SubmissionSheet @@ -427,9 +495,16 @@ makeCourseUserTable cid acts restrict colChoices psValidator csvColumns = do regGroups = setOf (folded . _entityVal . _tutorialRegGroup . _Just) tutorials tuts' = filter (\(Entity tutId _) -> any ((== tutId) . tutorialParticipantTutorial . entityVal) tuts'') tutorials tuts = foldr (\tut@(Entity _ Tutorial{..}) -> maybe (over _1 $ cons tut) (over _2 . flip (Map.insertWith (<|>)) (Just tut)) tutorialRegGroup) ([], Map.fromSet (const Nothing) regGroups) tuts' - exs = filter (\(Entity eId _) -> any ((== eId) . examRegistrationExam . entityVal) exams') exams + exs = filter (\(Entity eId _) -> any ((== eId) . examRegistrationExam . entityVal . fst3) usrExams) exams + usrDblExaminer :: Map UserId (Set ExamRegistrationId) + usrDblExaminer = Map.filter ((1 <) . Set.size) $ Map.fromListWith (<>) [(examiner, Set.singleton reg) | (Entity{entityKey=reg}, E.Value (Just examiner), _) <- usrExams] + checkUsrDbl :: Maybe UserId -> Maybe UserDisplayName -> Maybe UserDisplayName + checkUsrDbl (Just exid) exnm | isJust (usrDblExaminer !? exid) = exnm + checkUsrDbl _ _ = Nothing + ocs = [ (occ, checkUsrDbl exUsrId exUsrName) + | (Entity{entityVal=ExamRegistration{examRegistrationOccurrence = Just _oId@((exOccs !?) -> Just occ)}}, E.Value exUsrId, E.Value exUsrName) <- usrExams] subs = Map.fromList $ map (over (_2 . _2) (views _entityVal submissionRatingPoints <=< assertM (views _entityVal submissionRatingDone)) . over _1 E.unValue . over (_2 . _1) E.unValue) subs' - return (user, participant, userNoteId, tuts, exs, subGroup, subs, qualis) + return (user, participant, userNoteId, tuts, (exs,ocs), subGroup, subs, qualis) dbtColonnade = colChoices dbtSorting = mconcat [ single $ sortUserNameLink queryUser -- slower sorting through clicking name column header @@ -458,6 +533,15 @@ makeCourseUserTable cid acts restrict colChoices psValidator csvColumns = do E.where_ $ examRegistration E.^. ExamRegistrationUser E.==. user E.^. UserId return . E.min_ $ exam E.^. ExamName ) + , single ("exam-occurrences", SortColumn $ queryUser >>> \user -> + E.subSelectMaybe . E.from $ \(exam `E.InnerJoin` examRegistration `E.InnerJoin` examOccurrence) -> do + E.on $ examOccurrence E.^. ExamOccurrenceId E.=?. examRegistration E.^. ExamRegistrationOccurrence + E.on $ exam E.^. ExamId E.==. examRegistration E.^. ExamRegistrationExam + E.&&. exam E.^. ExamCourse E.==. E.val cid + E.where_ $ examRegistration E.^. ExamRegistrationUser E.==. user E.^. UserId + E.&&. E.isJust (examRegistration E.^. ExamRegistrationOccurrence) + return $ E.arrayAggWith E.AggModeDistinct (examOccurrence E.^. ExamOccurrenceName) [E.asc $ examOccurrence E.^. ExamOccurrenceName] + ) , single ("submission-group", SortColumn $ querySubmissionGroup >>> (E.?. SubmissionGroupName)) , single ("state", SortColumn $ queryParticipant >>> (E.^. CourseParticipantState)) , mconcat @@ -497,7 +581,7 @@ makeCourseUserTable cid acts restrict colChoices psValidator csvColumns = do -- , ("course-user-note", error "TODO") -- TODO , single ("submission-group", FilterColumn $ E.mkContainsFilter $ querySubmissionGroup >>> (E.?. SubmissionGroupName)) , single ("active", FilterColumn $ E.mkExactFilter $ queryParticipant >>> (E.==. E.val CourseParticipantActive) . (E.^. CourseParticipantState)) - , single ("has-personalised-sheet-files", FilterColumn $ \t (Last criterion) -> flip (maybe E.true) criterion $ \shn + , single ("has-personalised-sheet-files", FilterColumn $ \t (Last criterion) -> ifNothing criterion E.true $ \shn -> E.exists . E.from $ \(psFile `E.InnerJoin` sheet) -> do E.on $ psFile E.^. PersonalisedSheetFileSheet E.==. sheet E.^. SheetId E.where_ $ psFile E.^. PersonalisedSheetFileUser E.==. queryParticipant t E.^. CourseParticipantUser @@ -659,6 +743,7 @@ postCUsersR tid ssh csh = do , guardOn hasSubmissionGroups $ cap' colUserSubmissionGroup , guardOn hasTutorials . cap' $ colUserTutorials tid ssh csh , guardOn hasExams . cap' $ colUserExams tid ssh csh + , guardOn hasExams . cap' $ colUserExamOccurrences tid ssh csh , pure . cap' $ sortable (Just "registration") (i18nCell MsgRegisteredSince) (maybe mempty dateCell . preview (_Just . _userTableRegistration) . assertM' (has $ _userTableParticipant . _entityVal . _courseParticipantState . _CourseParticipantActive)) , pure . cap' $ sortable (Just "state") (i18nCell MsgCourseUserState) (i18nCell . view (_userTableParticipant . _entityVal . _courseParticipantState)) , guardOn (not $ null sheetList) . colUserSheets $ map (sheetName . entityVal) sheetList @@ -727,9 +812,12 @@ postCUsersR tid ssh csh = do addMessageI Success $ MsgCourseUsersDeregistered nrDel redirect $ CourseR tid ssh csh CUsersR (CourseUserRegisterTutorialData{..}, selectedUsers) -> do - runDB . forM_ selectedUsers $ - void . insertUnique . TutorialParticipant registerTutorial - addMessageI Success . MsgCourseUsersTutorialRegistered . fromIntegral $ Set.size selectedUsers + Sum nrOk <- runDB $ flip foldMapM selectedUsers $ \uid -> do + fsh <- selectCompanyUserPrime' uid + mbKey <- insertUnique $ TutorialParticipant registerTutorial uid fsh Nothing Nothing Nothing + return $ Sum $ length mbKey + let mStatus = bool Success Warning $ nrOk < Set.size selectedUsers + addMessageI mStatus $ MsgCourseUsersTutorialRegistered $ fromIntegral nrOk redirect $ CourseR tid ssh csh CUsersR (CourseUserRegisterExamData{..}, selectedUsers) -> do Sum nrReg <- fmap mconcat . runDB . forM (Set.toList selectedUsers) $ \uid -> maybeT (return mempty) $ do diff --git a/src/Handler/Exam/Correct.hs b/src/Handler/Exam/Correct.hs index b9e5c52fb..78bf77d06 100644 --- a/src/Handler/Exam/Correct.hs +++ b/src/Handler/Exam/Correct.hs @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2022 Gregor Kleen ,Sarah Vaupel +-- SPDX-FileCopyrightText: 2022-2025 Gregor Kleen ,Sarah Vaupel ,Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later @@ -20,6 +20,7 @@ import Handler.Utils.Exam (fetchExam) import Utils.Exam.Correct +{-# ANN module ("HLint: ignore Functor law" :: String) #-} -- | Minimum length of a participant identifier. Identifiers that are shorter would result in too many query results and are therefor rejected. minNeedleLength :: Int @@ -72,7 +73,7 @@ postECorrectR tid ssh csh examn = do Entity eId Exam{} <- lift $ fetchExam tid ssh csh examn euid <- traverse decrypt ciqUser - guardMExceptT (maybe True ((>= minNeedleLength) . length) $ euid ^? _Left) $ + guardMExceptT (maybe True ((>= minNeedleLength) . length) $ euid ^? _Left) $ CorrectInterfaceResponseFailure Nothing <$> (getMessageRender <*> pure MsgExamCorrectErrorNeedleTooShort) participantMatches <- lift . E.select . E.from $ \(examRegistration `E.InnerJoin` user) -> do @@ -84,14 +85,16 @@ postECorrectR tid ssh csh examn = do mUserIdent = euid ^? _Left E.where_ $ uidMatch E.||. (case mUserIdent of - Just userIdent -> user E.^. UserSurname E.==. E.val userIdent - E.||. user E.^. UserSurname `E.hasInfix` E.val userIdent - E.||. user E.^. UserFirstName E.==. E.val userIdent - E.||. user E.^. UserFirstName `E.hasInfix` E.val userIdent - E.||. user E.^. UserDisplayName E.==. E.val userIdent - E.||. user E.^. UserDisplayName `E.hasInfix` E.val userIdent - E.||. user E.^. UserMatrikelnummer E.==. E.val mUserIdent - E.||. user E.^. UserMatrikelnummer `E.hasInfix` E.val mUserIdent + Just userIdent -> user E.^. UserSurname E.==. E.val userIdent + E.||. user E.^. UserSurname `E.hasInfix` E.val userIdent + E.||. user E.^. UserFirstName E.==. E.val userIdent + E.||. user E.^. UserFirstName `E.hasInfix` E.val userIdent + E.||. user E.^. UserDisplayName E.==. E.val userIdent + E.||. user E.^. UserDisplayName `E.hasInfix` E.val userIdent + E.||. user E.^. UserMatrikelnummer E.==. E.val mUserIdent + E.||. user E.^. UserMatrikelnummer `E.hasInfix` E.val mUserIdent + E.||. user E.^. UserEmail E.==. E.val (userIdent & CI.mk) + E.||. user E.^. UserDisplayEmail E.==. E.val (userIdent & CI.mk) Nothing -> E.val False) E.limit $ succ maxCountUserMatches return user @@ -200,8 +203,8 @@ postECorrectR tid ssh csh examn = do , ciraHasMore = length participantMatches > maxCountUserMatches , ciraUsers = Set.fromList users } - + whenM acceptsJson $ sendResponseStatus (ciResponseStatus response) $ toJSON response - + redirect $ CExamR tid ssh csh examn EShowR diff --git a/src/Handler/Exam/Edit.hs b/src/Handler/Exam/Edit.hs index 725236bd8..67e700ac0 100644 --- a/src/Handler/Exam/Edit.hs +++ b/src/Handler/Exam/Edit.hs @@ -75,33 +75,7 @@ postEEditR tid ssh csh examn = do occIds <- fmap catMaybes . forM (Set.toList efOccurrences) $ traverse decrypt . eofId deleteWhere [ ExamOccurrenceExam ==. eId, ExamOccurrenceId /<-. occIds ] - forM_ (Set.toList efOccurrences) $ \case - ExamOccurrenceForm{ eofId = Nothing, .. } -> insert_ - ExamOccurrence - { examOccurrenceExam = eId - , examOccurrenceName = eofName - , examOccurrenceRoom = eofRoom - , examOccurrenceRoomHidden = eofRoomHidden - , examOccurrenceCapacity = eofCapacity - , examOccurrenceStart = eofStart - , examOccurrenceEnd = eofEnd - , examOccurrenceDescription = eofDescription - } - ExamOccurrenceForm{ .. } -> void . runMaybeT $ do - cID <- hoistMaybe eofId - eofId' <- decrypt cID - oldOcc <- MaybeT $ get eofId' - guard $ examOccurrenceExam oldOcc == eId - lift $ replace eofId' ExamOccurrence - { examOccurrenceExam = eId - , examOccurrenceName = eofName - , examOccurrenceRoom = eofRoom - , examOccurrenceRoomHidden = eofRoomHidden - , examOccurrenceCapacity = eofCapacity - , examOccurrenceStart = eofStart - , examOccurrenceEnd = eofEnd - , examOccurrenceDescription = eofDescription - } + void $ upsertExamOccurrences eId $ Set.toList efOccurrences pIds <- fmap catMaybes . forM (Set.toList efExamParts) $ traverse decrypt . epfId @@ -118,7 +92,7 @@ postEEditR tid ssh csh examn = do when brokenRefs $ throwM ExamEditWouldBreakSheetTypeReference - + deleteWhere [ ExamPartExam ==. eId, ExamPartId /<-. pIds ] forM_ (Set.toList efExamParts) $ \case ExamPartForm{ epfId = Nothing, .. } -> insert_ @@ -150,6 +124,7 @@ postEEditR tid ssh csh examn = do deleteWhere [ ExamCorrectorExam ==. eId ] insertMany_ $ map (ExamCorrector eId) adds memcachedByInvalidate AuthCacheExamCorrectorList $ Proxy @(Set UserId) + memcachedInvalidateClass MemcachedKeyClassExamOccurrences deleteWhere [ InvitationFor ==. invRef @ExamCorrector eId, InvitationEmail /<-. invites ] sinkInvitationsF examCorrectorInvitationConfig $ map (, eId, (InvDBDataExamCorrector, InvTokenDataExamCorrector)) invites diff --git a/src/Handler/Exam/Form.hs b/src/Handler/Exam/Form.hs index 34277e5cb..cd9004489 100644 --- a/src/Handler/Exam/Form.hs +++ b/src/Handler/Exam/Form.hs @@ -7,6 +7,8 @@ module Handler.Exam.Form , ExamOccurrenceForm(..) , ExamPartForm(..) , examForm + , examOccurrenceMultiForm, examOccurrenceForm + , upsertExamOccurrences, copyExamOccurrences , examFormTemplate, examTemplate , validateExam ) where @@ -23,12 +25,17 @@ import qualified Data.Map as Map import qualified Data.Set as Set import qualified Database.Esqueleto.Legacy as E -import qualified Database.Esqueleto.Utils as E +import qualified Database.Esqueleto.Experimental as Ex +import qualified Database.Esqueleto.Utils as Ex import qualified Control.Monad.State.Class as State import Text.Blaze.Html.Renderer.Text (renderHtml) +import Text.Pandoc.Shared (toRomanNumeral) +import qualified Data.Char as Char +import qualified Data.Text as Text import qualified Data.Text.Lazy as LT +import qualified Data.CaseInsensitive as CI import qualified Data.Conduit.Combinators as C @@ -61,7 +68,8 @@ data ExamForm = ExamForm data ExamOccurrenceForm = ExamOccurrenceForm { eofId :: Maybe CryptoUUIDExamOccurrence - , eofName :: ExamOccurrenceName + , eofName :: Maybe ExamOccurrenceName + , eofExaminer :: Maybe UserId , eofRoom :: Maybe RoomReference , eofRoomHidden :: Bool , eofCapacity :: Maybe Word64 @@ -74,6 +82,7 @@ instance Ord ExamOccurrenceForm where compare = mconcat [ comparing eofName , comparing eofStart + , comparing eofExaminer , comparing eofRoom , comparing eofEnd , comparing eofCapacity @@ -125,17 +134,17 @@ examForm (Entity _ Course{..}) template csrf = hoist liftHandler $ do <$> areq ciField (fslpI MsgTableExamName (mr MsgTableExamName) & setTooltip MsgExamNameTip) (efName <$> template) <*> aopt htmlField (fslI MsgExamDescription) (efDescription <$> template) <* aformSection MsgExamFormTimes - <*> aopt utcTimeField (fslpI MsgExamStart (mr MsgDate) & setTooltip MsgExamTimeTip) (efStart <$> template) - <*> aopt utcTimeField (fslpI MsgExamEnd (mr MsgDate) & setTooltip MsgExamTimeTip) (efEnd <$> template) - <*> aopt utcTimeField (fslpI MsgExamVisibleFrom (mr MsgDate) & setTooltip MsgExamVisibleFromTip) (efVisibleFrom <$> template) - <*> aopt utcTimeField (fslpI MsgExamRegisterFrom (mr MsgDate) & setTooltip MsgExamRegisterFromTip) (efRegisterFrom <$> template) - <*> aopt utcTimeField (fslpI MsgExamRegisterTo (mr MsgDate)) (efRegisterTo <$> template) + <*> aopt utcTimeField (fslpI MsgExamStart (mr MsgDate) & setTooltip (someMessages [MsgExamTimeTip,MsgExamTimeFilterTip])) (efStart <$> template) + <*> aopt utcTimeField (fslpI MsgExamEnd (mr MsgDate) & setTooltip (someMessages [MsgExamTimeTip,MsgExamTimeFilterTip])) (efEnd <$> template) + <*> aopt utcTimeField (fslpI MsgExamVisibleFrom (mr MsgDate) & setTooltip MsgExamVisibleFromTip ) (efVisibleFrom <$> template) + <*> aopt utcTimeField (fslpI MsgExamRegisterFrom (mr MsgDate) & setTooltip MsgExamRegisterFromTip) (efRegisterFrom <$> template) + <*> aopt utcTimeField (fslpI MsgExamRegisterTo (mr MsgDate)) (efRegisterTo <$> template) <*> aopt utcTimeField (fslpI MsgExamDeregisterUntil (mr MsgDate)) (efDeregisterUntil <$> template) <*> aopt utcTimeField (fslpI MsgExamPublishOccurrenceAssignments (mr MsgDate) & setTooltip MsgExamPublishOccurrenceAssignmentsTip) (efPublishOccurrenceAssignments <$> template) <*> aopt utcTimeField (fslpI MsgExamPartsFrom (mr MsgDate) & setTooltip MsgExamPartsFromTip) (efPartsFrom <$> template) - <*> aopt utcTimeField (fslpI MsgExamFinished (mr MsgDate) & setTooltip (bool MsgExamFinishedTip MsgExamFinishedTipCloseOnFinished $ is _ExamCloseOnFinished' schoolExamCloseMode)) (efFinished <$> template) + <*> aopt utcTimeField (fslpI MsgExamFinished (mr MsgDate) & setTooltip (bool MsgExamFinishedTip MsgExamFinishedTipCloseOnFinished $ is _ExamCloseOnFinished' schoolExamCloseMode)) (efFinished <$> template) <* aformSection MsgExamFormOccurrences - <*> examOccurrenceForm (efOccurrences <$> template) + <*> examOccurrenceMultiForm (efOccurrences <$> template) <* aformSection MsgExamFormAutomaticFunctions <*> apopt checkBoxField (fslI MsgExamPublicStatistics & setTooltip MsgExamPublicStatisticsTip) (efPublicStatistics <$> template <|> Just True) <*> optionalActionA (examGradingRuleForm $ efGradingRule =<< template) (fslI MsgExamAutomaticGrading & setTooltip MsgExamAutomaticGradingTip) (is _Just . efGradingRule <$> template) @@ -164,7 +173,7 @@ examForm (Entity _ Course{..}) template csrf = hoist liftHandler $ do (fslI MsgExamAuthorshipStatementContent & setTooltip MsgExamAuthorshipStatementContentForcedTip) contentField ttipReq | not schoolSheetExamAuthorshipStatementAllowOther - = fmap (fmap authorshipStatementDefinitionContent) . traverse forcedContentField $ entityVal <$> mSchoolAuthorshipStatement + = fmap (fmap authorshipStatementDefinitionContent) (traverse (forcedContentField . entityVal) mSchoolAuthorshipStatement) | otherwise = Just <$> reqContentField ttipReq in case schoolSheetExamAuthorshipStatementMode of @@ -248,18 +257,12 @@ examCorrectorsForm mPrev = wFormToAForm $ do fmap Set.fromList <$> massInputAccumW miAdd' miCell' miButtonAction' miLayout' ("correctors" :: Text) (fslI MsgExamCorrectors & setTooltip MsgExamCorrectorsTip) False (Set.toList <$> mPrev) -examOccurrenceForm :: Maybe (Set ExamOccurrenceForm) -> AForm Handler (Set ExamOccurrenceForm) -examOccurrenceForm prev = wFormToAForm $ do - currentRoute <- fromMaybe (error "examOccurrenceForm called from 404-handler") <$> getCurrentRoute - let - miButtonAction' :: forall p. PathPiece p => p -> Maybe (SomeRoute UniWorX) - miButtonAction' frag = Just . SomeRoute $ currentRoute :#: frag - fmap (fmap Set.fromList) . massInputAccumEditW miAdd' miCell' miButtonAction' miLayout' miIdent' (fslI MsgExamOccurrences) False $ Set.toList <$> prev - where - examOccurrenceForm' nudge mPrev csrf = do +examOccurrenceForm :: (Text -> Text) -> Maybe ExamOccurrenceForm -> Form ExamOccurrenceForm +examOccurrenceForm nudge mPrev csrf = do (eofIdRes, eofIdView) <- mopt hiddenField ("" & addName (nudge "id")) (Just $ eofId =<< mPrev) - (eofNameRes, eofNameView) <- mpreq (textField & cfStrip & cfCI) (fslI MsgExamRoomName & addName (nudge "name")) (eofName <$> mPrev) + (eofNameRes, eofNameView) <- mopt (textField & cfStrip & cfCI) (fslI MsgExamRoomName & addName (nudge "name")) (eofName <$> mPrev) + (eofExaminerRes, eofExaminerView) <- mopt examinerField (fslI MsgExamStaff & addName (nudge "examiner")) (eofExaminer <$> mPrev) -- TODO: restrict suggestions! (eofRoomRes', eofRoomView) <- ($ mempty) . renderAForm FormVertical $ (,) <$> roomReferenceFormOpt (fslI MsgExamRoomRoom & addName (nudge "room")) (eofRoom <$> mPrev) <*> apopt checkBoxField (fslI MsgExamRoomRoomHidden & setTooltip MsgExamRoomRoomHiddenTip & addName (nudge "room-hidden")) (eofRoomHidden <$> mPrev) @@ -269,10 +272,10 @@ examOccurrenceForm prev = wFormToAForm $ do (eofStartRes, eofStartView) <- mpreq utcTimeField (fslI MsgExamRoomStart & addName (nudge "start")) (eofStart <$> mPrev) (eofEndRes, eofEndView) <- mopt utcTimeField (fslI MsgExamRoomEnd & addName (nudge "end")) (eofEnd <$> mPrev) (eofDescRes, eofDescView) <- mopt htmlField (fslI MsgExamRoomDescription & addName (nudge "description")) (eofDescription <$> mPrev) - return ( ExamOccurrenceForm <$> eofIdRes <*> eofNameRes + <*> eofExaminerRes <*> eofRoomRes <*> eofRoomHiddenRes <*> eofCapacityRes @@ -281,20 +284,166 @@ examOccurrenceForm prev = wFormToAForm $ do <*> eofDescRes , $(widgetFile "widgets/massinput/examRooms/form") ) + where + examinerField = knownUserField True $ Just $ E.from $ \usr -> do + E.where_ $ + (E.exists . E.from $ \exCorr -> E.where_ $ exCorr E.^. ExamCorrectorUser E.==. usr E.^. UserId + ) E.||. + (E.exists . E.from $ \exOccr -> E.where_ $ exOccr E.^. ExamOccurrenceExaminer E.==. E.just (usr E.^. UserId) + ) + pure usr +examOccurrenceMultiForm :: Maybe (Set ExamOccurrenceForm) -> AForm Handler (Set ExamOccurrenceForm) +examOccurrenceMultiForm prev = wFormToAForm $ do + currentRoute <- fromMaybe (error "examOccurrenceMultiForm called from 404-handler") <$> getCurrentRoute + let + miButtonAction' :: forall p. PathPiece p => p -> Maybe (SomeRoute UniWorX) + miButtonAction' frag = Just . SomeRoute $ currentRoute :#: frag + + fmap (fmap Set.fromList) . massInputAccumEditW miAdd' miCell' miButtonAction' miLayout' miIdent' (fslI MsgExamOccurrences) False $ Set.toList <$> prev + where miAdd' nudge submitView csrf = do MsgRenderer mr <- getMsgRenderer - (res, formWidget) <- examOccurrenceForm' nudge Nothing csrf + (res, formWidget) <- examOccurrenceForm nudge Nothing csrf let addRes = res <&> \newDat (Set.fromList -> oldDat) -> if | newDat `Set.member` oldDat -> FormFailure [mr MsgExamRoomAlreadyExists] | otherwise -> FormSuccess $ pure newDat return (addRes, $(widgetFile "widgets/massinput/examRooms/add")) - miCell' nudge dat = examOccurrenceForm' nudge (Just dat) + miCell' nudge dat = examOccurrenceForm nudge (Just dat) miLayout' lLength _ cellWdgts delButtons addWdgts = $(widgetFile "widgets/massinput/examRooms/layout") miIdent' :: Text miIdent' = "exam-occurrences" + +examOccurrenceTemplate :: ExamOccurrence -> ExamOccurrenceForm +examOccurrenceTemplate ExamOccurrence{..} = ExamOccurrenceForm{..} + where + eofId = Nothing + eofName = Just examOccurrenceName + eofExaminer = examOccurrenceExaminer + eofRoom = examOccurrenceRoom + eofRoomHidden = examOccurrenceRoomHidden + eofCapacity = examOccurrenceCapacity + eofStart = examOccurrenceStart + eofEnd = examOccurrenceEnd + eofDescription = examOccurrenceDescription + +-- | copy all exam occurrences of an exam, that start on a specified day, to another day, preserving everything else +-- if the occurrence name contains the day it is replaced, otherwise guessExamOccurrenceName is invoked +copyExamOccurrences :: forall backend m . (PersistUniqueRead backend, PersistQueryRead backend + , PersistUniqueWrite backend, BaseBackend backend ~ SqlBackend, BackendCompatible SqlBackend backend + , MonadHandler m, MonadThrow m, HandlerSite m ~ UniWorX) + => Key Exam -> Day -> Day -> ReaderT backend m Int +copyExamOccurrences eId dfrom dto = do + let dfts = ["%d.%m.%Y", "%d.%m.%y", "%Y-%m-%d", "%y-%m-%d", "%d.%m", "%d-%m", "%m-%d"] + fts fs = (,) <$> formatTime' fs dfrom <*> formatTime' fs dto + shiftDay :: Day -> Day = addDays $ diffDays dto dfrom + drepl <- mapM fts dfts + exOccs <- Ex.select $ do + occ <- Ex.from $ Ex.table @ExamOccurrence + Ex.where_ $ occ Ex.^. ExamOccurrenceExam Ex.==. Ex.val eId + Ex.&&. Ex.day (occ Ex.^. ExamOccurrenceStart) Ex.==. Ex.val dfrom + return occ + res <- forM exOccs $ \Entity{entityVal=eo@ExamOccurrence{examOccurrenceName=oldName}} -> do + let eo' = _examOccurrenceStart . _utctDay %~ shiftDay $ + _examOccurrenceEnd . _Just . _utctDay %~ shiftDay $ eo + newName <- maybeM (guessExamOccurrenceName eId $ examOccurrenceTemplate eo') return $ return (fmap CI.mk $ textReplaceFirst drepl $ CI.original oldName) + insertUnique_ (eo'{examOccurrenceName=newName}) + memcachedInvalidateClass MemcachedKeyClassExamOccurrences + return $ length $ catMaybes res + +-- | generate an exam-unique occurrence name from data +-- Pattern: ___ +-- eofName is entirely ignored, assumed to be Nothing +guessExamOccurrenceName :: forall backend m . (PersistUniqueRead backend, PersistQueryRead backend, BaseBackend backend ~ SqlBackend, BackendCompatible SqlBackend backend + , MonadHandler m, MonadThrow m, HandlerSite m ~ UniWorX) + => Key Exam -> ExamOccurrenceForm -> ReaderT backend m ExamOccurrenceName +guessExamOccurrenceName eId ExamOccurrenceForm{..} = do + -- oday <- formatTime' "%m-%d" eofStart + oday <- formatTime' "%d.%m." eofStart + ohour <- ifM hasMoreThanOneHour (formatTime' "at%H" eofStart) (return mempty) + inis <- ifMoreThanOne ExamOccurrenceExaminer $ foldMapM getInitials eofExaminer + room <- case eofRoom of + Just (RoomReferenceSimple t) -> ifMoreThanOne ExamOccurrenceRoom $ return $ Text.take 4 t -- Text.cons '-' $ Text.take 4 t + _ -> return mempty + let pfx = CI.mk $ inis <> oday <> ohour <> room + eons = ocheck pfx : [ ocheck $ pfx <> CI.mk (Text.cons '_' $ toRomanNumeral n) | n <- [2..3999]] + fromMaybe "Handler.Exam.Form.guessExamOccurrenceName failed to guess a unique name" + <$> firstJustM eons + where + getInitials uid = get uid <&> foldMap (Text.filter Char.isUpper . userDisplayName) -- flip Text.snoc '_' . + ocheck eon = existsBy (UniqueExamOccurrence eId eon) <&> (flip toMaybe eon . not) + + ifMoreThanOne :: (PersistField t, Monoid o) => EntityField ExamOccurrence (Maybe t) -> ReaderT backend m o -> ReaderT backend m o + ifMoreThanOne eoprop act = ifM (hasMoreThanOne eoprop) act (return mempty) + + hasMoreThanOne :: PersistField t => EntityField ExamOccurrence (Maybe t) -> ReaderT backend m Bool + hasMoreThanOne eoprop = $(memcachedByHere) (Just . Right $ 1 * diffMinute) (eId, tshow $ persistFieldDef eoprop) $ Ex.selectExists $ do + exOcc <- Ex.from $ Ex.table @ExamOccurrence + Ex.where_ $ (exOcc Ex.^. ExamOccurrenceExam Ex.==. Ex.val eId) + Ex.&&. Ex.isJust (exOcc Ex.^. eoprop) + Ex.&&. Ex.exists (do + otOcc <- Ex.from $ Ex.table @ExamOccurrence + Ex.where_ $ (otOcc Ex.^. ExamOccurrenceExam Ex.==. Ex.val eId) + Ex.&&. Ex.isJust (otOcc Ex.^. eoprop) + Ex.&&. otOcc Ex.^. eoprop Ex.!=. exOcc Ex.^. eoprop + ) + + hasMoreThanOneHour :: ReaderT backend m Bool + hasMoreThanOneHour = $(memcachedByHere) (Just . Right $ 1 * diffMinute) eId $ Ex.selectExists $ do + exOcc <- Ex.from $ Ex.table @ExamOccurrence + Ex.where_ $ (exOcc Ex.^. ExamOccurrenceExam Ex.==. Ex.val eId) + Ex.&&. Ex.exists (do + other <- Ex.from $ Ex.table @ExamOccurrence + Ex.where_ $ (other Ex.^. ExamOccurrenceExam Ex.==. Ex.val eId) + Ex.&&. (Ex.day (other Ex.^. ExamOccurrenceStart) Ex.==. Ex.day (exOcc Ex.^. ExamOccurrenceStart)) + Ex.&&. ( other Ex.^. ExamOccurrenceStart Ex.!=. exOcc Ex.^. ExamOccurrenceStart) + ) + + +-- upsertExamOccurrences :: (MonoFoldable mono, Element mono ~ ExamOccurrenceForm) => ExamId -> mono -> DB () -- too specific +upsertExamOccurrences :: ( HandlerSite m ~ UniWorX, MonadHandler m, MonadThrow m + , PersistQueryRead backend, PersistUniqueWrite backend + , BaseBackend backend ~ SqlBackend, BackendCompatible SqlBackend backend) + => Key Exam -> [ExamOccurrenceForm] -> ReaderT backend m Int +upsertExamOccurrences eId = fmap (length . catMaybes) . mapM (\case + eof@ExamOccurrenceForm{ eofId = Nothing, eofName = eofNameMb, .. } -> do + eofName <- fromMaybeM (guessExamOccurrenceName eId eof) (pure eofNameMb) + $logInfoS "ExamOccurrenceForm" [st|New Exam Occurrence: #{eofName}|] + insertUnique_ ExamOccurrence + { examOccurrenceExam = eId + , examOccurrenceName = eofName + , examOccurrenceExaminer = eofExaminer + , examOccurrenceRoom = eofRoom + , examOccurrenceRoomHidden = eofRoomHidden + , examOccurrenceCapacity = eofCapacity + , examOccurrenceStart = eofStart + , examOccurrenceEnd = eofEnd + , examOccurrenceDescription = eofDescription + } + eof@ExamOccurrenceForm{eofName = eofNameMb, .. } -> fmap join $ runMaybeT $ do + cID <- hoistMaybe eofId + eofId' <- decrypt cID + oldOcc <- MaybeT $ get eofId' + guard $ examOccurrenceExam oldOcc == eId + lift $ do + eofName <- fromMaybeM (guessExamOccurrenceName eId eof) (pure eofNameMb) + res <- replaceUnique eofId' ExamOccurrence + { examOccurrenceExam = eId + , examOccurrenceName = eofName + , examOccurrenceExaminer = eofExaminer + , examOccurrenceRoom = eofRoom + , examOccurrenceRoomHidden = eofRoomHidden + , examOccurrenceCapacity = eofCapacity + , examOccurrenceStart = eofStart + , examOccurrenceEnd = eofEnd + , examOccurrenceDescription = eofDescription + } + memcachedInvalidateClass MemcachedKeyClassExamOccurrences + return $ flipMaybe () res + ) + examPartsForm :: Maybe (Set ExamPartForm) -> AForm Handler (Set ExamPartForm) examPartsForm prev = wFormToAForm $ do currentRoute <- fromMaybe (error "examPartsForm called from 404-handler") <$> getCurrentRoute @@ -371,7 +520,8 @@ examFormTemplate (Entity eId Exam{..}) = do (Just -> eofId, ExamOccurrence{..}) <- occurrences' return ExamOccurrenceForm { eofId - , eofName = examOccurrenceName + , eofName = examOccurrenceName & Just + , eofExaminer = examOccurrenceExaminer , eofRoom = examOccurrenceRoom , eofRoomHidden = examOccurrenceRoomHidden , eofCapacity = examOccurrenceCapacity @@ -419,7 +569,7 @@ examTemplate cid = runMaybeT $ do E.limit 1 E.orderBy [ E.desc $ course E.^. CourseTerm, E.asc $ exam E.^. ExamVisibleFrom ] return (course, exam, authorshipStatementDefinition) - + extraSchools <- lift $ selectList [ ExamOfficeSchoolExam ==. oldExamId ] [] oldTerm <- MaybeT . get $ courseTerm oldCourse @@ -474,10 +624,10 @@ validateExam cId oldExam = do guardValidation MsgExamPartsFromMustBeBeforeFinished $ NTop efFinished >= NTop efPartsFrom || is _Nothing efPartsFrom - forM_ efOccurrences $ \ExamOccurrenceForm{..} -> do - guardValidation (MsgExamOccurrenceEndMustBeAfterStart eofName) $ NTop eofEnd >= NTop (Just eofStart) + forM_ efOccurrences $ \ExamOccurrenceForm{eofName=fold->eofName, ..} -> do + guardValidation (MsgExamOccurrenceEndMustBeAfterStart eofName) $ NTop eofEnd >= NTop (Just eofStart) guardValidation (MsgExamOccurrenceStartMustBeAfterExamStart eofName) $ NTop (Just eofStart) >= NTop efStart - warnValidation (MsgExamOccurrenceEndMustBeBeforeExamEnd eofName) $ NTop eofEnd <= NTop efEnd + warnValidation (MsgExamOccurrenceEndMustBeBeforeExamEnd eofName) $ NTop eofEnd <= NTop efEnd forM_ [ (a, b) | a <- Set.toAscList efOccurrences, b <- Set.toAscList efOccurrences, b > a ] $ \(a, b) -> do eofRange' <- formatTimeRange SelFormatDateTime (eofStart a) (eofEnd a) @@ -490,7 +640,7 @@ validateExam cId oldExam = do , (/=) `on` fmap (LT.strip . renderHtml . markupOutput) . eofDescription ] - guardValidation (MsgExamOccurrenceDuplicateName $ eofName a) $ ((/=) `on` eofName) a b + guardValidation (MsgExamOccurrenceDuplicateName $ fold $ eofName a) $ ((/=) `on` eofName) a b oldOccurrencesWithRegistrations <- for oldExam $ \(Entity eId _) -> lift . E.select . E.from $ \examOccurrence -> do E.where_ $ examOccurrence E.^. ExamOccurrenceExam E.==. E.val eId @@ -517,7 +667,7 @@ validateExam cId oldExam = do .| C.mapM_ (\(Entity _ Sheet{..}) -> guardValidationM (MsgExamPartCannotBeDeletedDueToSheetReference epNumber sheetName) . anyM (otoList efExamParts) $ \ExamPartForm{..} -> (== Just epId) <$> traverse decrypt epfId) - mSchool <- liftHandler . runDB . E.selectMaybe . E.from $ \(course `E.InnerJoin` school) -> do + mSchool <- liftHandler . runDB . E.selectOne . E.from $ \(course `E.InnerJoin` school) -> do E.on $ course E.^. CourseSchool E.==. school E.^. SchoolId E.where_ $ course E.^. CourseId E.==. E.val cId return school diff --git a/src/Handler/Exam/New.hs b/src/Handler/Exam/New.hs index 886dfa0b9..3c09f207a 100644 --- a/src/Handler/Exam/New.hs +++ b/src/Handler/Exam/New.hs @@ -70,18 +70,7 @@ postCExamNewR tid ssh csh = do examPartWeight = epfWeight ] - insertMany_ - [ ExamOccurrence{..} - | ExamOccurrenceForm{..} <- Set.toList efOccurrences - , let examOccurrenceExam = examid - examOccurrenceName = eofName - examOccurrenceRoom = eofRoom - examOccurrenceRoomHidden = eofRoomHidden - examOccurrenceCapacity = eofCapacity - examOccurrenceStart = eofStart - examOccurrenceEnd = eofEnd - examOccurrenceDescription = eofDescription - ] + void $ upsertExamOccurrences examid $ Set.toList efOccurrences insertMany_ [ ExamOfficeSchool ssh' examid | ssh' <- Set.toList efOfficeSchools ] @@ -92,6 +81,7 @@ postCExamNewR tid ssh csh = do ] sinkInvitationsF examCorrectorInvitationConfig $ map (, examid, (InvDBDataExamCorrector, InvTokenDataExamCorrector)) invites memcachedByInvalidate AuthCacheExamCorrectorList $ Proxy @(Set UserId) + memcachedInvalidateClass MemcachedKeyClassExamOccurrences let recordNoShow (Entity _ CourseParticipant{..}) = do didRecord <- is _Just <$> insertUnique ExamResult diff --git a/src/Handler/Exam/Show.hs b/src/Handler/Exam/Show.hs index 090c6f1ed..b5e55db73 100644 --- a/src/Handler/Exam/Show.hs +++ b/src/Handler/Exam/Show.hs @@ -48,7 +48,7 @@ getEShowR tid ssh csh examn = do let occurrenceAssignmentsVisible = NTop (Just cTime) >= NTop examPublishOccurrenceAssignments || examOccurrenceRule == ExamRoomFifo occurrenceAssignmentsShown = occurrenceAssignmentsVisible || lecturerInfoShown - sheets <- selectList [ SheetCourse ==. examCourse ] [] + sheets <- selectList [ SheetCourse ==. examCourse ] [] let examPartSheets epId = do let sheets' = flip filter sheets $ \(Entity _ Sheet{..}) -> has (_examPart . re _SqlKey . only epId) sheetType flip filterM sheets' $ \(Entity _ Sheet{..}) -> hasReadAccessTo $ CSheetR tid ssh csh sheetName SShowR @@ -142,6 +142,11 @@ getEShowR tid ssh csh examn = do guard $ all (\(Entity _ occ, _, _, _) -> examOccurrenceRoom occ == examOccurrenceRoom primeOcc) occurrences guard $ andOf (folded . _4) occurrences examOccurrenceRoom primeOcc + examExaminer = do + (Entity _ primeOcc, _, _, _) <- occurrences ^? _head + guard $ all (\(Entity _ occ, _, _, _) -> examOccurrenceExaminer occ == examOccurrenceExaminer primeOcc) occurrences + guard $ andOf (folded . _4) occurrences + examOccurrenceExaminer primeOcc registerWidget mOcc | isRegistered <- is _Just $ join registered , examOccurrenceRule /= ExamRoomFifo || (isRegistered && not (orOf (folded . _2) occurrences)) @@ -204,7 +209,7 @@ getEShowR tid ssh csh examn = do guard $ evalExamModeDNF schoolExamDiscouragedModes examExamMode guardM . lift . hasWriteAccessTo $ CExamR tid ssh csh examn EEditR return $ notification NotificationBroad =<< messageI Warning MsgExamModeSchoolDiscouraged - + siteLayoutMsg heading $ do setTitleI heading let diff --git a/src/Handler/Exam/Users.hs b/src/Handler/Exam/Users.hs index 09e14253f..911f06532 100644 --- a/src/Handler/Exam/Users.hs +++ b/src/Handler/Exam/Users.hs @@ -1,8 +1,9 @@ --- SPDX-FileCopyrightText: 2022 Gregor Kleen ,Sarah Vaupel ,Sarah Vaupel ,Steffen Jost ,Winnie Ros +-- SPDX-FileCopyrightText: 2022-2025 Gregor Kleen ,Sarah Vaupel ,Sarah Vaupel ,Steffen Jost ,Winnie Ros ,Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later {-# OPTIONS_GHC -fno-warn-orphans #-} +{-# LANGUAGE TypeApplications #-} module Handler.Exam.Users ( getEUsersR, postEUsersR @@ -20,6 +21,8 @@ import Handler.Exam.AutoOccurrence (examAutoOccurrenceCalculateWidget) import Handler.ExamOffice.Exam (examCloseWidget, examFinishWidget) +import Database.Esqueleto.Experimental ((:&)(..)) +import qualified Database.Esqueleto.Experimental as Ex -- needs TypeApplications Lang-Pragma import qualified Database.Esqueleto.Legacy as E import qualified Database.Esqueleto.Utils as E import Database.Esqueleto.Utils.TH @@ -387,7 +390,7 @@ getEUsersR = postEUsersR postEUsersR tid ssh csh examn = do (((Any computedValues, registrationResult), examUsersTable), Entity eId examVal@Exam{..}, (bonus, resultSheets)) <- runDB $ do exam@(Entity eid examVal@Exam{..}) <- fetchExam tid ssh csh examn - Course{..} <- getJust examCourse + -- Course{..} <- getJust examCourse -- no longer needed somehow occurrences <- selectList [ExamOccurrenceExam ==. eid] [Asc ExamOccurrenceName] examParts <- selectList [ExamPartExam ==. eid] [Asc ExamPartName] bonus <- examRelevantSheets exam True @@ -519,29 +522,37 @@ postEUsersR tid ssh csh examn = do dbtFilter = mconcat [ uncurry singletonMap $ fltrUserNameEmail queryUser , uncurry singletonMap $ fltrUserMatriclenr queryUser - , uncurry singletonMap ("occurrence", FilterColumn . E.mkContainsFilterWith Just $ queryExamOccurrence >>> (E.?. ExamOccurrenceName)) + , singletonMap "occurrence" (FilterColumn . E.mkContainsFilterWith Just $ queryExamOccurrence >>> (E.?. ExamOccurrenceName)) , fltrExamResultPoints (to $ queryExamResult >>> (E.?. ExamResultResult)) - , fltrRelevantStudyFeaturesTerms (to $ - \t -> ( E.val courseTerm - , queryUser t E.^. UserId - )) - , fltrRelevantStudyFeaturesDegree (to $ - \t -> ( E.val courseTerm - , queryUser t E.^. UserId - )) - , fltrRelevantStudyFeaturesSemester (to $ - \t -> ( E.val courseTerm - , queryUser t E.^. UserId - )) + -- , fltrRelevantStudyFeaturesTerms (to $ + -- \t -> ( E.val courseTerm + -- , queryUser t E.^. UserId + -- )) + -- , fltrRelevantStudyFeaturesDegree (to $ + -- \t -> ( E.val courseTerm + -- , queryUser t E.^. UserId + -- )) + -- , fltrRelevantStudyFeaturesSemester (to $ + -- \t -> ( E.val courseTerm + -- , queryUser t E.^. UserId + -- )) + , singletonMap "tutorial" $ FilterColumn . E.mkExistsFilter $ \row (criterion :: Text) -> do + (tut :& usrTut) <- Ex.from $ Ex.table @Tutorial + `Ex.innerJoin` Ex.table @TutorialParticipant + `Ex.on` (\(tut :& usrTut) -> tut E.^. TutorialId E.==. usrTut E.^. TutorialParticipantTutorial) + Ex.where_ $ usrTut E.^. TutorialParticipantUser E.==. queryUser row E.^. UserId + E.&&. tut E.^. TutorialName `E.hasInfix` E.val (CI.mk criterion) ] - dbtFilterUI mPrev = mconcat $ catMaybes - [ Just $ fltrUserNameEmailUI mPrev - , Just $ fltrUserMatriclenrUI mPrev - , Just $ prismAForm (singletonFilter "occurrence") mPrev $ aopt (selectField' (Just $ SomeMessage MsgExamNoFilter) $ optionsF [CI.original examOccurrenceName | Entity _ ExamOccurrence{..} <- occurrences]) (fslI MsgTableExamOccurrence) - , Just $ fltrExamResultPointsUI mPrev - , Just $ fltrRelevantStudyFeaturesTermsUI mPrev - , Just $ fltrRelevantStudyFeaturesDegreeUI mPrev - , Just $ fltrRelevantStudyFeaturesSemesterUI mPrev + + dbtFilterUI mPrev = mconcat + [ fltrUserNameEmailUI mPrev + , fltrUserMatriclenrUI mPrev + , prismAForm (singletonFilter "occurrence") mPrev $ aopt (selectField' (Just $ SomeMessage MsgExamNoFilter) $ optionsF [CI.original examOccurrenceName | Entity _ ExamOccurrence{..} <- occurrences]) (fslI MsgTableExamOccurrence) + , fltrExamResultPointsUI mPrev + -- , fltrRelevantStudyFeaturesTermsUI mPrev + -- , fltrRelevantStudyFeaturesDegreeUI mPrev + -- , fltrRelevantStudyFeaturesSemesterUI mPrev + , prismAForm (singletonFilter "tutorial") mPrev $ aopt textField (fslI MsgCourseTutorial) ] dbtStyle = def { dbsFilterLayout = defaultDBSFilterLayout } dbtParams = DBParamsForm diff --git a/src/Handler/Firm.hs b/src/Handler/Firm.hs index e059888e9..783f5f445 100644 --- a/src/Handler/Firm.hs +++ b/src/Handler/Firm.hs @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2023 Steffen Jost +-- SPDX-FileCopyrightText: 2023-2025 Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later @@ -11,7 +11,8 @@ module Handler.Firm , getFirmUsersR , postFirmUsersR , getFirmSupersR, postFirmSupersR , getFirmCommR , postFirmCommR - , getFirmsCommR, postFirmsCommR + , getFirmsCommR , postFirmsCommR + , getFirmsSupervisionR , postFirmsSupervisionR ) where @@ -38,10 +39,7 @@ import qualified Database.Esqueleto.Legacy as EL (on) -- needed for legacy join import qualified Database.Esqueleto.Utils as E import Database.Esqueleto.Utils.TH - --- avoids repetition of local definitions -single :: (k,a) -> Map k a -single = uncurry Map.singleton +import Handler.Firm.Supervision -- decryptUser :: (MonadHandler m, HandlerSite m ~ UniWorX) => CryptoUUIDUser -> m UserId -- decryptUser = decrypt @@ -52,12 +50,18 @@ encryptUser = encrypt postalEmailField :: (MonadHandler m, HandlerSite m ~ UniWorX) => Field m Bool postalEmailField = boolFieldCustom (SomeMessage MsgUtilPostal) (SomeMessage MsgUtilEMail) $ Just $ SomeMessage MsgUtilUnchanged + + -- prioLetterPassword :: E.SqlExpr (Entity User) -> SqlExpr (Value Int64) + -- prioLetterPassword usr = E.case_ [E.when_ (usr E.^. UserPrefersPostal) E.then_ E.val ] + + --------------------------------- -- General firm affecting actions data FirmAction = FirmActNotify | FirmActResetSupervision | FirmActAddSupervisors + | FirmActAddAssociates | FirmActChangeContactFirm | FirmActChangeContactUser deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic) @@ -68,24 +72,32 @@ embedRenderMessage ''UniWorX ''FirmAction id data FirmActionData = FirmActNotifyData | FirmActResetSupervisionData - { firmActResetKeepOldSupers :: Maybe Bool - , firmActResetMutualSupervision :: Maybe Bool + { firmActResetKeepOldSupers :: Maybe Bool + , firmActResetMutualSupervision :: Maybe Bool } | FirmActAddSupervisorsData - { firmActAddSupervisorIds :: Set Text - , firmActAddSupervisorReroute :: Bool - , firmActAddSupervisorPostal :: Maybe Bool - , firmActAddSupervisorReason :: Maybe Text + { firmActAddUserIds :: Set Text + , firmActAddSupervisorReroute :: Bool + , firmActAddSupervisorPostal :: Maybe Bool + , firmActAddUserUseCompanyAddress :: Bool + , firmActAddSupervisorReason :: Maybe Text + } + | FirmActAddAssociatesData + { firmActAddUserIds :: Set Text + , firmActAddAssociatePriority :: Int + , firmActAddUserUseCompanyAddress :: Bool + , firmActAddAssociateReason :: Maybe Text } | FirmActChangeContactFirmData - { firmActCCFPostalAddr :: Maybe StoredMarkup - , firmActCCFEmail :: Maybe UserEmail - , firmActCCFPostalPref :: Maybe Bool + { firmActCCFPostalAddr :: Maybe StoredMarkup + , firmActCCFEmail :: Maybe UserEmail + , firmActCCFPostalPref :: Maybe Bool + , firmActCCFPinPassword :: Maybe Bool } | FirmActChangeContactUserData - { firmActCCUPostalAddr :: Maybe StoredMarkup - , firmActCCUUseCompanyPostal :: Maybe Bool - , firmActCCUPostalPref :: Maybe Bool + { firmActCCUPostalAddr :: Maybe StoredMarkup + , firmActCCUUseCompanyPostal :: Maybe Bool + , firmActCCUPostalPref :: Maybe Bool } deriving (Eq, Ord, Read, Show, Generic) @@ -97,18 +109,26 @@ firmActionMap mr isAdmin acts = mconcat (mkAct isAdmin <$> acts) <$> aopt boolField' (fslI MsgFirmActResetSuperKeep) (Just $ Just False) <*> aopt checkBoxField (fslI MsgFirmActResetMutualSupervision) (Just $ Just True ) mkAct _ FirmActAddSupervisors = singletonMap FirmActAddSupervisors $ FirmActAddSupervisorsData - <$> areq (textField & cfAnySeparatedSet) (fslI MsgFirmSuperDefault & setTooltip MsgCourseParticipantsRegisterUsersFieldTip) Nothing + <$> areq (textField & cfAnySeparatedSet) (fslI MsgFirmSuperDefault & setTooltip MsgCourseParticipantsRegisterUsersFieldTip) Nothing <*> areq checkBoxField (fslI MsgTableIsDefaultReroute) (Just True) <*> aopt postalEmailField (fslI MsgFormFieldPostal & setTooltip MsgFormFieldPostalTip) Nothing + <*> areq checkBoxField (fslI MsgCompanyUserUseCompanyAddress & setTooltip MsgCompanyUserUseCompanyAddressTip) (Just True) <*> aopt (textField & cfStrip & addDatalist ucdefSuperReasons) (fslI MsgUserCompanyReason & setTooltip MsgUserCompanyReasonTooltip) Nothing + mkAct _ FirmActAddAssociates = singletonMap FirmActAddAssociates $ FirmActAddAssociatesData + <$> areq (textField & cfAnySeparatedSet) (fslI MsgFirmAssociates & setTooltip MsgCourseParticipantsRegisterUsersFieldTip) Nothing + <*> areq intField (fslI MsgCompanyUserPriority & setTooltip MsgCompanyUserPriorityTip) (Just 0) + <*> areq checkBoxField (fslI MsgCompanyUserUseCompanyAddress & setTooltip MsgCompanyUserUseCompanyAddressTip) (Just True) + <*> aopt (textField & cfStrip & addDatalist ucdefAssocReasons) + (fslI MsgUserCompanyReason & setTooltip MsgUserCompanyReasonTooltip) Nothing mkAct _ FirmActChangeContactFirm = singletonMap FirmActChangeContactFirm $ FirmActChangeContactFirmData - <$> aopt htmlField (fslI MsgPostAddress & setTooltip (SomeMessages [SomeMessage MsgPostAddressTip, SomeMessage MsgUtilEmptyNoChangeTip])) Nothing - <*> aopt (emailField & cfStrip & cfCI) (fslI MsgUserDisplayEmail & setTooltip MsgUtilEmptyNoChangeTip) Nothing + <$> aopt htmlField (fslI MsgPostAddress & setTooltip (SomeMsgs [SomeMessage MsgPostAddressTip, SomeMessage MsgUtilEmptyNoChangeTip])) Nothing + <*> aopt (emailField & cfStrip & cfCI) (fslI MsgUserDisplayEmail & setTooltip MsgUtilEmptyNoChangeTip) Nothing <*> aopt postalEmailField (fslI MsgFormFieldPostal & setTooltip MsgFirmDefaultPreferenceInfo) Nothing + <*> aopt boolField' (fslI MsgFormFieldPinPass & setTooltip MsgFirmDefaultPreferenceInfo) Nothing <* aformMessage (Message Info (toHtml $ mr MsgFirmActChangeContactFirmInfo) (Just IconNotificationNonactive)) mkAct _ FirmActChangeContactUser = singletonMap FirmActChangeContactUser $ FirmActChangeContactUserData - <$> aopt htmlField (fslI MsgPostAddress & setTooltip (SomeMessages [SomeMessage MsgPostAddressTip, SomeMessage MsgUtilEmptyNoChangeTip])) Nothing + <$> aopt htmlField (fslI MsgPostAddress & setTooltip (SomeMsgs [SomeMessage MsgPostAddressTip, SomeMessage MsgUtilEmptyNoChangeTip])) Nothing <*> aopt boolField' (fslI MsgCompanyUserUseCompanyAddress & setTooltip MsgCompanyUserUseCompanyAddressTip) Nothing <*> aopt postalEmailField (fslI MsgFormFieldPostal & setTooltip MsgFormFieldPostalTip) Nothing mkAct _ _ = mempty @@ -116,7 +136,15 @@ firmActionMap mr isAdmin acts = mconcat (mkAct isAdmin <$> acts) ucdefSuperReasons = fmap (mkOptionList . map (\t -> Option t t t) . Set.toAscList) . runDB $ fmap (setOf $ folded . _Value . _Just) . E.select . E.distinct $ do usrc <- E.from $ E.table @UserCompany - E.where_ $ E.isJust $ usrc E.^. UserCompanyReason + E.where_ $ E.isJust (usrc E.^. UserCompanyReason) + E.&&. usrc E.^. UserCompanySupervisor + return $ usrc E.^. UserCompanyReason + ucdefAssocReasons :: HandlerFor UniWorX (OptionList Text) + ucdefAssocReasons = fmap (mkOptionList . map (\t -> Option t t t) . Set.toAscList) . runDB $ + fmap (setOf $ folded . _Value . _Just) . E.select . E.distinct $ do + usrc <- E.from $ E.table @UserCompany + E.where_ $ E.isJust (usrc E.^. UserCompanyReason) + E.&&. E.not__ (usrc E.^. UserCompanySupervisor) return $ usrc E.^. UserCompanyReason @@ -162,7 +190,7 @@ firmActionHandler route isAdmin = flip formResult faHandler reloadKeepGetParams route -- reload to reflect changes faHandler (FirmActAddSupervisorsData{..}, Set.toList -> [cid]) = do - avsUsers :: Map Text (Maybe UserId) <- sequenceA $ Map.fromSet guessAvsUser firmActAddSupervisorIds + avsUsers :: Map Text (Maybe UserId) <- sequenceA $ Map.fromSet guessAvsUser firmActAddUserIds let (usersFound', usersNotFound) = partition (is _Just . view _2) $ Map.toList avsUsers usersFound = mapMaybe snd usersFound' unless (null usersNotFound) $ @@ -179,17 +207,42 @@ firmActionHandler route isAdmin = flip formResult faHandler runDB $ do -- putMany [UserCompany uid cid True firmActAddSupervisorReroute 0 False | uid <- usersFound] -- putMany always overwrites existing records, which would destroy priority and useCompanyAddress here -- upsertManyWhere [UserCompany uid cid True firmActAddSupervisorReroute 0 False | uid <- usersFound] [copyField UserCompanySupervisor, copyField UserCompanySupervisorReroute] [] [] -- overwrite Supervisor and SupervisorReroute, keep priority and useCompanyAddress - upsertManyWhere [UserCompany uid cid True firmActAddSupervisorReroute 0 False firmActAddSupervisorReason| uid <- usersFound] [] [UserCompanySupervisor =. True, UserCompanySupervisorReroute =. firmActAddSupervisorReroute, UserCompanyReason =. firmActAddSupervisorReason] [] -- identical to previous line, but perhaps more clear? + upsertManyWhere + [UserCompany uid cid True firmActAddSupervisorReroute 0 firmActAddUserUseCompanyAddress firmActAddSupervisorReason | uid <- usersFound] + [] + [UserCompanySupervisor =. True, UserCompanySupervisorReroute =. firmActAddSupervisorReroute, UserCompanyReason =. firmActAddSupervisorReason] + [] -- identical to previous line, but perhaps more clear? whenIsJust firmActAddSupervisorPostal $ \prefPostal -> updateWhere [UserId <-. usersFound] [UserPrefersPostal =. prefPostal] addMessageI Success $ MsgFirmActAddSupersSet (fromIntegral $ length usersFound) firmActAddSupervisorPostal redirect route + faHandler (FirmActAddAssociatesData{..}, Set.toList -> [cid]) = do + avsUsers :: Map Text (Maybe UserId) <- sequenceA $ Map.fromSet guessAvsUser firmActAddUserIds + let (usersFound', usersNotFound) = partition (is _Just . view _2) $ Map.toList avsUsers + usersFound = mapMaybe snd usersFound' + unless (null usersNotFound) $ + let msgContent = [whamlet| + $newline never +
      + $forall (usr,_) <- usersNotFound +
    • #{usr} + |] + in addMessageModal Error (i18n . MsgCourseParticipantsRegisterNotFoundInAvs $ length usersNotFound) (Right msgContent) + when (null usersFound) $ do + addMessageI Warning MsgFirmActAddAssocsEmpty + reloadKeepGetParams route + runDB $ do + oks <- mapM insertUnique_ [UserCompany uid cid False False firmActAddAssociatePriority firmActAddUserUseCompanyAddress firmActAddAssociateReason | uid <- usersFound] + addMessageOutOfI (const . MsgFirmActAddAssocs) (length $ catMaybes oks) (length usersFound) + redirect route + faHandler (FirmActChangeContactFirmData{..}, Set.toList -> [cid]) = let changes = catMaybes [ (CompanyPostAddress =.) . Just <$> canonical firmActCCFPostalAddr , (CompanyEmail =.) . Just <$> canonical firmActCCFEmail , (CompanyPrefersPostal =.) <$> firmActCCFPostalPref + , (CompanyPinPassword =.) <$> firmActCCFPinPassword ] in unless (null changes) $ do runDB $ update cid changes @@ -216,9 +269,8 @@ firmActionHandler route isAdmin = flip formResult faHandler Just x -> updateWhereCount [UserCompanyCompany ==. cid] [UserCompanyUseCompanyAddress =. x] Nothing -> return 0 nrCid <- count [UserCompanyCompany ==. cid] - return (fromIntegral nrCid, max nrUsrChange nrUseComp) - let allok = bool Warning Success $ nrChanged == total - addMessageI allok $ MsgFirmUserActChangeResult nrChanged total + return (nrCid, max nrUsrChange nrUseComp) + addMessageOutOfI MsgFirmUserActChangeResult nrChanged total reloadKeepGetParams route -- reload to reflect changes faHandler _ = addMessageI Error MsgErrorUnknownFormAction @@ -444,7 +496,7 @@ mkFirmAllTable isAdmin uid = do -- , cmpy & firmCountActiveReroutes' ) dbtRowKey = (E.^. CompanyId) - dbtProj = dbtProjFilteredPostId + dbtProj = dbtProjId dbtColonnade = formColonnade $ mconcat [ dbSelect (applying _2) id (return . view (resultAllCompanyEntity . _entityKey)) , sortable (Just "name") (i18nCell MsgTableCompany) $ \(view resultAllCompany -> firm) -> @@ -465,12 +517,14 @@ mkFirmAllTable isAdmin uid = do -- , sortable (Just "reroute-act") (i18nCell MsgTableCompanyNrRerouteActive) $ \(view resultAllCompanyActiveReroutes -> nr) -> wgtCell $ word2widget nr -- , sortable (Just "reroute-all") (i18nCell MsgTableCompanyNrRerouteActive) $ \(view resultAllCompanyActiveReroutes' -> nr) -> wgtCell $ word2widget nr , sortable (Just "postal-pref") (i18nCell MsgTableCompanyPostalPreference) $ \(view $ resultAllCompany . _companyPrefersPostal -> b) -> iconFixedCell $ iconLetterOrEmail b + , sortable (Just "pin-password") (i18nCell MsgTableCompanyPinPassword) $ \(view $ resultAllCompany . _companyPinPassword -> b) -> ifIconCell b IconPinProtect & addIconFixedWidth ] dbtSorting = mconcat [ singletonMap "name" $ SortColumn (E.^. CompanyName) , singletonMap "short" $ SortColumn (E.^. CompanyShorthand) , singletonMap "avsnr" $ SortColumn (E.^. CompanyAvsId) , singletonMap "postal-pref" $ SortColumn (E.^. CompanyPrefersPostal) + , singletonMap "pin-password" $ SortColumn (E.^. CompanyPinPassword) , singletonMap "users" $ SortColumn firmCountUsers , singletonMap "secondary" $ SortColumn firmCountUsersSecondary , singletonMap "supervisors" $ SortColumn firmHasSupervisors @@ -482,10 +536,10 @@ mkFirmAllTable isAdmin uid = do -- , singletonMap "reroute-act" $ SortColumn firmCountActiveReroutes -- , singletonMap "reroute-all" $ SortColumn firmCountActiveReroutes' ] - dbtFilter = mconcat - [ single $ fltrCompanyNameNr queryAllCompany - , single ("company-number", FilterColumn $ E.mkExactFilterWithComma readMay (queryAllCompany >>> (E.^. CompanyAvsId))) - , single ("is-associate" , FilterColumn . E.mkExistsFilter $ \row (criterion :: Text) -> do + dbtFilter = Map.fromList + [ fltrCompanyNameNr queryAllCompany + , ("company-number", FilterColumn $ E.mkExactFilterWithComma readMay (queryAllCompany >>> (E.^. CompanyAvsId))) + , ("is-associate" , FilterColumn . E.mkExistsFilter $ \row (criterion :: Text) -> do (usr :& usrCmp) <- E.from $ E.table @User `E.innerJoin` E.table @UserCompany `E.on` (\(usr :& usrCmp) -> usr E.^. UserId E.==. usrCmp E.^. UserCompanyUser) @@ -496,7 +550,7 @@ mkFirmAllTable isAdmin uid = do ) ) -- THIS WAS WAY TOO SLOW: - -- , single ("is-supervisor" , FilterColumn . E.mkExistsFilter $ \row (criterion :: Text) -> do -- too slow + -- , ("is-supervisor" , FilterColumn . E.mkExistsFilter $ \row (criterion :: Text) -> do -- too slow -- (usr :& usrCmp) <- E.from $ E.table @User -- `E.leftJoin` E.table @UserCompany -- `E.on` (\(usr :& usrCmp) -> usr E.^. UserId E.=?. usrCmp E.?. UserCompanyUser) @@ -515,7 +569,7 @@ mkFirmAllTable isAdmin uid = do -- ) -- ) -- ) - -- , single ("is-supervisor" , FilterColumn . E.mkExistsFilter $ \row (criterion :: Text) -> do -- too slow + -- , ("is-supervisor" , FilterColumn . E.mkExistsFilter $ \row (criterion :: Text) -> do -- too slow -- usr <- E.from $ E.table @User -- E.where_ $ ((usr E.^. UserDisplayName `E.hasInfix` E.val criterion) -- E.||. (usr E.^. UserDisplayEmail `E.hasInfix` E.val (CI.mk criterion)) @@ -536,7 +590,7 @@ mkFirmAllTable isAdmin uid = do -- ) -- ) -- ) - -- , single ("is-supervisor" , FilterColumn . E.mkExistsFilter $ \row (criterion :: Text) -> do -- too slow + -- , ("is-supervisor" , FilterColumn . E.mkExistsFilter $ \row (criterion :: Text) -> do -- too slow -- usr <- E.from $ E.table @User -- E.where_ $ ((usr E.^. UserDisplayName `E.hasInfix` E.val criterion) -- E.||. (usr E.^. UserDisplayEmail `E.hasInfix` E.val (CI.mk criterion)) @@ -553,7 +607,7 @@ mkFirmAllTable isAdmin uid = do -- )) -- ) -- ) - -- , single ("is-supervisor", FilterColumn $ \row (getLast -> criterion) -> + -- , ("is-supervisor", FilterColumn $ \row (getLast -> criterion) -> -- case criterion of -- Nothing -> E.true -- (Just (crit::Text)) -> E.exists $ do @@ -573,35 +627,35 @@ mkFirmAllTable isAdmin uid = do -- )) -- ) -- ) - , single ("is-supervisor", mkFilterProjectedPost $ \(getLast -> criterion) dbr -> - case criterion of - Nothing -> return True :: DB Bool - (Just (crit::Text)) -> do - critFirms <- memcachedBy (Just . Right $ 3 * diffMinute) ("SVR:"<>crit) $ fmap (Set.fromList . fmap E.unValue) $ E.select $ E.distinct $ do - (usr :& cmp) <- E.from $ E.table @User `E.innerJoin` E.table @Company - `E.on` (\(usr :& cmp) -> E.exists (do - usrCmp <- E.from $ E.table @UserCompany - E.where_ $ usr E.^. UserId E.==. usrCmp E.^. UserCompanyUser - E.&&. usrCmp E.^. UserCompanySupervisor - E.&&. usrCmp E.^. UserCompanyCompany E.==. cmp E.^. CompanyId - ) E.||. E.exists (do - usrSpr <- E.from $ E.table @UserSupervisor - E.where_ $ usr E.^. UserId E.==. usrSpr E.^. UserSupervisorSupervisor - E.&&. E.exists (do - usrSub <- E.from $ E.table @UserCompany - E.where_ $ usrSub E.^. UserCompanyUser E.==. usrSpr E.^. UserSupervisorUser - E.&&. usrSub E.^. UserCompanyCompany E.==. cmp E.^. CompanyId - ) - )) - E.where_ $ (usr E.^. UserDisplayName `E.hasInfix` E.val crit ) - E.||. (usr E.^. UserDisplayEmail `E.hasInfix` E.val (CI.mk crit)) - E.||. (usr E.^. UserSurname `E.hasInfix` E.val crit ) - -- E.orderBy [E.asc $ cmp E.^. CompanyId] - return $ cmp E.^. CompanyId - let cid = dbr ^. resultAllCompanyEntity . _entityKey - return $ Set.member cid critFirms - ) - -- , single ("is-supervisor" , FilterColumn . E.mkExistsFilter $ \row (criterion :: Text) -> do -- too slow + -- , ("is-supervisor", mkFilterProjectedPost $ \(getLast -> criterion) dbr -> -- did not work as intended + -- case criterion of + -- Nothing -> return True :: DB Bool + -- (Just (crit::Text)) -> do + -- critFirms <- memcachedBy (Just . Right $ 3 * diffMinute) ("SVR:" <> crit) $ fmap (Set.fromList . fmap E.unValue) $ E.select $ E.distinct $ do + -- (usr :& cmp) <- E.from $ E.table @User `E.innerJoin` E.table @Company + -- `E.on` (\(usr :& cmp) -> E.exists (do + -- usrCmp <- E.from $ E.table @UserCompany + -- E.where_ $ usr E.^. UserId E.==. usrCmp E.^. UserCompanyUser + -- E.&&. usrCmp E.^. UserCompanySupervisor + -- E.&&. usrCmp E.^. UserCompanyCompany E.==. cmp E.^. CompanyId + -- ) E.||. E.exists (do + -- usrSpr <- E.from $ E.table @UserSupervisor + -- E.where_ $ usr E.^. UserId E.==. usrSpr E.^. UserSupervisorSupervisor + -- E.&&. E.exists (do + -- usrSub <- E.from $ E.table @UserCompany + -- E.where_ $ usrSub E.^. UserCompanyUser E.==. usrSpr E.^. UserSupervisorUser + -- E.&&. usrSub E.^. UserCompanyCompany E.==. cmp E.^. CompanyId + -- ) + -- )) + -- E.where_ $ (usr E.^. UserDisplayName `E.hasInfix` E.val crit ) + -- E.||. (usr E.^. UserDisplayEmail `E.hasInfix` E.val (CI.mk crit)) + -- E.||. (usr E.^. UserSurname `E.hasInfix` E.val crit ) + -- -- E.orderBy [E.asc $ cmp E.^. CompanyId] + -- return $ cmp E.^. CompanyId + -- let cid = dbr ^. resultAllCompanyEntity . _entityKey + -- return $ Set.member cid critFirms + -- ) + -- , ("is-supervisor" , FilterColumn . E.mkExistsFilter $ \row (criterion :: Text) -> do -- too slow -- (usr :& usrCmp) <- E.from $ E.table @User -- `E.leftJoin` E.table @UserCompany -- `E.on` (\(usr :& usrCmp) -> usr E.^. UserId E.=?. usrCmp E.?. UserCompanyUser) @@ -616,7 +670,16 @@ mkFirmAllTable isAdmin uid = do -- ) -- ) -- ) - , single ("is-default-supervisor" , FilterColumn . E.mkExistsFilter $ \row (criterion :: Text) -> do + , ("is-supervisor" , FilterColumn . E.mkExistsFilter $ \row (criterion :: Text) -> do + (usr :& _usrSpr :& usrCmp) <- E.from $ E.table @User + `E.innerJoin` E.table @UserSupervisor `E.on` (\(usr :& usrSpr ) -> usr E.^. UserId E.==. usrSpr E.^. UserSupervisorSupervisor) + `E.innerJoin` E.table @UserCompany `E.on` (\(_ :& usrSpr :& usrCmp) -> usrCmp E.^. UserCompanyUser E.==. usrSpr E.^. UserSupervisorUser) + E.where_ $ ((usr E.^. UserDisplayName `E.hasInfix` E.val criterion) + E.||. (usr E.^. UserDisplayEmail `E.hasInfix` E.val (CI.mk criterion)) + E.||. (usr E.^. UserSurname `E.hasInfix` E.val criterion) + ) E.&&. usrCmp E.^. UserCompanyCompany E.==. queryAllCompany row E.^. CompanyId + ) + , ("is-default-supervisor" , FilterColumn . E.mkExistsFilter $ \row (criterion :: Text) -> do (usr :& usrCmp) <- E.from $ E.table @User `E.innerJoin` E.table @UserCompany `E.on` (\(usr :& usrCmp) -> usr E.^. UserId E.==. usrCmp E.^. UserCompanyUser) @@ -626,7 +689,7 @@ mkFirmAllTable isAdmin uid = do ) E.&&. usrCmp E.^. UserCompanySupervisor E.&&. usrCmp E.^. UserCompanyCompany E.==. queryAllCompany row E.^. CompanyId ) - , single ("foreign-supervisor", FilterColumn $ \row (getLast -> criterion) -> + , ("foreign-supervisor", FilterColumn $ \row (getLast -> criterion) -> -- let checkSuper = do -- expensive -- usrSpr <- E.from $ E.table @UserSupervisor -- E.where_ $ E.notExists (do @@ -655,8 +718,8 @@ mkFirmAllTable isAdmin uid = do Just True -> E.exists checkSuper Just False -> E.notExists checkSuper ) - , single ("company-postal", FilterColumn $ E.mkExactFilterLast $ views (to queryAllCompany) (E.isJust . (E.^. CompanyPostAddress))) - , single ("qualification" , FilterColumn . E.mkExistsFilter $ \row (CI.mk -> criterion :: CI Text) -> do + , ("company-postal", FilterColumn $ E.mkExactFilterLast $ views (to queryAllCompany) (E.isJust . (E.^. CompanyPostAddress))) + , ("qualification" , FilterColumn . E.mkExistsFilter $ \row (CI.mk -> criterion :: CI Text) -> do (usrCmp :& usrQual :& qual) <- E.from $ E.table @UserCompany `E.innerJoin` E.table @QualificationUser `E.on` (\(usrCmp :& usrQual) -> usrCmp E.^. UserCompanyUser E.==. usrQual E.^. QualificationUserUser) @@ -666,15 +729,14 @@ mkFirmAllTable isAdmin uid = do E.&&. qual E.^. QualificationShorthand E.==. E.val criterion E.&&. validQualification now usrQual ) - , single ("company-address", FilterColumn $ E.mkContainsFilterWithCommaPlus id $ views (to queryAllCompany) ((E.->>. "markup-input").(E.^. CompanyPostAddress)) - ) + , ("company-address", FilterColumn $ E.mkContainsFilterWithCommaPlus id $ views (to queryAllCompany) ((E.->>. "markup-input").(E.^. CompanyPostAddress))) ] dbtFilterUI mPrev = mconcat [ fltrCompanyNameUI mPrev , prismAForm (singletonFilter "company-number") mPrev $ aopt textField (fslI MsgTableCompanyNo) , prismAForm (singletonFilter "is-associate") mPrev $ aopt textField (fslI MsgTableCompanyUser) -- , prismAForm (singletonFilter "is-supervisor0") mPrev $ aopt textField (fslI MsgTableSupervisor) - , prismAForm (singletonFilter "is-supervisor") mPrev $ aopt textField (fslI MsgTableSupervisor) + , prismAForm (singletonFilter "is-supervisor") mPrev $ aopt textField (fslI MsgTableSupervisorActive) , prismAForm (singletonFilter "is-default-supervisor") mPrev $ aopt textField (fslI MsgFirmSuperDefault) , prismAForm (singletonFilter "foreign-supervisor" . maybePrism _PathPiece) mPrev $ aopt (boolField . Just $ SomeMessage MsgBoolIrrelevant) (fslI MsgFilterForeignSupervisor) , prismAForm (singletonFilter "company-postal" . maybePrism _PathPiece) mPrev $ aopt (boolField . Just $ SomeMessage MsgBoolIrrelevant) (fslI MsgFilterFirmExtern & setTooltip MsgFilterFirmExternTooltip) @@ -761,6 +823,7 @@ data FirmUserActionData = FirmUserActNotifyData { firmUserActPostalAddr :: Maybe StoredMarkup , firmUserActUseCompanyPostal :: Maybe Bool , firmUserActPostalPref :: Maybe Bool + , firmUserActPinPassword :: Bool } | FirmUserActRemoveData { firmUserActRemoveSupers :: Bool @@ -799,8 +862,8 @@ instance HasUser UserCompanyTableData where hasUser = resultUserUser . _entityVal -mkFirmUserTable :: Bool -> CompanyId -> DB (FormResult (FirmUserActionData, Set UserId), Widget) -mkFirmUserTable isAdmin cid = do +mkFirmUserTable :: Bool -> Entity Company -> DB (FormResult (FirmUserActionData, Set UserId), Widget) +mkFirmUserTable isAdmin Entity{entityKey=cid, entityVal=compData} = do mr <- getMessageRender let reasonSuperior = Just $ tshow SupervisorReasonAvsSuperior @@ -848,9 +911,9 @@ mkFirmUserTable isAdmin cid = do , colUserNameModalHdr MsgTableCompanyUser ForProfileDataR , guardMonoid isAdmin $ sortable (Just "matriculation") (i18nCell MsgTableMatrikelNr) $ \(view resultUserUser -> entUsr ) -> cellHasMatrikelnummerLinkedAdmin entUsr , sortable (Just "personal-number") (i18nCell MsgCompanyPersonalNumber) $ \(view $ resultUserUser . _userCompanyPersonalNumber -> t) -> foldMap textCell t - , sortable (Just "supervisors") (i18nCell MsgTableCompanyNrSupers ) $ \(view resultUserCompanySupervisors -> nr) -> wgtCell $ word2widget nr - , sortable (Just "reroutes") (i18nCell MsgTableCompanyNrRerouteActive) $ \(view resultUserCompanyReroutes -> nr) -> wgtCell $ word2widget nr - , sortable (Just "postal-pref") (i18nCell MsgPrefersPostal) $ \(view $ resultUserUser . _userPrefersPostal -> b) -> iconFixedCell $ iconLetterOrEmail b + , sortable (Just "supervisors") (i18nCell MsgTableCompanyNrSupers ) $ \(view resultUserCompanySupervisors -> nr) -> wgtCell $ word2widget nr + , sortable (Just "reroutes") (i18nCell MsgTableCompanyNrRerouteActive) $ \(view resultUserCompanyReroutes -> nr) -> wgtCell $ word2widget nr + , colUserLetterEmailPin , sortable Nothing (i18nCell MsgCompanyUserUseCompanyAddress) $ \row -> let noUsrAddr = isNothing $ row ^. resultUserUser . _userPostAddress useCompA = row ^. resultUserUserCompany . _entityVal . _userCompanyUseCompanyAddress @@ -863,20 +926,21 @@ mkFirmUserTable isAdmin cid = do in numCell prio <> spacerCell <> ifIconCell isPrime IconTop , sortable Nothing (i18nCell MsgTableUserEdit) $ \(view resultUserUser -> entUsr) -> cellEditUserModal entUsr ] - dbtSorting = mconcat - [ single $ sortUserNameLink queryUserUser - , single $ sortUserEmail queryUserUser - , singletonMap "postal-pref" $ SortColumn $ queryUserUser >>> (E.^. UserPrefersPostal) - , singletonMap "matriculation" $ SortColumn $ queryUserUser >>> (E.^. UserMatrikelnummer) - , singletonMap "personal-number" $ SortColumn $ queryUserUser >>> (E.^. UserCompanyPersonalNumber) - , singletonMap "supervisors" $ SortColumn $ queryUserUserCompany >>> firmCountUserSupervisors - , singletonMap "reroutes" $ SortColumn $ queryUserUserCompany >>> firmCountUserSupervisorsReroute - , singletonMap "usr-reason" $ SortColumn $ queryUserUserCompany >>> (E.^. UserCompanyReason) - , singletonMap "priority" $ SortColumn $ queryUserUserCompany >>> (E.^. UserCompanyPriority) + + dbtSorting = Map.fromList + [ sortUserNameLink queryUserUser + , sortUserEmail queryUserUser + , sortUserLetterEmailPin queryUserUser + , ("matriculation" , SortColumn $ queryUserUser >>> (E.^. UserMatrikelnummer) ) + , ("personal-number" , SortColumn $ queryUserUser >>> (E.^. UserCompanyPersonalNumber)) + , ("supervisors" , SortColumn $ queryUserUserCompany >>> firmCountUserSupervisors ) + , ("reroutes" , SortColumn $ queryUserUserCompany >>> firmCountUserSupervisorsReroute ) + , ("usr-reason" , SortColumn $ queryUserUserCompany >>> (E.^. UserCompanyReason) ) + , ("priority" , SortColumn $ queryUserUserCompany >>> (E.^. UserCompanyPriority) ) ] - dbtFilter = mconcat - [ single $ fltrUserNameEmail queryUserUser - , singletonMap "has-supervisor" $ FilterColumn $ \row (getLast -> criterion) -> + dbtFilter = Map.fromList + [ fltrUserNameEmail queryUserUser + , ("has-supervisor", FilterColumn $ \row (getLast -> criterion) -> let checkSuper = do usrSpr <- E.from $ E.table @UserSupervisor E.where_ $ usrSpr E.^. UserSupervisorUser E.==. queryUserUser row E.^. UserId @@ -884,7 +948,8 @@ mkFirmUserTable isAdmin cid = do Nothing -> E.true Just True -> E.exists checkSuper Just False -> E.notExists checkSuper - , singletonMap "has-company-supervisor" $ FilterColumn $ \row (getLast -> criterion) -> + ) + , ("has-company-supervisor", FilterColumn $ \row (getLast -> criterion) -> let checkSuper = do usrSpr <- E.from $ E.table @UserSupervisor E.where_ $ usrSpr E.^. UserSupervisorUser E.==. queryUserUser row E.^. UserId @@ -897,7 +962,8 @@ mkFirmUserTable isAdmin cid = do Nothing -> E.true Just True -> E.exists checkSuper Just False -> E.notExists checkSuper - , singletonMap "has-foreign-supervisor" $ FilterColumn $ \row (getLast -> criterion) -> + ) + , ("has-foreign-supervisor", FilterColumn $ \row (getLast -> criterion) -> let checkSuper = do usrSpr <- E.from $ E.table @UserSupervisor E.where_ $ usrSpr E.^. UserSupervisorUser E.==. queryUserUser row E.^. UserId @@ -910,7 +976,8 @@ mkFirmUserTable isAdmin cid = do Nothing -> E.true Just True -> E.exists checkSuper Just False -> E.notExists checkSuper - , singletonMap "supervisor-is" $ FilterColumn $ \row (getLast -> criterion) -> + ) + , ("supervisor-is", FilterColumn $ \row (getLast -> criterion) -> case criterion of Just uid -> do -- uid <- decryptUser uuid @@ -919,7 +986,8 @@ mkFirmUserTable isAdmin cid = do E.where_ $ usrSpr E.^. UserSupervisorUser E.==. queryUserUser row E.^. UserId E.&&. usrSpr E.^. UserSupervisorSupervisor E.==. E.val uid _otherwise -> E.true - , singletonMap "supervisors-are" $ FilterColumn $ \row criteria -> + ) + , ("supervisors-are", FilterColumn $ \row criteria -> case criteria of _ | Set.null criteria -> E.true | otherwise -> do @@ -928,7 +996,8 @@ mkFirmUserTable isAdmin cid = do usrSpr <- E.from $ E.table @UserSupervisor E.where_ $ usrSpr E.^. UserSupervisorUser E.==. queryUserUser row E.^. UserId E.&&. usrSpr E.^. UserSupervisorSupervisor `E.in_` E.vals criteria - , singletonMap "is-primary-company" $ FilterColumn $ \row (getLast -> criterion) -> + ) + , ("is-primary-company", FilterColumn $ \row (getLast -> criterion) -> let checkPrimary = do other <- E.from $ E.table @UserCompany E.where_ $ other E.^. UserCompanyUser E.==. queryUserUserCompany row E.^. UserCompanyUser @@ -937,6 +1006,7 @@ mkFirmUserTable isAdmin cid = do Nothing -> E.true Just False -> E.exists checkPrimary Just True -> E.notExists checkPrimary + ) ] -- superField = selectField $ ???? dbtFilterUI mPrev = mconcat @@ -977,12 +1047,14 @@ mkFirmUserTable isAdmin cid = do , singletonMap FirmUserActMkSuper $ FirmUserActMkSuperData <$> aopt checkBoxField (fslI MsgTableIsDefaultReroute) (Just $ Just True) , singletonMap FirmUserActChangeContact $ FirmUserActChangeContactData - <$> aopt htmlField (fslI MsgPostAddress & setTooltip (SomeMessages [SomeMessage MsgPostAddressTip, SomeMessage MsgUtilEmptyNoChangeTip])) Nothing + <$> aopt htmlField (fslI MsgPostAddress & setTooltip (SomeMsgs [SomeMessage MsgPostAddressTip, SomeMessage MsgUtilEmptyNoChangeTip])) Nothing <*> aopt boolField' (fslI MsgCompanyUserUseCompanyAddress & setTooltip MsgCompanyUserUseCompanyAddressTip) Nothing <*> aopt postalEmailField (fslI MsgFormFieldPostal & setTooltip MsgFormFieldPostalTip) Nothing + <*> if companyPinPassword compData then pure False else + areq boolField' (fslI MsgFormFieldPinPassRemove) Nothing , singletonMap FirmUserActChangeDetails $ FirmUserActChangeDetailsData <$> aopt intField (fslI MsgCompanyUserPriority & setTooltip MsgCompanyUserPriorityTip) Nothing - <*> aopt (textField & cfStrip & addDatalist userReasons) (fslI MsgUserCompanyReason & setTooltip (SomeMessages [SomeMessage MsgUserCompanyReasonTooltip, SomeMessage MsgNullDeletes])) Nothing + <*> aopt (textField & cfStrip & addDatalist userReasons) (fslI MsgUserCompanyReason & setTooltip (SomeMsgs [SomeMessage MsgUserCompanyReasonTooltip, SomeMessage MsgNullDeletes])) Nothing , singletonMap FirmUserActRemove $ FirmUserActRemoveData <$> areq boolField' (fslI MsgFirmActRemoveSupers) (Just True) ] @@ -1031,8 +1103,8 @@ postFirmUsersR fsh = do , E.Value nrCompanyEmployeeRerPost , E.Value nrCompanyDefaultReroutes , E.Value nrCompanyActiveReroutes - ) , (fusrRes, fusrTable)) <- runDB $ (,) - <$> fromMaybeM notFound (E.selectOne $ do + ) , (fusrRes, fusrTable)) <- runDB $ do + compEnt <- fromMaybeM notFound (E.selectOne $ do cmpy <- E.from $ E.table @Company E.where_ $ cmpy E.^. CompanyId E.==. E.val cid return ( cmpy @@ -1049,7 +1121,8 @@ postFirmUsersR fsh = do -- usr <- E.from $ E.table @User -- E.where_ $ E.exists $ firmQuerySupervisedBy cmpyId Nothing usr -- return usr - <*> mkFirmUserTable isAdmin cid + tbl <- mkFirmUserTable isAdmin (compEnt ^. _1) + return (compEnt, tbl) let resetSupers :: Maybe Bool -> NonEmpty UserId -> DB Int64 resetSupers Nothing _ = return 0 @@ -1089,23 +1162,22 @@ postFirmUsersR fsh = do nrUpd <- runDB $ updateWhereCount [UserCompanyCompany ==. cid, UserCompanyUser <-. uids] [UserCompanySupervisor =. True, UserCompanySupervisorReroute =. (firmUserActMkSuperReroute == Just True)] addMessageI Success $ MsgFirmActAddSupersSet nrUpd Nothing reloadKeepGetParams $ FirmUsersR fsh -- reload to reflect changes - (FirmUserActChangeDetailsData{..}, Set.toList -> uids) -> do + (FirmUserActChangeDetailsData{..}, uids) -> do let upReason = case canonical firmUserActDetailReason of Nothing -> Nothing Just "NULL" -> Just $ UserCompanyReason =. Nothing other -> Just $ UserCompanyReason =. other - nrUpd <- runDB $ updateWhereCount [UserCompanyCompany ==. cid, UserCompanyUser <-. uids] $ catMaybes [upReason, (UserCompanyPriority =.) <$> firmUserActDetailPriority] - let total = fromIntegral $ length uids - allok = bool Warning Success $ nrUpd == total - addMessageI allok $ MsgFirmUserActChangeDetailsResult nrUpd total + nrUpd <- runDB $ updateWhereCount [UserCompanyCompany ==. cid, UserCompanyUser <-. Set.toList uids] $ catMaybes [upReason, (UserCompanyPriority =.) <$> firmUserActDetailPriority] + addMessageOutOfI MsgFirmUserActChangeDetailsResult nrUpd $ Set.size uids reloadKeepGetParams $ FirmUsersR fsh -- reload to reflect changes (FirmUserActChangeContactData{..}, Set.toList -> uids) | firmUserActUseCompanyPostal == Just True, isJust firmUserActPostalAddr -> addMessageI Error MsgCompanyUserUseCompanyPostalError | otherwise -> do - let changes = catMaybes - [ toMaybe (firmUserActUseCompanyPostal == Just True) (UserPostAddress =. Nothing) -- precondition ensures that only one update applies for UserPostAddress - , (UserPostAddress =.) . Just <$> canonical firmUserActPostalAddr -- note that Nothing means no change and not delete address! + let changes = + bcons (firmUserActUseCompanyPostal == Just True) (UserPostAddress =. Nothing) $ -- precondition ensures that only one update applies for UserPostAddress + bcons firmUserActPinPassword (UserPinPassword =. Nothing) $ catMaybes + [ (UserPostAddress =.) . Just <$> canonical firmUserActPostalAddr -- note that Nothing means no change and not delete address! , (UserPrefersPostal =.) <$> firmUserActPostalPref ] nrChanged <- runDB $ do @@ -1114,9 +1186,7 @@ postFirmUsersR fsh = do Just x -> updateWhereCount [UserCompanyCompany ==. cid, UserCompanyUser <-. uids] [UserCompanyUseCompanyAddress =. x] Nothing -> return 0 return $ max nrUsrChange nrUseComp - let total = fromIntegral $ length uids - allok = bool Warning Success $ nrChanged == total - addMessageI allok $ MsgFirmUserActChangeResult nrChanged total + addMessageOutOfI MsgFirmUserActChangeResult nrChanged $ length uids reloadKeepGetParams $ FirmUsersR fsh -- reload to reflect changes (FirmUserActRemoveData{..}, Set.toList -> uids) -> do let optRemove = if firmUserActRemoveSupers then id else const $ return 0 @@ -1129,7 +1199,7 @@ postFirmUsersR fsh = do addMessageI allok $ someMessages [MsgFirmUserActRemoveResult nrUc, MsgFirmRemoveSupervision nrSuper nrSubs] reloadKeepGetParams $ FirmUsersR fsh -- reload to reflect changes - formFirmAction <- runFirmActionFormPost cid (FirmUsersR fsh) isAdmin [FirmActNotify, FirmActResetSupervision, FirmActAddSupervisors, FirmActChangeContactFirm, FirmActChangeContactUser] + formFirmAction <- runFirmActionFormPost cid (FirmUsersR fsh) isAdmin [FirmActNotify, FirmActResetSupervision, FirmActAddAssociates, FirmActChangeContactFirm, FirmActChangeContactUser] siteLayout (citext2widget companyName) $ do setTitle $ toHtml $ CI.original companyShorthand <> "-" <> tshow companyAvsId @@ -1239,7 +1309,7 @@ mkFirmSuperTable isAdmin cid = do , sortable (Just "user-company") (i18nCell MsgTableCompanies) $ \( view resultSuperCompanies -> cmps) -> intercalate semicolonCell [companyCell cmpShort cmpName isSuper | (E.Value cmpName, E.Value cmpShort, E.Value isSuper) <- cmps] , sortable (Just "personal-number") (i18nCell MsgCompanyPersonalNumber) $ \(view $ resultSuperUser . _userCompanyPersonalNumber -> t) -> foldMap textCell t - , sortable (Just "postal-pref") (i18nCell MsgPrefersPostal) $ \(view $ resultSuperUser . _userPrefersPostal -> b) -> iconFixedCell $ iconLetterOrEmail b + , colUserLetterEmailPin , colUserEmail , sortable (Just "supervised") (i18nCell MsgTableCompanyNrEmpSupervised) $ \(view resultSuperCompanySupervised -> nr) -> wgtCell $ word2widget nr , sortable (Just "rerouted") (i18nCell MsgTableCompanyNrEmpRerouted ) $ \(view resultSuperCompanyReroutes -> nr) -> wgtCell $ word2widget nr @@ -1251,31 +1321,32 @@ mkFirmSuperTable isAdmin cid = do , sortable (Just "def-reroute") (i18nCell MsgTableIsDefaultReroute) $ \(view resultSuperCompanyDefaultReroute -> mb) -> tickmarkCell (mb == Just True) , sortable Nothing (i18nCell MsgTableUserEdit) $ \(view resultSuperUser -> entUsr) -> cellEditUserModal entUsr ] - dbtSorting = mconcat - [ single $ sortUserNameLink querySuperUser - , single $ sortUserEmail querySuperUser - , singletonMap "matriculation" $ SortColumn $ querySuperUser >>> (E.^. UserMatrikelnummer) - , singletonMap "personal-number" $ SortColumn $ querySuperUser >>> (E.^. UserCompanyPersonalNumber) - , singletonMap "postal-pref" $ SortColumn $ querySuperUser >>> (E.^. UserPrefersPostal) - , singletonMap "supervised" $ SortColumn $ querySuperUser >>> firmCountForSupervisor cid Nothing - , singletonMap "rerouted" $ SortColumn $ querySuperUser >>> firmCountForSupervisor cid (Just (E.^. UserSupervisorRerouteNotifications)) - , singletonMap "user-company" $ SortColumn (\row -> E.subSelect $ do + dbtSorting = Map.fromList + [ sortUserNameLink querySuperUser + , sortUserEmail querySuperUser + , sortUserLetterEmailPin querySuperUser + , ("matriculation" , SortColumn $ querySuperUser >>> (E.^. UserMatrikelnummer)) + , ("personal-number" , SortColumn $ querySuperUser >>> (E.^. UserCompanyPersonalNumber)) + , ("supervised" , SortColumn $ querySuperUser >>> firmCountForSupervisor cid Nothing) + , ("rerouted" , SortColumn $ querySuperUser >>> firmCountForSupervisor cid (Just (E.^. UserSupervisorRerouteNotifications))) + , ("user-company" , SortColumn (\row -> E.subSelect $ do (cmp :& usrCmp) <- E.from $ E.table @Company `E.innerJoin` E.table @UserCompany `E.on` (\(cmp :& usrCmp) -> cmp E.^. CompanyId E.==. usrCmp E.^. UserCompanyCompany) E.where_ $ usrCmp E.^. UserCompanyUser E.==. querySuperUser row E.^. UserId E.orderBy [E.asc $ cmp E.^. CompanyName] return (cmp E.^. CompanyName) - ) - , singletonMap "def-super" $ SortColumn $ querySuperUserCompany >>> (E.?. UserCompanySupervisor) - , singletonMap "def-reroute" $ SortColumn $ querySuperUserCompany >>> (E.?. UserCompanySupervisorReroute) + )) + , ("def-super" , SortColumn $ querySuperUserCompany >>> (E.?. UserCompanySupervisor)) + , ("def-reroute" , SortColumn $ querySuperUserCompany >>> (E.?. UserCompanySupervisorReroute)) ] - dbtFilter = mconcat - [ single $ fltrUserNameEmail querySuperUser - , singletonMap "is-foreign-supervisor" $ FilterColumn $ \(querySuperUserCompany -> suc) (getLast -> criterion) -> + dbtFilter = Map.fromList + [ fltrUserNameEmail querySuperUser + , ("is-foreign-supervisor", FilterColumn $ \(querySuperUserCompany -> suc) (getLast -> criterion) -> case criterion of Nothing -> E.true Just True -> E.isNothing $ suc E.?. UserCompanyUser Just False -> E.isJust $ suc E.?. UserCompanyUser - , singletonMap "super-relation-foreign" $ FilterColumn $ \row (getLast -> criterion) -> + ) + , ("super-relation-foreign", FilterColumn $ \row (getLast -> criterion) -> let checkSuper = do usrSpr <- E.from $ E.table @UserSupervisor E.where_ $ usrSpr E.^. UserSupervisorSupervisor E.==. querySuperUser row E.^. UserId @@ -1288,6 +1359,7 @@ mkFirmSuperTable isAdmin cid = do Nothing -> E.true Just True -> E.exists checkSuper Just False -> E.notExists checkSuper + ) ] dbtFilterUI mPrev = mconcat [ fltrUserNameEmailHdrUI MsgTableSupervisor mPrev diff --git a/src/Handler/Firm/Supervision.hs b/src/Handler/Firm/Supervision.hs new file mode 100644 index 000000000..3d91ba641 --- /dev/null +++ b/src/Handler/Firm/Supervision.hs @@ -0,0 +1,234 @@ +-- SPDX-FileCopyrightText: 2023-2025 Steffen Jost +-- +-- SPDX-License-Identifier: AGPL-3.0-or-later + +{-# LANGUAGE TypeApplications #-} + +module Handler.Firm.Supervision + ( getFirmsSupervisionR , postFirmsSupervisionR + ) + where + +import Import + +-- import Jobs +import Utils.Company +import Handler.Utils +import Handler.Utils.Company + + +import qualified Data.Set as Set +import qualified Data.Map as Map +-- import qualified Data.Csv as Csv +-- import qualified Data.Text as T +import qualified Data.CaseInsensitive as CI +-- import qualified Data.Conduit.List as C +-- import Database.Persist.Sql (deleteWhereCount, updateWhereCount) +import Database.Persist.Postgresql +import Database.Esqueleto.Experimental ((:&)(..)) +import qualified Database.Esqueleto.Experimental as E -- needs TypeApplications Lang-Pragma +import qualified Database.Esqueleto.Legacy as EL (on) -- needed for legacy join expected by dbTable +-- import qualified Database.Esqueleto.PostgreSQL as E +import qualified Database.Esqueleto.Utils as E +-- import Database.Esqueleto.Utils.TH + + +-- decryptUser :: (MonadHandler m, HandlerSite m ~ UniWorX) => CryptoUUIDUser -> m UserId +-- decryptUser = decrypt + +-- encryptUser :: (MonadHandler m, HandlerSite m ~ UniWorX) => UserId -> m CryptoUUIDUser +-- encryptUser = encrypt + + +----------------------- +-- Supervision Sanity + +data ActSupervision = ASChangeCompany | ASRemoveAssociation + deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic) + deriving anyclass (Universe, Finite) + +nullaryPathPiece ''ActSupervision $ camelToPathPiece' 2 +embedRenderMessage ''UniWorX ''ActSupervision id + +data ActSupervisionData + = ASChangeCompanyData { asTblCompany :: Maybe CompanyShorthand, asTblReason :: Maybe Text } + | ASRemoveAssociationData + deriving (Eq, Ord, Read, Show, Generic) + +data SupervisionViolation = SupervisionViolationEither | SupervisionViolationClient | SupervisionViolationSupervisor | SupervisionViolationBoth + deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic) + deriving anyclass (Universe, Finite) + +nullaryPathPiece ''SupervisionViolation $ camelToPathPiece' 1 +embedRenderMessage ''UniWorX ''SupervisionViolation id + +supervisionViolationField :: (MonadHandler m, HandlerSite m ~ UniWorX) => Field m SupervisionViolation +-- supervisionViolationField = radioGroupField (Just $ SomeMessage MsgSupervisionViolationEither) $ optionsFinite +supervisionViolationField = radioGroupField Nothing $ optionsFinite + +type TblSupervisionData = DBRow (Entity UserSupervisor, Entity User, Entity User) + +mkSupervisionTable :: DB (FormResult (ActSupervisionData, Set UserSupervisorId), Widget) +mkSupervisionTable = over _1 postprocess <$> dbTable validator DBTable{..} + where + dbtIdent = "sanity-super" :: Text + dbtStyle = def { dbsFilterLayout = defaultDBSFilterLayout} + + queryRelation :: (E.SqlExpr (Entity UserSupervisor) `E.InnerJoin` E.SqlExpr (Entity User) `E.InnerJoin` E.SqlExpr (Entity User)) -> E.SqlExpr (Entity UserSupervisor) + queryRelation = $(E.sqlIJproj 3 1) + querySupervisor :: (E.SqlExpr (Entity UserSupervisor) `E.InnerJoin` E.SqlExpr (Entity User) `E.InnerJoin` E.SqlExpr (Entity User)) -> E.SqlExpr (Entity User) + querySupervisor = $(E.sqlIJproj 3 2) + queryClient :: (E.SqlExpr (Entity UserSupervisor) `E.InnerJoin` E.SqlExpr (Entity User) `E.InnerJoin` E.SqlExpr (Entity User)) -> E.SqlExpr (Entity User) + queryClient = $(E.sqlIJproj 3 3) + + resultRelation :: Lens' TblSupervisionData (Entity UserSupervisor) + resultRelation = _dbrOutput . _1 + resultSupervisor :: Lens' TblSupervisionData (Entity User) + resultSupervisor = _dbrOutput . _2 + resultClient :: Lens' TblSupervisionData (Entity User) + resultClient = _dbrOutput . _3 + + dbtSQLQuery (uus `E.InnerJoin` spr `E.InnerJoin` sub) = do + EL.on $ uus E.^. UserSupervisorSupervisor E.==. spr E.^. UserId + EL.on $ uus E.^. UserSupervisorUser E.==. sub E.^. UserId + E.where_ $ E.isJust (uus E.^. UserSupervisorCompany) + return (uus, spr, sub) + dbtRowKey = queryRelation >>> (E.^. UserSupervisorId) + dbtProj = dbtProjId + dbtColonnade = formColonnade $ mconcat + [ dbSelect (applying _2) id (return . view (resultRelation . _entityKey)) + , sortable (Just "reroute") (i18nCell MsgTableRerouteActive) $ \(view $ resultRelation . _entityVal . _userSupervisorRerouteNotifications -> b) -> ifIconCell b IconReroute + , sortable (Just "reason") (i18nCell MsgUserSupervisorReason) $ \(view $ resultRelation . _entityVal . _userSupervisorReason -> r) -> maybeCell r textCell + , sortable (Just "rel-comp") (i18nCell MsgUserSupervisorCompany) $ \(view $ resultRelation . _entityVal . _userSupervisorCompany -> c) -> maybeCell c companyIdCell\ + , sortable (Just "supervisor") (i18nCell MsgTableSupervisor) $ \(view $ resultSupervisor -> u) -> cellHasUserModal ForProfileDataR u + , sortable (Just "super-comp") (i18nCell MsgTableCompanies) $ \(view $ resultSupervisor . _entityKey -> uid) -> flip (set' cellContents) mempty $ liftHandler $ runDB $ -- why does sqlCell not work here? Mismatch "YesodDB UniWorX" and "RWST (Maybe (Env,FileEnv), UniWorX, [Lang]) Enctype Ints (HandlerFor UniWorX" + maybeMonoid <$> wgtCompanies True uid + , sortable (Just "client") (i18nCell MsgTableSupervisee) $ \(view $ resultClient -> u) -> cellHasUserModal ForProfileDataR u + , sortable (Just "client-comp") (i18nCell MsgTableCompanies) $ \(view $ resultClient . _entityKey -> uid) -> flip (set' cellContents) mempty $ liftHandler $ runDB $ -- why does sqlCell not work here? Mismatch "YesodDB UniWorX" and "RWST (Maybe (Env,FileEnv), UniWorX, [Lang]) Enctype Ints (HandlerFor UniWorX" + maybeMonoid <$> wgtCompanies True uid + ] + validator = def & defaultSorting [SortAscBy "rel-comp", SortAscBy "supervisor", SortAscBy "client"] + & defaultFilter (singletonMap "violation" [toPathPiece SupervisionViolationEither]) + dbtSorting = Map.fromList + [ ("reason" , SortColumn $ queryRelation >>> (E.^. UserSupervisorReason)) + , ("rel-comp" , SortColumn $ queryRelation >>> (E.^. UserSupervisorCompany)) + , ("reroute" , SortColumn $ queryRelation >>> (E.^. UserSupervisorRerouteNotifications)) + , ("supervisor" , SortColumn $ querySupervisor >>> (E.^. UserDisplayName)) + , ("client" , SortColumn $ queryClient >>> (E.^. UserDisplayName)) + , ("super-comp" , SortColumn (\row -> E.subSelect $ do + (cmp :& usrCmp) <- E.from $ E.table @Company `E.innerJoin` E.table @UserCompany `E.on` (\(cmp :& usrCmp) -> cmp E.^. CompanyId E.==. usrCmp E.^. UserCompanyCompany) + E.where_ $ usrCmp E.^. UserCompanyUser E.==. querySupervisor row E.^. UserId + E.orderBy [E.asc $ cmp E.^. CompanyName] + return (cmp E.^. CompanyName) + )) + , ("client-comp" , SortColumn (\row -> E.subSelect $ do + (cmp :& usrCmp) <- E.from $ E.table @Company `E.innerJoin` E.table @UserCompany `E.on` (\(cmp :& usrCmp) -> cmp E.^. CompanyId E.==. usrCmp E.^. UserCompanyCompany) + E.where_ $ usrCmp E.^. UserCompanyUser E.==. queryClient row E.^. UserId + E.orderBy [E.asc $ cmp E.^. CompanyName] + return (cmp E.^. CompanyName) + )) + ] + + dbtFilter = Map.fromList + [ ("violation", FilterColumn $ \(queryRelation -> us) (getLast -> criterion) -> case criterion of + Just SupervisionViolationSupervisor -> missingCompanySupervisor us + Just SupervisionViolationClient -> missingCompanyClient us + Just SupervisionViolationBoth -> missingCompanySupervisor us E.&&. missingCompanyClient us + _ -> missingCompanySupervisor us E.||. missingCompanyClient us + ) + , ("rel-company", FilterColumn $ E.mkExistsFilter $ \(queryRelation -> us) (commaSeparatedText -> criteria) -> do + let numCrits = setMapMaybe readMay criteria + cmp <- E.from $ E.table @Company + E.where_ $ cmp E.^. CompanyId E.=?. us E.^. UserSupervisorCompany + E.&&. E.or ( + bcons (notNull numCrits) + (E.mkExactFilter (E.^. CompanyAvsId) cmp numCrits) + [E.mkContainsFilterWith CI.mk (E.^. CompanyName) cmp criteria + ,E.mkContainsFilterWith CI.mk (E.^. CompanyShorthand) cmp criteria + ] + ) + ) + , ("supervisor-company", fltrCompanyShortNrUsr (querySupervisor >>> (E.^. UserId))) + , ("client-company" , fltrCompanyShortNrUsr (queryClient >>> (E.^. UserId))) + , ("supervisor", FilterColumn . E.mkContainsFilter $ querySupervisor >>> (E.^. UserDisplayName)) + , ("client" , FilterColumn . E.mkContainsFilter $ queryClient >>> (E.^. UserDisplayName)) + ] + dbtFilterUI mPrev = mconcat -- Maybe (Map FilterKey [Text]) -> AForm DB (Map FilterKey [Text]) + [ prismAForm (singletonFilter "violation" . maybePrism _PathPiece) mPrev $ aopt supervisionViolationField (fslI MsgSupervisionViolationChoice) + , prismAForm (singletonFilter "rel-company") mPrev $ aopt textField (fslI MsgUserSupervisorCompany & setTooltip MsgTableFilterCommaNameNr) + , prismAForm (singletonFilter "supervisor") mPrev $ aopt textField (fslI MsgTableSupervisor) + , fltrCompanyNameNrUsrHdrUI "supervisor-company" (someMessages [MsgTableSupervisor, MsgTableCompanyShort]) mPrev + , prismAForm (singletonFilter "client") mPrev $ aopt textField (fslI MsgTableSupervisee) + , fltrCompanyNameNrUsrHdrUI "client-company" (someMessages [MsgTableSupervisee, MsgTableCompanyShort]) mPrev + ] + + suggestionSupervision :: Handler (OptionList Text) + suggestionSupervision = mkOptionListText <$> runDB + (E.select $ do + us <- E.from $ E.table @UserSupervisor + let reason = us E.^. UserSupervisorReason + countRows' :: E.SqlExpr (E.Value Int64) = E.countRows + E.where_ $ E.isJust reason + E.groupBy reason + E.orderBy [E.desc countRows'] + E.limit 9 + pure $ E.coalesceDefault [reason] (E.val "") + ) + dbtParams = DBParamsForm + { dbParamsFormMethod = POST + , dbParamsFormAction = Nothing + , dbParamsFormAttrs = [] + , dbParamsFormSubmit = FormSubmit + , dbParamsFormAdditional = + let acts :: Map ActSupervision (AForm Handler ActSupervisionData) + acts = mconcat + [ singletonMap ASChangeCompany $ ASChangeCompanyData + <$> aopt companyField (fslI MsgUserSupervisorCompany) Nothing + <*> aopt (textField & cfStrip & addDatalist suggestionSupervision) (fslI MsgUserSupervisorReason & setTooltip MsgStarKeepsEmptyDeletes) (Just $ Just "*") + , singletonMap ASRemoveAssociation $ pure ASRemoveAssociationData + ] + in renderAForm FormStandard $ (, mempty) . First . Just <$> multiActionA acts (fslI MsgTableAction) Nothing + , dbParamsFormEvaluate = liftHandler . runFormPost + , dbParamsFormResult = id + , dbParamsFormIdent = def + } + dbtCsvEncode = noCsvEncode + dbtCsvDecode = Nothing + dbtExtraReps = [] + + postprocess :: FormResult (First ActSupervisionData, DBFormResult UserSupervisorId Bool TblSupervisionData) + -> FormResult ( ActSupervisionData, Set UserSupervisorId) + postprocess inp = do + (First (Just act), jobMap) <- inp + let jobSet = Map.keysSet . Map.filter id $ getDBFormResult (const False) jobMap + return (act, jobSet) + + +getFirmsSupervisionR, postFirmsSupervisionR :: Handler Html +getFirmsSupervisionR = postFirmsSupervisionR +postFirmsSupervisionR = do + (svRes, svTbl) <- runDB mkSupervisionTable + formResult svRes $ \case + (ASRemoveAssociationData, relations) -> do + nrDel <- runDB $ deleteWhereCount [UserSupervisorId <-. Set.toList relations] + addMessageOutOfI MsgSupervisionsRemoved nrDel $ Set.size relations + reloadKeepGetParams FirmsSupervisionR + (ASChangeCompanyData{..}, relations) -> do + let rsnChg = case asTblReason of + Just "*" -> Nothing + _ -> Just $ UserSupervisorReason =. asTblReason + chgs = mcons rsnChg [UserSupervisorCompany =. CompanyKey <$> canonical asTblCompany] + nrChg <- runDB $ updateWhereCount [UserSupervisorId <-. Set.toList relations] chgs + addMessageOutOfI MsgSupervisionsEdited nrChg $ Set.size relations + reloadKeepGetParams FirmsSupervisionR + -- TODO: Bug Firmenwechsel: Bestehende Ansprechpartnerbeziehung - Firma ändern! + let heading = MsgMenuFirmsSupervision + siteLayoutMsg heading $ do + setTitleI heading + [whamlet|$newline never +

      + _{MsgFirmSupervisionRInfo} In folgenden Ansprechpartnerbeziehungen gehören entweder der Ansprechpartner oder der Angesprochene # + nicht mehr der Firma an, welche als Begründung für die Beziehung eingetragen ist: +

      + ^{svTbl} + |] diff --git a/src/Handler/Health.hs b/src/Handler/Health.hs index 708feea8f..7e50f8239 100644 --- a/src/Handler/Health.hs +++ b/src/Handler/Health.hs @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2022 Gregor Kleen ,Steffen Jost ,Steffen Jost +-- SPDX-FileCopyrightText: 2022-2025 Gregor Kleen ,Steffen Jost ,Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later @@ -7,7 +7,7 @@ module Handler.Health where import Import import Data.Time.Format.ISO8601 (iso8601Show) -import Handler.Utils.DateTime (formatTimeW) +import Handler.Utils.DateTime (formatTimeW, formatDiffDays) import qualified Data.Aeson.Encode.Pretty as Aeson import qualified Data.Text.Lazy.Builder as Builder @@ -127,6 +127,9 @@ getStatusR = do then tshow tdiff else pack . iso8601Show . calendarTimeTime . fromIntegral $ truncate tdiff + diffTime2 :: UTCTime -> Text + diffTime2 = formatDiffDays . diffUTCTime currtime + withUrlRenderer [hamlet| $doctype 5 @@ -148,11 +151,11 @@ getStatusR = do

      Instance Start
      #{show starttime} # - Uptime: #{diffTime starttime} + Uptime: #{diffTime starttime} ~ #{diffTime2 starttime}

      Compile Time
      #{show cTime} # - Build age: #{diffTime cTime} + Build age: #{diffTime cTime} ~ #{diffTime2 cTime} |] where -- vnr_full :: Text = $(embedStringFile "nix/docker/version.json") -- nix/ files not accessible during container construction diff --git a/src/Handler/Health/Interface.hs b/src/Handler/Health/Interface.hs index 58cfcbe4a..ccb15ae24 100644 --- a/src/Handler/Health/Interface.hs +++ b/src/Handler/Health/Interface.hs @@ -150,7 +150,7 @@ mkInterfaceLogTable interfs@(reqIfs, banIfs) = do ] unless (null reqIfs) $ E.where_ $ matchUIH reqIfs unless (null banIfs) $ E.where_ $ matchUIHnot banIfs - -- unless (null banIfs) $ E.where_ $ E.not_ $ matchUIH banIfs -- !!! DOES NOT WORK !!! Yields strange results, see #155 + -- unless (null banIfs) $ E.where_ $ E.not_ $ matchUIH banIfs -- !!! DOES NOT WORK !!! Yields strange results, see DevOps #1970 -- unless (null banIfs) $ E.where_ $ E.not_ $ E.parens $ matchUIH banIfs -- WORKS OKAY -- E.where_ $ E.not_ (ilog E.^. InterfaceLogInterface E.==. E.val "LMS" E.&&. ilog E.^. InterfaceLogSubtype E.==. E.val (sanitize "F")) -- BAD All missing, except for "Printer" "F" -- E.where_ $ E.not_ $ E.parens (ilog E.^. InterfaceLogInterface E.==. E.val "LMS" E.&&. ilog E.^. InterfaceLogSubtype E.==. E.val (sanitize "F")) -- WORKS OKAY @@ -308,17 +308,15 @@ wildcardCell c (Just x) = c x mkInterfaceWarnTable :: DB (FormResult (IWTableActionData, Set InterfaceHealthId), Widget) mkInterfaceWarnTable = do let - mkOption :: E.Value Text -> Option Text - mkOption (E.unValue -> t) = Option{ optionDisplay = t, optionInternalValue = t, optionExternalValue = toPathPiece t } getSuggestion pj = E.select $ E.distinct $ do il <- E.from $ E.table @InterfaceLog let res = il E.^. pj E.orderBy [E.asc res] pure res suggestionInterface :: HandlerFor UniWorX (OptionList Text) - suggestionInterface = mkOptionList . fmap mkOption <$> runDB (getSuggestion InterfaceLogInterface) + suggestionInterface = mkOptionList . fmap mkOptionText <$> runDB (getSuggestion InterfaceLogInterface) suggestionSubtype :: HandlerFor UniWorX (OptionList Text) - suggestionSubtype = mkOptionList . fmap mkOption <$> runDB (getSuggestion InterfaceLogSubtype) + suggestionSubtype = mkOptionList . fmap mkOptionText <$> runDB (getSuggestion InterfaceLogSubtype) dbtIdent = "interface-warnings" :: Text dbtSQLQuery :: IWTableExpr -> E.SqlQuery IWTableExpr dbtSQLQuery = return diff --git a/src/Handler/LMS.hs b/src/Handler/LMS.hs index ab0fa1964..d882c1d82 100644 --- a/src/Handler/LMS.hs +++ b/src/Handler/LMS.hs @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2022-24 Sarah Vaupel ,Steffen Jost ,Steffen Jost +-- SPDX-FileCopyrightText: 2022-2025 Sarah Vaupel ,Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later @@ -10,13 +10,13 @@ module Handler.LMS , getLmsSchoolR , getLmsR , postLmsR , getLmsIdentR - , getLmsEditR , postLmsEditR -- V2 , getLmsLearnersR , getLmsLearnersDirectR , getLmsReportR , postLmsReportR , getLmsReportUploadR , postLmsReportUploadR , postLmsReportDirectR + , getLmsOrphansR -- - , getLmsFakeR , postLmsFakeR + -- , getLmsFakeR , postLmsFakeR , getLmsUserR , getLmsUserSchoolR , getLmsUserAllR @@ -29,6 +29,7 @@ import Jobs import Handler.Utils import Handler.Utils.Users import Handler.Utils.LMS +import Handler.Utils.Company import qualified Data.Set as Set @@ -43,17 +44,13 @@ import qualified Database.Esqueleto.Legacy as E import qualified Database.Esqueleto.PostgreSQL as E import qualified Database.Esqueleto.Utils as E import Database.Esqueleto.Utils.TH -import Database.Persist.Sql (deleteWhereCount, updateWhereCount) +import Database.Persist.Sql (updateWhereCount) -- deleteWhereCount -- V2 import Handler.LMS.Learners as Handler.LMS import Handler.LMS.Report as Handler.LMS -import Handler.LMS.Fake as Handler.LMS -- TODO: remove in production! +-- import Handler.LMS.Fake as Handler.LMS -- TODO: remove in production! --- avoids repetition of local definitions -single :: (k,a) -> Map k a -single = uncurry Map.singleton - -- Button only needed here data ButtonManualLms = BtnLmsEnqueue | BtnLmsDequeue deriving (Enum, Eq, Ord, Bounded, Read, Show, Generic) @@ -95,14 +92,14 @@ postLmsAllR = do , formSubmit = FormNoSubmit } - LmsConf{lmsDeletionDays} <- getsYesod $ view _appLmsConf + lmsTable <- runDB $ do - view _2 <$> mkLmsAllTable isAdmin lmsDeletionDays + view _2 <$> mkLmsAllTable isAdmin siteLayoutMsg MsgMenuLms $ do setTitleI MsgMenuLms $(i18nWidgetFile "lms-all") -type AllQualificationTableData = DBRow (Entity Qualification, Ex.Value Word64, Ex.Value Word64) +type AllQualificationTableData = DBRow (Entity Qualification, Ex.Value Word64, Ex.Value Word64, Ex.Value Word64) resultAllQualification :: Lens' AllQualificationTableData Qualification resultAllQualification = _dbrOutput . _1 . _entityVal @@ -112,10 +109,13 @@ resultAllQualificationActive = _dbrOutput . _2 . _unValue resultAllQualificationTotal :: Lens' AllQualificationTableData Word64 resultAllQualificationTotal = _dbrOutput . _3 . _unValue +resultAllQualificationOrphans :: Lens' AllQualificationTableData Word64 +resultAllQualificationOrphans = _dbrOutput . _4 . _unValue -mkLmsAllTable :: Bool -> Int -> DB (Any, Widget) -mkLmsAllTable isAdmin lmsDeletionDays = do - svs <- getSupervisees + +mkLmsAllTable :: Bool -> DB (Any, Widget) +mkLmsAllTable isAdmin = do + svs <- getSupervisees True let resultDBTable = DBTable{..} where @@ -127,9 +127,12 @@ mkLmsAllTable isAdmin lmsDeletionDays = do Ex.where_ $ filterSvs luser cactive = Ex.subSelectCount $ do luser <- Ex.from $ Ex.table @LmsUser - Ex.where_ $ filterSvs luser Ex.&&. E.isNothing (luser E.^. LmsUserStatus) + Ex.where_ $ filterSvs luser Ex.&&. E.isNothing (luser Ex.^. LmsUserStatus) + corphans = Ex.subSelectCount $ do + lorphan <- Ex.from $ Ex.table @LmsOrphan + Ex.where_ $ lorphan Ex.^. LmsOrphanQualification Ex.==. quali Ex.^. QualificationId -- Failed attempt using Join/GroupBy instead of subselect: see branch csv-osis-demo-groupby-problem - return (quali, cactive, cusers) + return (quali, cactive, cusers, corphans) dbtRowKey = (Ex.^. QualificationId) dbtProj = dbtProjId adminable = if isAdmin then sortable else \_ _ _ -> mempty @@ -155,11 +158,11 @@ mkLmsAllTable isAdmin lmsDeletionDays = do in tickmarkCell $ elearnstart && isJust reminder , sortable Nothing (i18nCell MsgQualificationRefreshReminder & cellTooltips [SomeMessage MsgQualificationRefreshReminderTooltip, SomeMessage MsgTableDiffDaysTooltip]) $ foldMap (textCell . formatCalendarDiffDays ) . view (resultAllQualification . _qualificationRefreshReminder) - , sortable Nothing (i18nCell MsgQualificationAuditDuration & cellTooltips [SomeMessage (MsgQualificationAuditDurationTooltip lmsDeletionDays), SomeMessage MsgTableDiffDaysTooltip]) $ - foldMap (textCell . formatCalendarDiffDays . fromMonths) . view (resultAllQualification . _qualificationAuditDuration) + , sortable Nothing (i18nCell MsgQualificationAuditDuration & cellTooltips [SomeMessage MsgQualificationAuditDurationTooltip, SomeMessage MsgTableDiffDaysTooltip]) $ + (textCell . formatCalendarDiffDays . fromDays ) . view (resultAllQualification . _qualificationAuditDuration) , sortable (Just "qel-renew") (i18nCell MsgTableLmsElearningRenews & cellTooltip MsgQualificationElearningRenew) $ tickmarkCell . view (resultAllQualification . _qualificationElearningRenews) - , sortable (Just "qel-limit") (i18nCell MsgTableLmsElearningLimit & cellTooltip MsgQualificationElearningLimit) + , sortable (Just "qel-limit") (i18nCell MsgTableLmsElearningLimit & cellTooltip MsgQualificationElearningLimitExplain) $ cellMaybe numCell . view (resultAllQualification . _qualificationElearningLimit) , sortable (Just "qel-reuse") (i18nCell MsgTableQualificationLmsReuses & cellTooltip MsgTableQualificationLmsReusesTooltip) $ \(view (resultAllQualification . _qualificationLmsReuses) -> reuseQid) -> maybeCell reuseQid qualificationIdShortCell @@ -176,6 +179,7 @@ mkLmsAllTable isAdmin lmsDeletionDays = do $ \(view resultAllQualificationActive -> n) -> wgtCell $ word2widget n , adminable Nothing (i18nCell MsgTableQualificationCountTotal) $ wgtCell . word2widget . view resultAllQualificationTotal -- \(view resultAllQualificationTotal -> n) -> wgtCell $ word2widget n + , adminable Nothing (i18nCell MsgLmsOrphans) $ wgtCell . word2widget . view resultAllQualificationOrphans ] dbtSorting = mconcat [ @@ -210,12 +214,6 @@ mkLmsAllTable isAdmin lmsDeletionDays = do dbTable resultDBTableValidator resultDBTable - -getLmsEditR, postLmsEditR :: SchoolId -> QualificationShorthand -> Handler Html -getLmsEditR = postLmsEditR -postLmsEditR = error "TODO: STUB" - - data LmsTableCsv = LmsTableCsv -- L..T..C.. -> ltc.. { ltcDisplayName :: UserDisplayName , ltcEmail :: UserEmail @@ -302,19 +300,22 @@ instance CsvColumnsExplained LmsTableCsv where type LmsTableExpr = ( E.SqlExpr (Entity QualificationUser) `E.InnerJoin` E.SqlExpr (Entity User) `E.InnerJoin` E.SqlExpr (Entity LmsUser) - ) `E.LeftOuterJoin` E.SqlExpr (Maybe (Entity QualificationUserBlock)) + `E.LeftOuterJoin` E.SqlExpr (Maybe (Entity QualificationUserBlock)) + ) +-- due to GHC staging restrictions, we use the preprocessor instead +#define LMS_TABLE_JOIN "IIL" queryQualUser :: LmsTableExpr -> E.SqlExpr (Entity QualificationUser) -queryQualUser = $(sqlIJproj 3 1) . $(sqlLOJproj 2 1) +queryQualUser = $(sqlMIXproj LMS_TABLE_JOIN 1) queryUser :: LmsTableExpr -> E.SqlExpr (Entity User) -queryUser = $(sqlIJproj 3 2) . $(sqlLOJproj 2 1) +queryUser = $(sqlMIXproj LMS_TABLE_JOIN 2) queryLmsUser :: LmsTableExpr -> E.SqlExpr (Entity LmsUser) -queryLmsUser = $(sqlIJproj 3 3) . $(sqlLOJproj 2 1) +queryLmsUser = $(sqlMIXproj LMS_TABLE_JOIN 3) queryQualBlock :: LmsTableExpr -> E.SqlExpr (Maybe (Entity QualificationUserBlock)) -queryQualBlock = $(sqlLOJproj 2 2) +queryQualBlock = $(sqlMIXproj LMS_TABLE_JOIN 4) type LmsTableData = DBRow (Entity QualificationUser, Entity User, Entity LmsUser, Maybe (Entity QualificationUserBlock), E.Value (Maybe [Maybe UTCTime]), E.Value (Maybe CompanyId), E.Value Bool) @@ -354,9 +355,9 @@ instance HasQualificationUser LmsTableData where data LmsTableAction = LmsActNotify | LmsActRenewNotify - | LmsActRenewPin | LmsActReset | LmsActRestart + | LmsActTerminate deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic) deriving anyclass (Universe, Finite) @@ -365,7 +366,7 @@ embedRenderMessage ''UniWorX ''LmsTableAction id data LmsTableActionData = LmsActNotifyData | LmsActRenewNotifyData - | LmsActRenewPinData -- no longer used + | LmsActTerminateData | LmsActResetData { lmsActRestartExtend :: Maybe Integer , lmsActRestartUnblock :: Maybe Bool @@ -383,11 +384,6 @@ isNotifyAct LmsActNotifyData = True isNotifyAct LmsActRenewNotifyData = True isNotifyAct _ = False -isRenewPinAct :: LmsTableActionData -> Bool -isRenewPinAct LmsActRenewNotifyData = True -isRenewPinAct LmsActRenewPinData = True -isRenewPinAct _ = False - isResetAct :: LmsTableActionData -> Bool isResetAct LmsActResetData{} = True isResetAct _ = False @@ -424,11 +420,7 @@ lmsTableQuery now qid (qualUser `E.InnerJoin` user `E.InnerJoin` lmsUser `E.Left let pjOrder = [E.desc $ pj E.^. PrintJobCreated, E.desc $ pj E.^. PrintJobAcknowledged] -- latest created comes first! This is assumed to be the case later on! pure $ --(E.arrayAggWith E.AggModeAll (pj E.^. PrintJobCreated ) pjOrder, -- return two aggregates only works with select, the restricted type of subSelect does not seem to support this! E.arrayAggWith E.AggModeAll (pj E.^. PrintJobAcknowledged) pjOrder - primeComp = E.subSelect . E.from $ \uc -> do - E.where_ $ user E.^. UserId E.==. uc E.^. UserCompanyUser - E.orderBy [E.desc $ uc E.^. UserCompanyPriority, E.asc $ uc E.^. UserCompanyCompany] - return (uc E.^. UserCompanyCompany) - return (qualUser, user, lmsUser, qualBlock, printAcknowledged, primeComp, validQualification now qualUser) + return (qualUser, user, lmsUser, qualBlock, printAcknowledged, selectCompanyUserPrime user, validQualification now qualUser) mkLmsTable :: ( Functor h, ToSortable h @@ -441,12 +433,13 @@ mkLmsTable :: ( Functor h, ToSortable h -> PSValidator (MForm Handler) (FormResult (First LmsTableActionData, DBFormResult UserId Bool LmsTableData)) -> DB (FormResult (LmsTableActionData, Set UserId), Widget) mkLmsTable isAdmin (Entity qid quali) acts cols psValidator = do - now <- liftIO getCurrentTime -- lookup all companies - cmpMap <- memcachedBy (Just . Right $ 15 * diffMinute) ("CompanyDictionary"::Text) $ do + cmpMap <- memcachedBy (Just . Right $ 30 * diffMinute) ("CompanyDictionary"::Text) $ do cmps <- selectList [] [] -- [Asc CompanyShorthand] return $ Map.fromList $ fmap (\Entity{..} -> (entityKey, entityVal)) cmps + now <- liftIO getCurrentTime let + nowaday = utctDay now getCompanyName :: CompanyId -> CompanyName getCompanyName cid = maybe (unCompanyKey cid) companyName $ Map.lookup cid cmpMap -- use shorthand in case of impossible failure @@ -457,54 +450,64 @@ mkLmsTable isAdmin (Entity qid quali) acts cols psValidator = do dbtRowKey = queryUser >>> (E.^. UserId) dbtProj = dbtProjId dbtColonnade = cols getCompanyName - dbtSorting = mconcat - [ single $ sortUserNameLink queryUser - , single $ sortUserEmail queryUser - , single $ sortUserMatriclenr queryUser - , single ("valid-until" , SortColumnNullsInv $ queryQualUser >>> (E.^. QualificationUserValidUntil)) - -- , single ("validity" , SortColumn $ queryQualUser >>> validQualification nowaday) - , single ("last-refresh" , SortColumnNullsInv $ queryQualUser >>> (E.^. QualificationUserLastRefresh)) - , single ("first-held" , SortColumnNullsInv $ queryQualUser >>> (E.^. QualificationUserFirstHeld)) - , single ("blocked" , SortColumnNeverNull$ queryQualBlock >>> (E.?. QualificationUserBlockFrom)) - , single ("schedule-renew", SortColumnNullsInv $ queryQualUser >>> (E.^. QualificationUserScheduleRenewal)) - , single ("ident" , SortColumnNullsInv $ queryLmsUser >>> (E.^. LmsUserIdent)) - , single ("pin" , SortColumnNullsInv $ queryLmsUser >>> (E.^. LmsUserPin)) - -- , single ("status" , SortColumnNullsInv $ views (to queryLmsUser) (E.^. LmsUserStatusDay)) - , single ("status" , SortColumnNeverNull $ \row -> E.coalesceDefault [ queryLmsUser row E.^. LmsUserStatusDay + dbtSorting = Map.fromList + [ sortUserNameLink queryUser + , sortUserEmail queryUser + , sortUserMatriclenr queryUser + , ("valid-until" , SortColumnNullsInv $ queryQualUser >>> (E.^. QualificationUserValidUntil)) + -- , ("validity" , SortColumn $ queryQualUser >>> validQualification nowaday) + , ("last-refresh" , SortColumnNullsInv $ queryQualUser >>> (E.^. QualificationUserLastRefresh)) + , ("first-held" , SortColumnNullsInv $ queryQualUser >>> (E.^. QualificationUserFirstHeld)) + , ("blocked" , SortColumnNeverNull$ queryQualBlock >>> (E.?. QualificationUserBlockFrom)) + , ("schedule-renew", SortColumnNullsInv $ queryQualUser >>> (E.^. QualificationUserScheduleRenewal)) + , ("ident" , SortColumnNullsInv $ queryLmsUser >>> (E.^. LmsUserIdent)) + , ("pin" , SortColumnNullsInv $ queryLmsUser >>> (E.^. LmsUserPin)) + -- , ("status" , SortColumnNullsInv $ views (to queryLmsUser) (E.^. LmsUserStatusDay)) + , ("status" , SortColumnNeverNull $ \row -> E.coalesceDefault [ queryLmsUser row E.^. LmsUserStatusDay , queryLmsUser row E.^. LmsUserNotified ](queryLmsUser row E.^. LmsUserStarted)) - , single ("started" , SortColumnNullsInv $ queryLmsUser >>> (E.^. LmsUserStarted)) - , single ("datepin" , SortColumnNullsInv $ queryLmsUser >>> (E.^. LmsUserDatePin)) - , single ("received" , SortColumnNullsInv $ queryLmsUser >>> (E.^. LmsUserReceived)) - , single ("notified" , SortColumnNullsInv $ queryLmsUser >>> (E.^. LmsUserNotified)) -- cannot include printJob acknowledge date - , single ("ended" , SortColumnNullsInv $ queryLmsUser >>> (E.^. LmsUserEnded)) - , single ("user-company", SortColumn $ \row -> E.subSelect $ E.from $ \(usrComp `E.InnerJoin` comp) -> do + , ("started" , SortColumnNullsInv $ queryLmsUser >>> (E.^. LmsUserStarted)) + , ("datepin" , SortColumnNullsInv $ queryLmsUser >>> (E.^. LmsUserDatePin)) + , ("received" , SortColumnNullsInv $ queryLmsUser >>> (E.^. LmsUserReceived)) + , ("notified" , SortColumnNullsInv $ queryLmsUser >>> (E.^. LmsUserNotified)) -- cannot include printJob acknowledge date + , ("ended" , SortColumnNullsInv $ queryLmsUser >>> (E.^. LmsUserEnded)) + , ("user-company", SortColumn $ \row -> E.subSelect $ E.from $ \(usrComp `E.InnerJoin` comp) -> do E.on $ usrComp E.^. UserCompanyCompany E.==. comp E.^. CompanyId E.where_ $ usrComp E.^. UserCompanyUser E.==. queryUser row E.^. UserId E.orderBy [E.asc (comp E.^. CompanyName)] return (comp E.^. CompanyName) - ) + ) ] - dbtFilter = mconcat - [ single $ fltrUserNameEmail queryUser - , single ("ident" , FilterColumn . E.mkContainsFilterWithCommaPlus LmsIdent $ views (to queryLmsUser) (E.^. LmsUserIdent)) - , single ("status" , FilterColumn . E.mkExactFilterMaybeLast $ views (to queryLmsUser) (E.^. LmsUserStatus)) - -- , single ("validity" , FilterColumn . E.mkExactFilterLast $ views (to queryQualUser) ((E.>=. E.val nowaday) . (E.^. QualificationUserValidUntil))) - , single ("validity" , FilterColumn . E.mkExactFilterLast $ views (to queryQualUser) (validQualification now)) - -- , single ("renewal-due" , FilterColumn $ \(queryQualUser -> quser) criterion -> + dbtFilter = Map.fromList + [ fltrUserNameEmail queryUser + , ("ident" , FilterColumn . E.mkContainsFilterWithCommaPlus LmsIdent $ views (to queryLmsUser) (E.^. LmsUserIdent)) + , ("status" , FilterColumn . E.mkExactFilterMaybeLast $ views (to queryLmsUser) (E.^. LmsUserStatus)) + -- , ("validity" , FilterColumn . E.mkExactFilterLast $ views (to queryQualUser) ((E.>=. E.val nowaday) . (E.^. QualificationUserValidUntil))) + , ("validity" , FilterColumn . E.mkExactFilterLast $ views (to queryQualUser) (validQualification now)) + -- , ("renewal-due" , FilterColumn $ \(queryQualUser -> quser) criterion -> -- if | Just renewal <- mbRenewal -- , Just True <- getLast criterion -> quser E.^. QualificationUserValidUntil E.<=. E.val renewal -- E.&&. quser E.^. QualificationUserValidUntil E.>=. E.val nowaday -- | otherwise -> E.true -- ) - , single ("notified", FilterColumn . E.mkExactFilterLast $ views (to queryLmsUser) (E.isJust . (E.^. LmsUserNotified))) - , single ("avs-number" , FilterColumn . E.mkExistsFilter $ \row criterion -> + , ("long-valid", + let cutoff = if + | Just refWithin <- qualificationRefreshWithin quali -> computeNewValidDate' (refWithin <> calendarDay) nowaday -- longer valid than renewal + | Just valDuration <- qualificationValidDuration quali -> computeNewValidDate (valDuration `div` 2) nowaday -- or longer valid than half the duration + | otherwise -> computeNewValidDate' (calendarYear <> calendarDay) nowaday -- or a year and a day + -- in FilterColumn . E.mkExactFilterLast $ views (to queryQualUser) ((E.>. E.val cutoff) . (E.^. QualificationUserValidUntil)) -- for use with boolField + in FilterColumn $ \(queryQualUser -> quser) (getLast -> criterion) -> if -- for use with checkboxField + | Just True <- criterion -> quser E.^. QualificationUserValidUntil E.>=. E.val cutoff + | otherwise -> E.true + ) + , ("notified", FilterColumn . E.mkExactFilterLast $ views (to queryLmsUser) (E.isJust . (E.^. LmsUserNotified))) + , ("avs-number" , FilterColumn . E.mkExistsFilter $ \row criterion -> E.from $ \usrAvs -> -- do E.where_ $ usrAvs E.^. UserAvsUser E.==. queryUser row E.^. UserId E.&&. ((E.val criterion :: E.SqlExpr (E.Value (CI Text))) E.==. (E.explicitUnsafeCoerceSqlExprValue "citext" (usrAvs E.^. UserAvsNoPerson) :: E.SqlExpr (E.Value (CI Text))) )) - , single ("user-company", FilterColumn . E.mkExistsFilter $ \row criterion -> + , ("user-company", FilterColumn . E.mkExistsFilter $ \row criterion -> E.from $ \(usrComp `E.InnerJoin` comp) -> do let testname = (E.val criterion :: E.SqlExpr (E.Value (CI Text))) `E.isInfixOf` (E.explicitUnsafeCoerceSqlExprValue "citext" (comp E.^. CompanyName) :: E.SqlExpr (E.Value (CI Text))) @@ -514,21 +517,22 @@ mkLmsTable isAdmin (Entity qid quali) acts cols psValidator = do E.where_ $ usrComp E.^. UserCompanyUser E.==. queryUser row E.^. UserId E.&&. testcrit ) , fltrAVSCardNos queryUser - , single ("personal-number", FilterColumn $ \(queryUser -> user) (criteria :: Set.Set Text) -> if + , ("personal-number", FilterColumn $ \(queryUser -> user) (criteria :: Set.Set Text) -> if | Set.null criteria -> E.true | otherwise -> E.any (\c -> user E.^. UserCompanyPersonalNumber `E.hasInfix` E.val c) criteria ) ] dbtFilterUI mPrev = mconcat [ fltrUserNameEmailHdrUI MsgLmsUser mPrev - , prismAForm (singletonFilter "user-company") mPrev $ aopt textField (fslI MsgTableCompany) - , prismAForm (singletonFilter "personal-number" ) mPrev $ aopt textField (fslI MsgCompanyPersonalNumber) + , prismAForm (singletonFilter "user-company") mPrev $ aopt textField (fslI MsgTableCompany) + , prismAForm (singletonFilter "personal-number" ) mPrev $ aopt textField (fslI MsgCompanyPersonalNumberFraport) , fltrAVSCardNosUI mPrev , prismAForm (singletonFilter "avs-number" ) mPrev $ aopt textField (fslI MsgAvsPersonNo) - , prismAForm (singletonFilter "ident" . maybePrism _PathPiece) mPrev $ aopt (hoistField lift textField) (fslI MsgTableLmsIdent) - , prismAForm (singletonFilter "validity" . maybePrism _PathPiece) mPrev $ aopt (boolField . Just $ SomeMessage MsgBoolIrrelevant) (fslI MsgFilterLmsValid) - , prismAForm (singletonFilter "notified" . maybePrism _PathPiece) mPrev $ aopt (boolField . Just $ SomeMessage MsgBoolIrrelevant) (fslI MsgFilterLmsNotified) - , prismAForm (singletonFilter "status" . maybePrism _PathPiece) mPrev $ aopt (hoistField liftHandler (selectField optionsFinite) :: (Field _ (Maybe LmsStatus))) (fslI MsgTableLmsStatus) + , prismAForm (singletonFilter "ident" . maybePrism _PathPiece) mPrev $ aopt (hoistField lift textField) (fslI MsgTableLmsIdent) + , prismAForm (singletonFilter "validity" . maybePrism _PathPiece) mPrev $ aopt boolField' (fslI MsgFilterLmsValid) + , prismAForm (singletonFilter "long-valid" . maybePrism _PathPiece) mPrev $ aopt checkBoxField (fslI MsgFilterLmsLongValid) + , prismAForm (singletonFilter "notified" . maybePrism _PathPiece) mPrev $ aopt boolField' (fslI MsgFilterLmsNotified) + , prismAForm (singletonFilter "status" . maybePrism _PathPiece) mPrev $ aopt (hoistField liftHandler (selectField optionsFinite) :: (Field _ (Maybe LmsStatus))) (fslI MsgTableLmsStatus) -- , if isNothing mbRenewal then mempty -- else prismAForm (singletonFilter "renewal-due" . maybePrism _PathPiece) mPrev $ aopt checkBoxField (fslI MsgFilterLmsRenewal) ] @@ -604,15 +608,15 @@ postLmsR sid qsh = do let nowaday = utctDay now msgResetInfo <- messageIconI Info IconNotificationNonactive MsgLmsActResetInfo msgRestartWarning <- messageIconI Warning IconWarning MsgLmsActRestartWarning + msgTerminateInfo <- messageIconI Info IconNotificationNonactive MsgLmsActTerminateInfo ((lmsRes, lmsTable), Entity qid quali, lmsQualiReused) <- runDB $ do - qent@Entity{entityVal=Qualification{qualificationLmsReuses = reuseQuali}} <- getBy404 $ SchoolQualificationShort sid qsh + qent@Entity{entityVal=Qualification{qualificationLmsReuses = reuseQuali, qualificationElearningStart = elearnStart, qualificationRefreshWithin = refreshWithin}} <- getBy404 $ SchoolQualificationShort sid qsh lmsQualiReused <- traverseJoin get reuseQuali let acts :: Map LmsTableAction (AForm Handler LmsTableActionData) acts = mconcat [ singletonMap LmsActNotify $ pure LmsActNotifyData , singletonMap LmsActRenewNotify $ pure LmsActRenewNotifyData - -- , singletonMap LmsActRenewPin $ pure LmsActRenewPinData , singletonMap LmsActReset $ LmsActResetData <$> aopt intField (fslI MsgLmsActRestartExtend) Nothing <*> aopt checkBoxField (fslI MsgLmsActRestartUnblock) Nothing @@ -624,6 +628,7 @@ postLmsR sid qsh = do <*> aopt checkBoxField (fslI MsgLmsActNotify) Nothing -- <*> aopt (commentField MsgQualificationActBlockSupervisor) (fslI MsgMessageWarning) Nothing <* aformMessage msgRestartWarning + , singletonMap LmsActTerminate $ bool pure (<$ aformMessage msgTerminateInfo) (elearnStart && isJust refreshWithin) LmsActTerminateData ] colChoices getCompanyName = mconcat [ guardMonoid isAdmin $ dbSelect (applying _2) id (return . view (resultUser . _entityKey)) @@ -705,6 +710,14 @@ postLmsR sid qsh = do formResult lmsRes $ \case _ | not isAdmin -> addMessageI Error MsgUnauthorized -- only admins can use the form on this page + (LmsActTerminateData, selectedUsers) -> do + let usersList = Set.toList selectedUsers + numUsers = Set.size selectedUsers + numDel <- runDB $ terminateLms LmsOrphanReasonManualTermination qid usersList -- calls audit by itself + let mStatus = bool Success Warning $ numDel < numUsers + addMessageI mStatus $ someMessages [MsgLmsActTerminateFeedback numDel numUsers, MsgLmsActTerminateWarning] + reloadKeepGetParams $ LmsR sid qsh + (action, selectedUsers) | isResetRestartAct action -> do let usersList = Set.toList selectedUsers numUsers = Set.size selectedUsers @@ -725,21 +738,19 @@ postLmsR sid qsh = do , QualificationUserUser <-. usersList , QualificationUserValidUntil <. cutoff ] [] - forM_ shortUsers $ upsertQualificationUser qid now cutoff Nothing "E-Learning Reset" + forM_ shortUsers $ upsertQualificationUser qid now cutoff Nothing "E-Learning Reset" -- do not terminate LMS here , since it is expected to continue - fromIntegral <$> (if isReset - then updateWhereCount ([LmsUserQualification ==. qid, LmsUserUser <-. usersList, LmsUserResetTries ==. False, LmsUserEnded ==. Nothing] -- , LmsUserLocked ==. True] -- needs to be locked for reset, but this is counter-intuitive for users; should be harmless, but delays reset until lock is effective - ++ ([LmsUserStatus ==. Just LmsBlocked] ||. [LmsUserStatus ==. Just LmsExpired])) - (bcons actRestartNotify (LmsUserNotified =. Nothing) [LmsUserResetTries =. True]) - else deleteWhereCount [LmsUserQualification ==. qid, LmsUserUser <-. usersList] - ) + if isReset + then fromIntegral <$> updateWhereCount ([LmsUserQualification ==. qid, LmsUserUser <-. usersList, LmsUserResetTries ==. False, LmsUserEnded ==. Nothing] -- , LmsUserLocked ==. True] -- needs to be locked for reset, but this is counter-intuitive for users; should be harmles, but delays reset until lock is effective + ++ ([LmsUserStatus ==. Just LmsBlocked] ||. [LmsUserStatus ==. Just LmsExpired])) [LmsUserResetTries =. True] + else terminateLms LmsOrphanReasonManualRestart qid usersList unless isReset $ forM_ selectedUsers $ \uid -> queueJob' $ JobLmsEnqueueUser { jQualification = qid, jUser = uid } runDB $ forM_ selectedUsers $ \uid -> - audit $ TransactionLmsReset + audit $ TransactionLmsReset -- NOTE: double audit, if isRestart; double audit if actRestartExtend { transactionQualification = qid , transactionLmsUser = uid , transactionLmsReset = isReset @@ -752,7 +763,8 @@ postLmsR sid qsh = do addMessageI mStatus $ bool MsgLmsActRestartFeedback MsgLmsActResetFeedback isReset chgUsers numUsers reloadKeepGetParams $ LmsR sid qsh - (action, selectedUsers) | isRenewPinAct action || isNotifyAct action -> do + (action, selectedUsers) | isNotifyAct action -> do + let isRenewPinAct = action == LmsActRenewNotifyData numExaminees <- runDB $ do okUsers <- selectList [ LmsUserQualification ==. qid -- matching qualification , LmsUserEnded ==. Nothing -- not yet deleted @@ -760,25 +772,23 @@ postLmsR sid qsh = do , LmsUserUser <-. Set.toList selectedUsers -- selected ] [] forM_ okUsers $ \(Entity lid LmsUser {lmsUserUser = uid, lmsUserQualification = qid'}) -> do - when (isRenewPinAct action) $ do + when isRenewPinAct $ do newPin <- liftIO randomLMSpw update lid [LmsUserPin =. newPin, LmsUserDatePin =. now, LmsUserResetPin =. True] - when (isNotifyAct action) $ - queueJob' $ JobUserNotification { jRecipient = uid, jNotification = NotificationQualificationRenewal qid' False } + queueJob' $ JobUserNotification { jRecipient = uid, jNotification = NotificationQualificationRenewal qid' False } return $ length okUsers let numSelected = length selectedUsers diffSelected = numSelected - numExaminees mstat = bool Success Warning $ diffSelected /= 0 - when (isRenewPinAct action) $ addMessageI mstat $ MsgLmsPinRenewal numExaminees - when (isNotifyAct action) $ addMessageI mstat $ MsgLmsNotificationSend numExaminees - when (diffSelected /= 0) $ addMessageI Warning $ MsgLmsActionFailed diffSelected + when isRenewPinAct $ addMessageI mstat $ MsgLmsPinRenewal numExaminees + addMessageI mstat $ MsgLmsNotificationSend numExaminees + when (diffSelected /= 0) $ addMessageI Warning $ MsgLmsActionFailed diffSelected reloadKeepGetParams $ LmsR sid qsh _ -> addMessageI Error MsgUnauthorized -- should not happen let heading = citext2widget $ "LMS " <> qualificationName quali siteLayout heading $ do setTitle $ toHtml $ "LMS " <> unSchoolKey sid <> "-" <> qsh - LmsConf{lmsDeletionDays} <- getsYesod $ view _appLmsConf $(widgetFile "lms") -- redirect to a specific lms user diff --git a/src/Handler/LMS/Fake.hs b/src/Handler/LMS/Fake.hs index 6d7882b7d..9c5e66ff0 100644 --- a/src/Handler/LMS/Fake.hs +++ b/src/Handler/LMS/Fake.hs @@ -1,7 +1,10 @@ --- SPDX-FileCopyrightText: 2022 Steffen Jost +-- SPDX-FileCopyrightText: 2022-2025 Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later + +-- For testing and debugging only, not to be used in production + module Handler.LMS.Fake ( getLmsFakeR, postLmsFakeR ) where @@ -22,7 +25,7 @@ import Control.Applicative (ZipList(..), getZipList) getLmsFakeR, postLmsFakeR :: SchoolId -> QualificationShorthand -> Handler Html getLmsFakeR = postLmsFakeR -postLmsFakeR sid qsh = do +postLmsFakeR sid qsh = do qent <- runDB $ getBy404 $ SchoolQualificationShort sid qsh now <- liftIO getCurrentTime let qName :: Text = CI.original $ unSchoolKey sid <> "-" <> qsh @@ -39,13 +42,13 @@ postLmsFakeR sid qsh = do setTitle $ toHtml $ "Testnutzer generieren " <> qName toWidget [whamlet| Hier können zufällige Testbenutzer mit ablaufenden Qualifikationen generiert werden, - welche dann im angegebenen Zeitraum fällig werden. + welche dann im angegebenen Zeitraum fällig werden. ^{fakeForm} -

      Hinweise: +

      Hinweise:
        -
      • Emails der generierten Teilnehmer enden auf @example.com +
      • Emails der generierten Teilnehmer enden auf @example.com und die Matrikelnummer lautet TESTUSER.
      • Bereits vorhandene Teilnehmer mit gleicher Ident werden nicht neu generiert.
      • Vorhandene Qualifikationen solcher Teilnehmer werden einfach überschrieben. @@ -69,8 +72,8 @@ fakeQualificationUsers (Entity qid Qualification{qualificationRefreshWithin}) (u pwHash <- liftIO $ PWStore.makePasswordWith pwHashAlgorithm pw pwHashStrength return $ TEnc.decodeUtf8 pwHash theSupervisor <- selectKeysList [UserSurname ==. "Jost", UserFirstName ==. "Steffen"] [Asc UserCreated, LimitTo 1] - let addSupervisor = case theSupervisor of - [s] -> \suid k -> case k of + let addSupervisor = case theSupervisor of + [s] -> \suid k -> case k of 1 -> void $ insertBy $ UserSupervisor s suid True Nothing Nothing 2 -> do void $ insertBy $ UserSupervisor s suid True Nothing (Just "Test") @@ -121,16 +124,16 @@ fakeQualificationUsers (Entity qid Qualification{qualificationRefreshWithin}) (u if | (Left (Entity _ User{userMatrikelnummer})) <- euid , userMatrikelnummer /= Just "TESTUSER" -> return 0 - | otherwise -> do + | otherwise -> do let uid = either entityKey id euid qualificationUserUser = uid qualificationUserQualification = qid qualificationUserValidUntil = addDays expOffset expiryNotifyDay - qualificationUserFirstHeld = addGregorianMonthsClip (-24) qualificationUserValidUntil - qualificationUserLastRefresh = qualificationUserFirstHeld + qualificationUserFirstHeld = computeNewValidDate (-24) qualificationUserValidUntil + qualificationUserLastRefresh = qualificationUserFirstHeld qualificationUserScheduleRenewal = True qualificationUserLastNotified = now - _ <- upsert QualificationUser{..} + _ <- upsert QualificationUser{..} [ QualificationUserValidUntil =. qualificationUserValidUntil , QualificationUserLastRefresh =. qualificationUserLastRefresh ] diff --git a/src/Handler/LMS/Learners.hs b/src/Handler/LMS/Learners.hs index 144d8f9bb..2088872a2 100644 --- a/src/Handler/LMS/Learners.hs +++ b/src/Handler/LMS/Learners.hs @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2023 Steffen Jost +-- SPDX-FileCopyrightText: 2023-2025 Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later @@ -7,6 +7,7 @@ module Handler.LMS.Learners ( getLmsLearnersR , getLmsLearnersDirectR + , getLmsOrphansR ) where @@ -19,9 +20,10 @@ import Handler.Utils.LMS import qualified Data.Map as Map import qualified Data.Csv as Csv +import qualified Data.Char as Char import qualified Data.Text as Text import qualified Data.Conduit.List as C --- import qualified Database.Esqueleto.Experimental as Ex -- needs TypeApplications Lang-Pragma +import qualified Database.Esqueleto.Experimental as Ex -- needs TypeApplications Lang-Pragma import qualified Database.Esqueleto.Legacy as E import qualified Database.Esqueleto.Utils as E @@ -34,6 +36,17 @@ data LmsUserTableCsv = LmsUserTableCsv -- for csv export only deriving Generic makeLenses_ ''LmsUserTableCsv +lmsUserDelete2csv :: LmsIdent -> LmsUserTableCsv +lmsUserDelete2csv lid = LmsUserTableCsv + { csvLUTident = lid + , csvLUTpin = "00000000" + , csvLUTresetPin = LmsBool False + , csvLUTdelete = LmsBool $ isJust $ Text.find Char.isLetter $ getLmsIdent lid -- safety-catch: do not delete LMS Test-users, FRADrive LMS Idents always contain at least one letter + , csvLUTstaff = LmsBool False + , csvLUTresetTries= LmsBool False + , csvLUTlock = LmsBool True + } + -- | Mundane conversion needed for direct download without dbTable only lmsUser2csv :: UTCTime -> LmsUser -> LmsUserTableCsv lmsUser2csv cutoff lu@LmsUser{..} = LmsUserTableCsv @@ -76,19 +89,15 @@ instance FromNamedRecord LmsUserTableCsv where <*> csv Csv..: csvLmsLock instance CsvColumnsExplained LmsUserTableCsv where - csvColumnsExplanations _ = mconcat - [ single csvLmsIdent MsgCsvColumnLmsIdent - , single csvLmsPin MsgCsvColumnLmsPin - , single csvLmsResetPin MsgCsvColumnLmsResetPin - , single csvLmsDelete MsgCsvColumnLmsDelete - , single csvLmsStaff MsgCsvColumnLmsStaff - , single csvLmsResetTries MsgCsvColumnLmsResetTries - , single csvLmsLock MsgCsvColumnLmsLock + csvColumnsExplanations _ = Map.fromList + [ (csvLmsIdent , msg2widget MsgCsvColumnLmsIdent) + , (csvLmsPin , msg2widget MsgCsvColumnLmsPin) + , (csvLmsResetPin , msg2widget MsgCsvColumnLmsResetPin) + , (csvLmsDelete , msg2widget MsgCsvColumnLmsDelete) + , (csvLmsStaff , msg2widget MsgCsvColumnLmsStaff) + , (csvLmsResetTries , msg2widget MsgCsvColumnLmsResetTries) + , (csvLmsLock , msg2widget MsgCsvColumnLmsLock) ] - where - single :: RenderMessage UniWorX msg => Csv.Name -> msg -> Map Csv.Name Widget - single k v = singletonMap k [whamlet|_{v}|] - mkUserTable :: SchoolId -> QualificationShorthand -> QualificationId -> UTCTime -> DB (Any, Widget) @@ -125,11 +134,11 @@ mkUserTable _sid qsh qid cutoff = do ] dbtFilter = Map.fromList [ (csvLmsIdent , FilterColumn $ E.mkContainsFilterWithCommaPlus LmsIdent (E.^. LmsUserIdent )) - , (csvLmsResetPin , FilterColumn $ E.mkExactFilterLast (E.^. LmsUserResetPin)) + , (csvLmsResetPin , FilterColumn $ E.mkExactFilterLast (E.^. LmsUserResetPin)) ] dbtFilterUI = \mPrev -> mconcat - [ prismAForm (singletonFilter csvLmsIdent . maybePrism _PathPiece) mPrev $ aopt (hoistField lift textField) (fslI MsgTableLmsIdent & setTooltip MsgTableFilterCommaPlus) - , prismAForm (singletonFilter csvLmsResetPin . maybePrism _PathPiece) mPrev $ aopt (hoistField lift (boolField . Just $ SomeMessage MsgBoolIrrelevant)) (fslI MsgTableLmsResetPin) + [ prismAForm (singletonFilter csvLmsIdent . maybePrism _PathPiece) mPrev $ aopt (hoistField lift textField) (fslI MsgTableLmsIdent & setTooltip MsgTableFilterCommaPlus) + , prismAForm (singletonFilter csvLmsResetPin . maybePrism _PathPiece) mPrev $ aopt (hoistField lift boolField') (fslI MsgTableLmsResetPin) ] dbtStyle = def { dbsFilterLayout = defaultDBSFilterLayout } dbtParams = def @@ -161,22 +170,52 @@ mkUserTable _sid qsh qid cutoff = do getQidCutoff :: SchoolId -> QualificationShorthand -> DB (QualificationId, UTCTime) getQidCutoff sid qsh = do Entity{entityKey = qid, entityVal = Qualification{qualificationAuditDuration=auditDur}} <- getBy404 $ SchoolQualificationShort sid qsh - cutoff <- liftHandler $ lmsDeletionDate auditDur + now <- liftIO getCurrentTime + let cutoff = lmsDeletionDate now auditDur return (qid, cutoff) getLmsLearnersR :: SchoolId -> QualificationShorthand -> Handler Html getLmsLearnersR sid qsh = do - lmsTable <- runDB $ do + (lmsTable, nr_orphans) <- runDB $ do (qid, cutoff) <- getQidCutoff sid qsh - view _2 <$> mkUserTable sid qsh qid cutoff + lmsTable <- view _2 <$> mkUserTable sid qsh qid cutoff + nr_orphans <- count [LmsOrphanQualification ==. qid] + return (lmsTable, nr_orphans) + when (nr_orphans > 0) $ addMessageI Warning $ MsgLmsOrphanNr nr_orphans siteLayoutMsg MsgMenuLmsLearners $ do setTitleI MsgMenuLmsLearners lmsTable + +-- selectOrphans :: QualificationId -> UTCTime -> DB [(LmsOrphanId, LmsIdent)] +selectOrphans :: (MonadHandler m, HasAppSettings (HandlerSite m), BackendCompatible SqlBackend backend, PersistQueryRead backend, PersistUniqueRead backend) + => Key Qualification -> UTCTime -> ReaderT backend m [(LmsOrphanId, LmsIdent)] +selectOrphans qid now = do + lmsConf <- getsYesod $ view _appLmsConf + let cutoff_seen_first = addLocalDays (negate $ lmsConf ^. _lmsOrphanDeletionDays) now + cutoff_deleted_last = addHours (negate $ lmsConf ^. _lmsOrphanRepeatHours) now + cutoff_seen_last = cutoff_deleted_last + orphan_max_batch = lmsConf ^. _lmsOrphanDeletionBatch + $(E.unValueN 2) <<$>> (Ex.select $ do + orv <- Ex.from $ Ex.table @LmsOrphan + Ex.where_ $ Ex.val qid E.==. orv Ex.^. LmsOrphanQualification + Ex.&&. E.hasLetter (orv Ex.^. LmsOrphanIdent) -- do not delete LMS Test-users, FRADrive LMS Idents always contain at least one letter + Ex.&&. Ex.val cutoff_seen_first E.>=. orv Ex.^. LmsOrphanSeenFirst -- has been seen for while + Ex.&&. Ex.val cutoff_seen_last E.<=. orv Ex.^. LmsOrphanSeenLast -- was still seen recently + Ex.&&. Ex.val cutoff_deleted_last E.>~. orv Ex.^. LmsOrphanDeletedLast -- not already recently deleted + Ex.&&. Ex.notExists (do -- not currently used anywhere (LmsIdent share the namespace) + lusr <- Ex.from $ Ex.table @LmsUser + Ex.where_ $ lusr Ex.^. LmsUserIdent E.==. orv Ex.^.LmsOrphanIdent + ) + Ex.orderBy [Ex.desc $ orv Ex.^. LmsOrphanDeletedLast, Ex.asc $ orv Ex.^. LmsOrphanSeenLast] -- Note for PostgreSQL: DESC == DESC NULLS FIRST + Ex.limit orphan_max_batch + return (orv E.^. LmsOrphanId, orv E.^. LmsOrphanIdent) + ) + getLmsLearnersDirectR :: SchoolId -> QualificationShorthand -> Handler TypedContent getLmsLearnersDirectR sid qsh = do - $logInfoS "LMS" $ "Direct Download Users for " <> tshow qsh <> " at " <> tshow sid - (lms_users,cutoff,qshs) <- runDB $ do + -- $logInfoS "LMS" $ "Direct Download Users for " <> tshow qsh <> " at " <> tshow sid + (lms_users, orphans, cutoff, qshs) <- runDB $ do (qid, cutoff) <- getQidCutoff sid qsh qidsReuse <- selectList [QualificationLmsReuses ==. Just qid] [] let qids = qid : (entityKey <$> qidsReuse) @@ -185,8 +224,6 @@ getLmsLearnersDirectR sid qsh = do , LmsUserEnded ==. Nothing -- , LmsUserReceived ==. Nothing ||. LmsUserResetPin ==. True ||. LmsUserStatus !=. Nothing -- send delta only NOTE: know-how no longer expects delta ] [Asc LmsUserStarted, Asc LmsUserIdent] - return (lms_users, cutoff, qshs) - {- To avoid exporting unneeded columns, we would need an SqlSelect instance for LmsUserTableCsv; probably not worth it Ex.select $ do lmsuser <- Ex.from $ Ex.table @LmsUser @@ -200,11 +237,16 @@ getLmsLearnersDirectR sid qsh = do , csvLUTstaff = LmsBool False } -} + now <- liftIO getCurrentTime + orphans <- selectOrphans qid now + updateWhere [LmsOrphanId <-. map fst orphans] [LmsOrphanDeletedLast =. Just now] + return (lms_users, orphans, cutoff, qshs) + LmsConf{..} <- getsYesod $ view _appLmsConf let --csvRenderedData = toNamedRecord . lmsUser2csv . entityVal <$> lms_users --csvRenderedHeader = lmsUserTableCsvHeader --cvsRendered = CsvRendered {..} - csvRendered = toCsvRendered lmsUserTableCsvHeader $ lmsUser2csv cutoff . entityVal <$> lms_users + csvRendered = toCsvRendered lmsUserTableCsvHeader $ (lmsUser2csv cutoff . entityVal <$> lms_users) <> (lmsUserDelete2csv . snd <$> orphans) fmtOpts = (review csvPreset CsvPresetRFC) { csvIncludeHeader = lmsDownloadHeader , csvDelimiter = lmsDownloadDelimiter @@ -212,11 +254,86 @@ getLmsLearnersDirectR sid qsh = do } csvOpts = def { csvFormat = fmtOpts } csvSheetName <- csvFilenameLmsUser qsh - let nr = length lms_users - msg = "Success. LMS user learners download file " <> csvSheetName <> " containing " <> tshow nr <> " rows for Qualifications " <> Text.intercalate ", " (ciOriginal <$> qshs) + let nr = length lms_users + orv_nr = length orphans + msg0 = "Success. LMS learners direct download file " <> csvSheetName <> " containing " <> tshow nr <> " rows for Qualifications " <> Text.intercalate ", " (ciOriginal <$> qshs) + msg1 = ". Orphaned LMS idents marked for deletion: " <> tshow orv_nr + msg = if orv_nr > 0 then msg0 <> msg1 else msg1 $logInfoS "LMS" msg addHeader "Content-Disposition" $ "attachment; filename=\"" <> csvSheetName <> "\"" csvRenderedToTypedContentWith csvOpts csvSheetName csvRendered <* runDB (logInterface "LMS" (ciOriginal qsh) True (Just nr) "") -- direct Download see: --- https://ersocon.net/blog/2017/2/22/creating-csv-files-in-yesod \ No newline at end of file +-- https://ersocon.net/blog/2017/2/22/creating-csv-files-in-yesod + + +getLmsOrphansR :: SchoolId -> QualificationShorthand -> Handler Html +getLmsOrphansR sid qsh = do + orvTable <- runDB $ do + qid <- getKeyBy404 $ SchoolQualificationShort sid qsh + let + orvDBTable = DBTable{..} + where + queryOrphan = id + -- resultOrphan = _dbrOutput . _entityVal -- would need explicit type to work + dbtSQLQuery orv = do + E.where_ $ orv E.^. LmsOrphanQualification E.==. E.val qid + return orv + dbtRowKey = (E.^. LmsOrphanId) + dbtProj = dbtProjId + dbtColonnade = dbColonnade $ mconcat + [ sortable (Just "ident") (i18nCell MsgTableLmsIdent) $ \(view $ _dbrOutput . _entityVal . _lmsOrphanIdent . _getLmsIdent -> lid) -> textCell lid + , sortable (Just "seen-first") (i18nCell MsgLmsOrphanSeenFirst) $ \(view $ _dbrOutput . _entityVal . _lmsOrphanSeenFirst -> d) -> dateTimeCell d + , sortable (Just "seen-last") (i18nCell MsgLmsOrphanSeenLast) $ \(view $ _dbrOutput . _entityVal . _lmsOrphanSeenLast -> d) -> dateTimeCell d + , sortable (Just "deleted-last") (i18nCell MsgLmsOrphanDeletedLast) $ \(view $ _dbrOutput . _entityVal . _lmsOrphanDeletedLast -> d) -> cellMaybe dateTimeCell d + , sortable (Just "status") (i18nCell MsgTableLmsStatus) $ \(view $ _dbrOutput . _entityVal . _lmsOrphanResultLast -> s) -> lmsStateCell s + , sortable (Just "reason") (i18nCell MsgLmsOrphanReason) $ \(view $ _dbrOutput . _entityVal . _lmsOrphanReason -> t) -> cellMaybe textCell t + ] + dbtSorting = Map.fromList + [ ("ident" , SortColumn (E.^. LmsOrphanIdent)) + , ("seen-first" , SortColumn (E.^. LmsOrphanSeenFirst)) + , ("seen-last" , SortColumn (E.^. LmsOrphanSeenLast)) + , ("deleted-last" , SortColumn (E.^. LmsOrphanDeletedLast)) + , ("status" , SortColumn (E.^. LmsOrphanResultLast)) + , ("reason" , SortColumn (E.^. LmsOrphanReason)) + ] + cachedNextOrphans = $(memcachedByHere) (Just $ Right $ 1 * diffMinute) ("cache-next-orphans" <> tshow qid) $ do + now <- liftIO getCurrentTime + next_orphans <- runDBRead $ selectOrphans qid now -- only query next orphans when really needed; not sure how to formulate a proper sub-query here + -- addMessageI Info $ MsgLmsOrphanNr $ length next_orphans -- debug + return $ map fst next_orphans + dbtFilter = Map.fromList + [ ("ident" , FilterColumn $ E.mkContainsFilterWithCommaPlus LmsIdent (E.^. LmsOrphanIdent)) + , ("reason" , FilterColumn $ E.mkContainsFilterWith Just (E.^. LmsOrphanReason)) + , ("preview" , FilterColumnHandler $ \case + (x:_) + | x == tshow True -> do + next_orphans <- cachedNextOrphans + return $ \row -> (queryOrphan row E.^. LmsOrphanId) `E.in_` E.valList next_orphans + | x == tshow False -> do + next_orphans <- cachedNextOrphans + return $ \row -> (queryOrphan row E.^. LmsOrphanId) `E.notIn` E.valList next_orphans + _ -> return (const E.true) + ) + ] + -- checkBoxTextField = convertField show (\case { t | t == show True -> True; _ -> False }) checkBoxField -- UNNECESSARY hack to use FilterColumnHandler, which only works on [Text] criteria + dbtFilterUI mPrev = mconcat + [ -- prismAForm (singletonFilter "preview" . maybePrism _PathPiece) mPrev $ aopt checkBoxField (fslI MsgLmsOrphanPreviewFltr) -- NOTE: anticipated checkBoxTextField-hack not needed here + prismAForm (singletonFilter "preview" . maybePrism _PathPiece) mPrev $ aopt boolField' (fslI MsgLmsOrphanPreviewFltr) + , prismAForm (singletonFilter "reason" . maybePrism _PathPiece) mPrev $ aopt textField (fslI MsgLmsOrphanReason) + , prismAForm (singletonFilter "ident" . maybePrism _PathPiece) mPrev $ aopt textField (fslI MsgTableLmsIdent & setTooltip MsgTableFilterCommaPlus) + ] + dbtStyle = def { dbsFilterLayout = defaultDBSFilterLayout } + dbtParams = def + dbtIdent :: Text + dbtIdent = "lms-orphans" + dbtCsvEncode = noCsvEncode + dbtCsvDecode = Nothing + dbtExtraReps = [] + orvDBTableValidator = def & defaultSorting [SortAscBy "seen-first", SortDescBy "deleted-last"] + snd <$> (dbTable orvDBTableValidator orvDBTable :: DB (Any, Widget)) + + LmsConf{..} <- getsYesod $ view _appLmsConf + siteLayoutMsg MsgLmsOrphans $ do + setTitleI MsgLmsOrphans + $(i18nWidgetFile "lms-orphans") diff --git a/src/Handler/LMS/Report.hs b/src/Handler/LMS/Report.hs index c360c3eb9..66d846232 100644 --- a/src/Handler/LMS/Report.hs +++ b/src/Handler/LMS/Report.hs @@ -64,15 +64,12 @@ instance FromNamedRecord LmsReportTableCsv where <*> csv Csv..: csvLmsLock instance CsvColumnsExplained LmsReportTableCsv where - csvColumnsExplanations _ = mconcat - [ single csvLmsIdent MsgCsvColumnLmsIdent - , single csvLmsDate MsgCsvColumnLmsDate - , single csvLmsResult MsgCsvColumnLmsResult - , single csvLmsLock MsgCsvColumnLmsLock + csvColumnsExplanations _ = Map.fromList + [ (csvLmsIdent , msg2widget MsgCsvColumnLmsIdent) + , (csvLmsDate , msg2widget MsgCsvColumnLmsDate) + , (csvLmsResult , msg2widget MsgCsvColumnLmsResult) + , (csvLmsLock , msg2widget MsgCsvColumnLmsLock) ] - where - single :: RenderMessage UniWorX msg => Csv.Name -> msg -> Map Csv.Name Widget - single k v = singletonMap k [whamlet|_{v}|] data LmsReportCsvActionClass = LmsReportInsert | LmsReportUpdate deriving (Eq, Ord, Read, Show, Generic, Enum, Bounded) diff --git a/src/Handler/LMS/Users.hs b/src/Handler/LMS/Users.hs index b5f534b5a..d97278a24 100644 --- a/src/Handler/LMS/Users.hs +++ b/src/Handler/LMS/Users.hs @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2022-23 Steffen Jost +-- SPDX-FileCopyrightText: 2022-2025 Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later @@ -68,24 +68,22 @@ instance FromNamedRecord LmsUserTableCsv where <*> csv Csv..: csvLmsStaff instance CsvColumnsExplained LmsUserTableCsv where - csvColumnsExplanations _ = mconcat - [ single csvLmsIdent MsgCsvColumnLmsIdent - , single csvLmsPin MsgCsvColumnLmsPin - , single csvLmsResetPin MsgCsvColumnLmsResetPin - , single csvLmsDelete MsgCsvColumnLmsDelete - , single csvLmsStaff MsgCsvColumnLmsStaff + csvColumnsExplanations _ = Map.fromList + [ (csvLmsIdent , msg2widget MsgCsvColumnLmsIdent) + , (csvLmsPin , msg2widget MsgCsvColumnLmsPin) + , (csvLmsResetPin , msg2widget MsgCsvColumnLmsResetPin) + , (csvLmsDelete , msg2widget MsgCsvColumnLmsDelete) + , (csvLmsStaff , msg2widget MsgCsvColumnLmsStaff) ] - where - single :: RenderMessage UniWorX msg => Csv.Name -> msg -> Map Csv.Name Widget - single k v = singletonMap k [whamlet|_{v}|] - -mkUserTable :: SchoolId -> QualificationShorthand -> QualificationId -> DB (Any, Widget) -mkUserTable _sid qsh qid = do - cutoff <- liftHandler $ lmsDeletionDate Nothing - dbtCsvName <- csvFilenameLmsUser qsh +mkUserTable :: SchoolId -> Entity Qualification -> DB (Any, Widget) +mkUserTable _sid Entity{entityKey=qid, entityVal=quali} = do + let qsh = qualificationShorthand quali + dbtCsvName <- csvFilenameLmsUser qsh + now <- liftIO getCurrentTime let dbtCsvSheetName = dbtCsvName + cutoff = lmsDeletionDate now $ qualificationAuditDuration quali let userDBTable = DBTable{..} where @@ -100,7 +98,7 @@ mkUserTable _sid qsh qid = do , sortable (Just csvLmsPin) (i18nCell MsgTableLmsPin & cellAttrs <>~ [("uw-hide-column-default-hidden",mempty)] ) $ \(view $ _dbrOutput . _entityVal . _lmsUserPin -> pin ) -> textCell pin , sortable (Just csvLmsResetPin) (i18nCell MsgTableLmsResetPin) $ \(view $ _dbrOutput . _entityVal . _lmsUserResetPin -> reset) -> ifIconCell reset IconReset - , sortable (Just csvLmsDelete) (i18nCell MsgTableLmsDelete) $ \(view $ _dbrOutput . _entityVal . _lmsUserToDelete cutoff -> del ) -> ifIconCell del IconRemoveUser + , sortable (Just csvLmsDelete) (i18nCell MsgTableLmsDelete) $ \(view $ _dbrOutput . _entityVal . _lmsUserToDelete cutoff -> del ) -> ifIconCell del IconRemoveUser , sortable Nothing (i18nCell MsgTableLmsStaff) $ const mempty ] dbtSorting = Map.fromList @@ -145,8 +143,8 @@ mkUserTable _sid qsh qid = do getLmsUsersR :: SchoolId -> QualificationShorthand -> Handler Html getLmsUsersR sid qsh = do lmsTable <- runDB $ do - qid <- getKeyBy404 $ SchoolQualificationShort sid qsh - view _2 <$> mkUserTable sid qsh qid + qent <- getBy404 $ SchoolQualificationShort sid qsh + view _2 <$> mkUserTable sid qent siteLayoutMsg MsgMenuLmsUsers $ do setTitleI MsgMenuLmsUsers lmsTable @@ -154,13 +152,14 @@ getLmsUsersR sid qsh = do getLmsUsersDirectR :: SchoolId -> QualificationShorthand -> Handler TypedContent getLmsUsersDirectR sid qsh = do $logInfoS "LMS" $ "Direct Download Users for " <> tshow qsh <> " at " <> tshow sid - cutoff <- lmsDeletionDate Nothing - lms_users <- runDB $ do - qid <- getKeyBy404 $ SchoolQualificationShort sid qsh - selectList [ LmsUserQualification ==. qid - , LmsUserEnded ==. Nothing - -- , LmsUserReceived ==. Nothing ||. LmsUserResetPin ==. True ||. LmsUserStatus !=. Nothing -- send delta only NOTE: know-how no longer expects delta - ] [Asc LmsUserStarted, Asc LmsUserIdent] + now <- liftIO getCurrentTime + (cutoff, lms_users) <- runDB $ do + Entity{entityKey=qid, entityVal=quali} <- getBy404 $ SchoolQualificationShort sid qsh + (lmsDeletionDate now (qualificationAuditDuration quali),) <$> + selectList [ LmsUserQualification ==. qid + , LmsUserEnded ==. Nothing + -- , LmsUserReceived ==. Nothing ||. LmsUserResetPin ==. True ||. LmsUserStatus !=. Nothing -- send delta only NOTE: know-how no longer expects delta + ] [Asc LmsUserStarted, Asc LmsUserIdent] {- To avoid exporting unneeded columns, we would need an SqlSelect instance for LmsUserTableCsv; probably not worth it Ex.select $ do @@ -175,7 +174,7 @@ getLmsUsersDirectR sid qsh = do , csvLUTstaff = LmsBool False } -} - LmsConf{..} <- getsYesod $ view _appLmsConf + LmsConf{..} <- getsYesod $ view _appLmsConf let --csvRenderedData = toNamedRecord . lmsUser2csv . entityVal <$> lms_users --csvRenderedHeader = lmsUserTableCsvHeader --cvsRendered = CsvRendered {..} @@ -188,10 +187,10 @@ getLmsUsersDirectR sid qsh = do csvOpts = def { csvFormat = fmtOpts } csvSheetName <- csvFilenameLmsUser qsh let nr = length lms_users - msg = "Success. LMS Users download file " <> csvSheetName <> " containing " <> tshow nr <> " rows" + msg = "Success. LMS Users download file " <> csvSheetName <> " containing " <> tshow nr <> " rows" $logInfoS "LMS" msg addHeader "Content-Disposition" $ "attachment; filename=\"" <> csvSheetName <> "\"" - csvRenderedToTypedContentWith csvOpts csvSheetName csvRendered + csvRenderedToTypedContentWith csvOpts csvSheetName csvRendered -- direct Download see: -- https://ersocon.net/blog/2017/2/22/creating-csv-files-in-yesod \ No newline at end of file diff --git a/src/Handler/MailCenter.hs b/src/Handler/MailCenter.hs index f84cf4ec7..c6871eeb7 100644 --- a/src/Handler/MailCenter.hs +++ b/src/Handler/MailCenter.hs @@ -19,7 +19,7 @@ import qualified Data.Map as Map -- import qualified Data.Text as Text -- import Database.Persist.Sql (updateWhereCount) --- import Database.Esqueleto.Experimental ((:&)(..)) +import Database.Esqueleto.Experimental ((:&)(..)) import qualified Database.Esqueleto.Legacy as EL (on) -- only `on` and `from` are different, needed for dbTable using Esqueleto.Legacy import qualified Database.Esqueleto.Experimental as E import qualified Database.Esqueleto.Utils as E @@ -41,13 +41,7 @@ import qualified Data.ByteString.Lazy as LB import Handler.Utils - --- avoids repetition of local definitions -single :: (k,a) -> Map k a -single = uncurry Map.singleton - - -data MCTableAction = MCActDummy -- just a dummy, since we don't now yet which actions we will be needing +data MCTableAction = MCActResendEmail deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic) instance Universe MCTableAction @@ -55,10 +49,29 @@ instance Finite MCTableAction nullaryPathPiece ''MCTableAction $ camelToPathPiece' 2 embedRenderMessage ''UniWorX ''MCTableAction id -data MCTableActionData = MCActDummyData +newtype MCTableActionData = MCActResendEmailData UserEmail deriving (Eq, Ord, Read, Show, Generic) +resendMailTo :: (MonoFoldable mono, Element mono ~ SentMailId) => UserEmail -> mono -> Handler () +resendMailTo recv smids = do + (recvName, mails) <- runDBRead $ (,) + <$> (userDisplayName . entityVal <<$>> getByFilter ([UserEmail ==. recv] ||. [UserDisplayEmail ==. recv])) + <*> E.select (do + (sm :& smc) <- E.from $ E.table @SentMail `E.innerJoin` E.table @SentMailContent `E.on` (\(sm :& smc) -> sm E.^. SentMailContentRef E.==. smc E.^. SentMailContentId) + E.where_ $ sm E.^. SentMailId `E.in_` E.vals smids + return (sm, smc) + ) + forM_ mails $ \(Entity {entityVal=SentMail{..}}, Entity{entityVal=SentMailContent{sentMailContentContent=content}}) -> do + let mailParts = getMailContent content + mailTo = [] + mailCc = [] + mailBcc = [Address{addressName = recvName, addressEmail = ciOriginal recv}] + mailFrom = error "Handler.MailCenter.resenMailTo: mailFrom not replaced by sendSimpleMail" -- :: Address -- will be filled in later by sendSimpleMail + mailHeaders = toHeaders sentMailHeaders -- :: Headers -- keep as it was? Includes To/Cc/Bcc + sendSimpleMail Mail{..} + + type MCTableExpr = ( E.SqlExpr (Entity SentMail) `E.LeftOuterJoin` E.SqlExpr (Maybe (Entity User)) @@ -101,21 +114,29 @@ mkMCTable = do -- , sortable Nothing (i18nCell MsgCommContent) $ \(view $ resultMail . _entityKey -> k) -> anchorCellM (MailHtmlR <$> encrypt k) (text2widget "html") -- , sortable Nothing (i18nCell MsgCommSubject) $ \(preview $ resultMail . _entityVal . _sentMailHeaders . _mailHeaders' . _mailHeader' "Subject" -> h) -> cellMaybe textCell h ] - dbtSorting = mconcat - [ single ("sent" , SortColumn $ queryMail >>> (E.^. SentMailSentAt)) - , single ("recipient" , sortUserNameBareM queryRecipient) + dbtSorting = Map.fromList + [ ("sent" , SortColumn $ queryMail >>> (E.^. SentMailSentAt)) + , ("recipient" , sortUserNameBareM queryRecipient) ] - dbtFilter = mconcat - [ single ("sent" , FilterColumn . E.mkDayFilterTo $ views (to queryMail) (E.^. SentMailSentAt)) - , single ("recipient" , FilterColumn . E.mkContainsFilterWithCommaPlus Just $ views (to queryRecipient) (E.?. UserDisplayName)) - , single ("subject" , FilterColumn . E.mkContainsFilterWithCommaPlus id $ views (to queryMail) (E.str2text . (E.^. SentMailHeaders))) - -- , single ("regex" , FilterColumn . E.mkRegExFilterWith id $ views (to queryMail) (E.str2text . (E.^. SentMailHeaders))) + dbtFilter = Map.fromList + [ ("sentTo" , FilterColumn . E.mkDayFilterTo $ views (to queryMail) (E.^. SentMailSentAt)) + , ("sentFrom" , FilterColumn . E.mkDayFilterFrom $ views (to queryMail) (E.^. SentMailSentAt)) + , ("recipient" , FilterColumn . E.mkContainsFilterWithCommaPlus Just $ views (to queryRecipient) (E.?. UserDisplayName)) + , ("subject" , FilterColumn . E.mkContainsFilterWithCommaPlus id $ views (to queryMail) (E.str2text . (E.^. SentMailHeaders))) + -- , ("regex" , FilterColumn . E.mkRegExFilterWith id $ views (to queryMail) (E.str2text . (E.^. SentMailHeaders))) + , ("content" , FilterColumn . E.mkExistsFilter $ \row (criterion :: Text) -> do + body <- E.from $ E.table @SentMailContent + E.where_ $ body E.^. SentMailContentId E.==. queryMail row E.^. SentMailContentRef + E.&&. E.mailContentContains (body E.^. SentMailContentContent) (E.val criterion) + ) ] dbtFilterUI mPrev = mconcat - [ prismAForm (singletonFilter "sent" . maybePrism _PathPiece) mPrev $ aopt (hoistField lift dayField) (fslI MsgPrintJobCreated) + [ prismAForm (singletonFilter "sentTo" . maybePrism _PathPiece) mPrev $ aopt (hoistField lift dayField) (fslI MsgTableFilterSentBefore) + , prismAForm (singletonFilter "sentFrom" . maybePrism _PathPiece) mPrev $ aopt (hoistField lift dayField) (fslI MsgTableFilterSentAfter) , prismAForm (singletonFilter "recipient" . maybePrism _PathPiece) mPrev $ aopt (hoistField lift textField) (fslI MsgPrintRecipient & setTooltip MsgTableFilterCommaPlus) , prismAForm (singletonFilter "subject" . maybePrism _PathPiece) mPrev $ aopt (hoistField lift textField) (fslI MsgCommSubject & setTooltip MsgTableFilterCommaPlusShort) -- , prismAForm (singletonFilter "regex" . maybePrism _PathPiece) mPrev $ aopt (hoistField lift textField) (fslI MsgCommSubject ) + , prismAForm (singletonFilter "content" . maybePrism _PathPiece) mPrev $ aopt (hoistField lift textField) (fslI MsgCommContent) -- & setTooltip MsgCommContentSearch) ] dbtStyle = def { dbsFilterLayout = defaultDBSFilterLayout} dbtIdent :: Text @@ -127,17 +148,16 @@ mkMCTable = do { dbParamsFormMethod = POST , dbParamsFormAction = Nothing -- Just $ SomeRoute currentRoute , dbParamsFormAttrs = [] - , dbParamsFormSubmit = FormNoSubmit - , dbParamsFormAdditional = \_csrf -> return (FormMissing, mempty) - -- , dbParamsFormSubmit = FormSubmit - -- , dbParamsFormAdditional - -- = let acts :: Map MCTableAction (AForm Handler MCTableActionData) - -- acts = mconcat - -- [ singletonMap MCActDummy $ pure MCActDummyData - -- ] - -- in renderAForm FormStandard - -- $ (, mempty) . First . Just - -- <$> multiActionA acts (fslI MsgTableAction) Nothing + , dbParamsFormSubmit = FormSubmit + , dbParamsFormAdditional + = let acts :: Map MCTableAction (AForm Handler MCTableActionData) + acts = mconcat + [ singletonMap MCActResendEmail $ MCActResendEmailData + <$> areq (emailField & cfStrip & cfCI) (fslI MsgMCActResendEmail & setTooltip MsgMCActResendEmailTooltip) Nothing + ] + in renderAForm FormStandard + $ (, mempty) . First . Just + <$> multiActionA acts (fslI MsgTableAction) Nothing , dbParamsFormEvaluate = liftHandler . runFormPost , dbParamsFormResult = id , dbParamsFormIdent = def @@ -156,8 +176,9 @@ getMailCenterR = postMailCenterR postMailCenterR = do (mcRes, mcTable) <- runDB mkMCTable formResult mcRes $ \case - (MCActDummyData, Set.toList -> _smIds) -> do - addMessageI Success MsgBoolIrrelevant + (MCActResendEmailData recv, smIds) -> do + resendMailTo recv smIds + addMessageI (bool Success Error $ null smIds) $ MsgMCActResendEmailInfo (Set.size smIds) (ciOriginal recv) reloadKeepGetParams MailCenterR siteLayoutMsg MsgMenuMailCenter $ do setTitleI MsgMenuMailCenter @@ -186,10 +207,10 @@ getMailAttachmentR cusm attdisp = do _ -> notFound getMailHtmlR :: CryptoUUIDSentMail -> Handler Html -getMailHtmlR = handleMailShow (SomeMessages [SomeMessage MsgUtilEMail, SomeMessage MsgMenuMailHtml]) [typeHtml,typePlain] +getMailHtmlR = handleMailShow (SomeMsgs [SomeMessage MsgUtilEMail, SomeMessage MsgMenuMailHtml]) [typeHtml,typePlain] getMailPlainR :: CryptoUUIDSentMail -> Handler Html -getMailPlainR = handleMailShow (SomeMessages [SomeMessage MsgUtilEMail, SomeMessage MsgMenuMailPlain]) [typePlain,typeHtml] +getMailPlainR = handleMailShow (SomeMsgs [SomeMessage MsgUtilEMail, SomeMessage MsgMenuMailPlain]) [typePlain,typeHtml] handleMailShow :: _ -> [ContentType] -> CryptoUUIDSentMail -> Handler Html handleMailShow hdr prefTypes cusm = do diff --git a/src/Handler/News.hs b/src/Handler/News.hs index 2ac689c39..e8c6d920a 100644 --- a/src/Handler/News.hs +++ b/src/Handler/News.hs @@ -153,7 +153,7 @@ newsUpcomingSheets uid = do , sortable (Just "sheet") (i18nCell MsgTableSheet) $ \DBRow{ dbrOutput=(E.Value tid, E.Value ssh, E.Value csh, E.Value shn, _, _) } -> anchorCell (CSheetR tid ssh csh shn SShowR) shn , sortable (Just "deadline") (i18nCell MsgDeadline) $ \DBRow{ dbrOutput=(_, _, _, _, E.Value mDeadline, _) } -> - maybe mempty (cell . formatTimeW SelFormatDateTime) mDeadline + cellMaybe dateTimeCell mDeadline , sortable (Just "done") (i18nCell MsgDone) $ \DBRow{ dbrOutput=(E.Value tid, E.Value ssh, E.Value csh, E.Value shn, _, E.Value mbsid) } -> case mbsid of Nothing -> cell $ do @@ -277,9 +277,9 @@ newsUpcomingExams uid = do , sortable (Just "register-to") (i18nCell MsgTableExamRegisterTo) $ \DBRow { dbrOutput = view lensExam -> Entity _ Exam{..} } -> maybe mempty dateTimeCell examRegisterTo , sortable (Just "time") (i18nCell MsgTableExamTime) $ \DBRow{ dbrOutput } -> if | Just (Entity _ ExamOccurrence{..}) <- preview lensOccurrence dbrOutput - -> cell $ formatTimeRangeW SelFormatDateTime examOccurrenceStart examOccurrenceEnd + -> rangeCell examOccurrenceStart examOccurrenceEnd | Entity _ Exam{..} <- view lensExam dbrOutput - , Just start <- examStart -> cell $ formatTimeRangeW SelFormatDateTime start examEnd + , Just start <- examStart -> rangeCell start examEnd | otherwise -> mempty {- NOTE: We do not want thoughtless exam registrations, since many people click "register" and don't show up, causing logistic problems. Hence we force them here to click twice. Maybe add a captcha where users have to distinguish pictures showing pink elephants and course lecturers. diff --git a/src/Handler/PrintCenter.hs b/src/Handler/PrintCenter.hs index 43af0bff9..ecb5c143d 100644 --- a/src/Handler/PrintCenter.hs +++ b/src/Handler/PrintCenter.hs @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2022-24 Sarah Vaupel ,Steffen Jost ,Steffen Jost +-- SPDX-FileCopyrightText: 2022-2025 Sarah Vaupel ,Steffen Jost ,Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later @@ -28,7 +28,7 @@ import Database.Esqueleto.Utils.TH import Utils.Print import qualified Data.Aeson as Aeson -import qualified Data.Text as Text +-- import qualified Data.Text as Text -- import qualified Data.Set as Set import Handler.Utils @@ -37,17 +37,12 @@ import Handler.Utils import qualified Data.CaseInsensitive as CI import Jobs.Queue - - --- avoids repetition of local definitions -single :: (k,a) -> Map k a -single = uncurry Map.singleton - +-- import qualified Control.Monad.State.Class as State -- for debug only data LRQF = LRQF { lrqfLetter :: Text - , lrqfUser :: Either UserEmail UserId - , lrqfSuper :: Maybe (Either UserEmail UserId) + , lrqfUser :: UserId + , lrqfSuper :: Maybe UserId , lrqfQuali :: Entity Qualification , lrqfIdent :: LmsIdent , lrqfPin :: Text @@ -59,28 +54,29 @@ makeRenewalForm :: Maybe LRQF -> Form LRQF makeRenewalForm tmpl = identifyForm FIDLmsLetter . validateForm validateLetterRenewQualification $ \html -> do -- now_day <- utctDay <$> liftIO getCurrentTime flip (renderAForm FormStandard) html $ LRQF - <$> areq textField (fslI MsgPrintLetterType) (lrqfLetter <$> tmpl) - <*> areq (userField False Nothing) (fslI MsgLmsUser) (lrqfUser <$> tmpl) - <*> aopt (userField False Nothing) (fslI MsgTableSupervisor) (lrqfSuper <$> tmpl) - <*> areq qualificationFieldEnt (fslI MsgQualificationName) (lrqfQuali <$> tmpl) - <*> areq lmsField (fslI MsgTableLmsIdent) (lrqfIdent <$> tmpl) - <*> areq textField (fslI MsgTableLmsPin) (lrqfPin <$> tmpl) - <*> aopt dayField (fslI MsgLmsQualificationValidUntil) (lrqfExpiry <$> tmpl) + <$> areq textField (fslI MsgPrintLetterType) (lrqfLetter <$> tmpl) + <*> areq (knownUserField False Nothing) (fslI MsgLmsUser) (lrqfUser <$> tmpl) + <*> aopt (knownUserField False Nothing) (fslI MsgTableSupervisor) (lrqfSuper <$> tmpl) + <*> areq qualificationFieldEnt (fslI MsgQualificationName) (lrqfQuali <$> tmpl) + <*> areq lmsField (fslI MsgTableLmsIdent) (lrqfIdent <$> tmpl) + <*> areq textField (fslI MsgTableLmsPin) (lrqfPin <$> tmpl) + <*> aopt dayField (fslI MsgLmsQualificationValidUntil) (lrqfExpiry <$> tmpl) <*> areq (boolField . Just $ SomeMessage MsgBoolIrrelevant) - (fslI MsgLmsRenewalReminder) (lrqfReminder <$> tmpl) + (fslI MsgLmsRenewalReminder) (lrqfReminder <$> tmpl) where lmsField = convertField LmsIdent getLmsIdent textField validateLetterRenewQualification :: FormValidator LRQF Handler () -validateLetterRenewQualification = -- do +validateLetterRenewQualification = do -- LRQF{..} <- State.get + -- liftHandler $ addMessage Warning $ text2Html $ "Validate called:" <> tshow lrqfUser -- DEBUG return () lrqf2letter :: LRQF -> DB (Entity User, SomeLetter) lrqf2letter LRQF{..} | lrqfLetter == "r" = do - usr <- getUser lrqfUser - rcvr <- mapM getUser lrqfSuper + usr <- getEntity404 lrqfUser + rcvr <- mapM getEntity404 lrqfSuper now <- liftIO getCurrentTime let letter = LetterRenewQualification { lmsLogin = lrqfIdent @@ -100,8 +96,8 @@ lrqf2letter LRQF{..} } return (fromMaybe usr rcvr, SomeLetter letter) | lrqfLetter == "e" || lrqfLetter == "E" = do - rcvr <- mapM getUser lrqfSuper - usr <- getUser lrqfUser + rcvr <- mapM getEntity404 lrqfSuper + usr <- getEntity404 lrqfUser usrShrt <- encrypt $ entityKey usr usrUuid <- encrypt $ entityKey usr urender <- liftHandler getUrlRender @@ -119,10 +115,10 @@ lrqf2letter LRQF{..} } return (fromMaybe usr rcvr, SomeLetter letter) | otherwise = error "Unknown Letter Type encountered. Use 'e' or 'r' only." - where - getUser :: Either UserEmail UserId -> DB (Entity User) - getUser (Right uid) = getEntity404 uid - getUser (Left mail) = getBy404 $ UniqueEmail mail + -- where + -- getUser :: Either UserEmail UserId -> DB (Entity User) + -- getUser (Right uid) = getEntity404 uid + -- getUser (Left mail) = getBy404 $ UniqueEmail mail data PJTableAction = PJActAcknowledge | PJActReprint @@ -224,33 +220,33 @@ mkPJTable = do , sortable (Just "qualification")(i18nCell MsgPrintQualification) $ \(preview $ resultQualification . _entityVal -> q) -> maybeCell q qualificationCell , sortable (Just "lmsid") (i18nCell MsgPrintLmsUser) $ \( view $ resultPrintJob . _entityVal . _printJobLmsUser -> l) -> foldMap (textCell . getLmsIdent) l ] - dbtSorting = mconcat - [ single ("name" , SortColumn $ queryPrintJob >>> (E.^. PrintJobName)) - , single ("filename" , SortColumn $ queryPrintJob >>> (E.^. PrintJobFilename)) - , single ("created" , SortColumn $ queryPrintJob >>> (E.^. PrintJobCreated)) - , single ("acknowledged" , SortColumn $ queryPrintJob >>> (E.^. PrintJobAcknowledged)) - , single ("apcid" , SortColumn $ queryPrintJob >>> (E.^. PrintJobApcIdent)) - , single ("recipient" , sortUserNameBareM queryRecipient) - , single ("affected" , sortUserNameBareM queryAffected) - , single ("sender" , sortUserNameBareM querySender ) - , single ("course" , SortColumn $ queryCourse >>> (E.?. CourseName)) - , single ("qualification", SortColumn $ queryQualification >>> (E.?. QualificationName)) - , single ("lmsid" , SortColumn $ queryPrintJob >>> (E.^. PrintJobLmsUser)) + dbtSorting = Map.fromList + [ ("name" , SortColumn $ queryPrintJob >>> (E.^. PrintJobName)) + , ("filename" , SortColumn $ queryPrintJob >>> (E.^. PrintJobFilename)) + , ("created" , SortColumn $ queryPrintJob >>> (E.^. PrintJobCreated)) + , ("acknowledged" , SortColumn $ queryPrintJob >>> (E.^. PrintJobAcknowledged)) + , ("apcid" , SortColumn $ queryPrintJob >>> (E.^. PrintJobApcIdent)) + , ("recipient" , sortUserNameBareM queryRecipient) + , ("affected" , sortUserNameBareM queryAffected ) + , ("sender" , sortUserNameBareM querySender ) + , ("course" , SortColumn $ queryCourse >>> (E.?. CourseName)) + , ("qualification", SortColumn $ queryQualification >>> (E.?. QualificationName)) + , ("lmsid" , SortColumn $ queryPrintJob >>> (E.^. PrintJobLmsUser)) ] - dbtFilter = mconcat - [ single ("name" , FilterColumn . E.mkContainsFilterWithCommaPlus id $ views (to queryPrintJob) (E.^. PrintJobName)) - , single ("apcid" , FilterColumn . E.mkContainsFilterWithComma id $ views (to queryPrintJob) (E.^. PrintJobApcIdent)) - , single ("filename" , FilterColumn . E.mkContainsFilter $ views (to queryPrintJob) (E.^. PrintJobFilename)) - , single ("created" , FilterColumn . E.mkDayFilter $ views (to queryPrintJob) (E.^. PrintJobCreated)) - --, single ("created" , FilterColumn . E.mkDayBetweenFilter $ views (to queryPrintJob) (E.^. PrintJobCreated)) - , single ("recipient" , FilterColumn . E.mkContainsFilterWithCommaPlus Just $ views (to queryRecipient) (E.?. UserDisplayName)) - , single ("affected" , FilterColumn . E.mkContainsFilterWithCommaPlus Just $ views (to queryAffected) (E.?. UserDisplayName)) - , single ("sender" , FilterColumn . E.mkContainsFilterWithCommaPlus Just $ views (to querySender) (E.?. UserDisplayName)) - , single ("course" , FilterColumn . E.mkContainsFilterWith (Just . CI.mk) $ views (to queryCourse) (E.?. CourseName)) - , single ("qualification", FilterColumn . E.mkContainsFilterWith (Just . CI.mk) $ views (to queryQualification) (E.?. QualificationName)) - , single ("lmsid" , FilterColumn . E.mkContainsFilterWithCommaPlus (Just . LmsIdent) $ views (to queryPrintJob) (E.^. PrintJobLmsUser)) + dbtFilter = Map.fromList + [ ("name" , FilterColumn . E.mkContainsFilterWithCommaPlus id $ views (to queryPrintJob) (E.^. PrintJobName)) + , ("apcid" , FilterColumn . E.mkContainsFilterWithComma id $ views (to queryPrintJob) (E.^. PrintJobApcIdent)) + , ("filename" , FilterColumn . E.mkContainsFilter $ views (to queryPrintJob) (E.^. PrintJobFilename)) + , ("created" , FilterColumn . E.mkDayFilter $ views (to queryPrintJob) (E.^. PrintJobCreated)) + --, ("created" , FilterColumn . E.mkDayBetweenFilter $ views (to queryPrintJob) (E.^. PrintJobCreated)) + , ("recipient" , FilterColumn . E.mkContainsFilterWithCommaPlus Just $ views (to queryRecipient) (E.?. UserDisplayName)) + , ("affected" , FilterColumn . E.mkContainsFilterWithCommaPlus Just $ views (to queryAffected) (E.?. UserDisplayName)) + , ("sender" , FilterColumn . E.mkContainsFilterWithCommaPlus Just $ views (to querySender) (E.?. UserDisplayName)) + , ("course" , FilterColumn . E.mkContainsFilterWith (Just . CI.mk) $ views (to queryCourse) (E.?. CourseName)) + , ("qualification", FilterColumn . E.mkContainsFilterWith (Just . CI.mk) $ views (to queryQualification) (E.?. QualificationName)) + , ("lmsid" , FilterColumn . E.mkContainsFilterWithCommaPlus (Just . LmsIdent) $ views (to queryPrintJob) (E.^. PrintJobLmsUser)) - , single ("acknowledged" , FilterColumn . E.mkExactFilterLast $ views (to queryPrintJob) (E.isJust . (E.^. PrintJobAcknowledged))) + , ("acknowledged" , FilterColumn . E.mkExactFilterLast $ views (to queryPrintJob) (E.isJust . (E.^. PrintJobAcknowledged))) ] dbtFilterUI mPrev = mconcat [ prismAForm (singletonFilter "name" . maybePrism _PathPiece) mPrev $ aopt (hoistField lift textField) (fslI MsgPrintJobName & setTooltip MsgTableFilterCommaPlus) @@ -319,8 +315,7 @@ postPrintCenterR = do oks <- runDB $ forM pjIds $ fmap countOk . reprintPDF (fromMaybe False ignoreReroute) let nr_oks = getSum $ mconcat oks nr_tot = length pjIds - mstat = bool Warning Success $ nr_oks == nr_tot - addMessageI mstat $ MsgPrintJobReprint nr_oks nr_tot + addMessageOutOfI MsgPrintJobReprint nr_oks nr_tot reloadKeepGetParams PrintCenterR siteConf <- getYesod let lprConf = siteConf ^. _appLprConf @@ -346,7 +341,7 @@ postPrintSendR = do uid = usr ^. _entityKey mkLetter qual = LRQF { lrqfLetter = "r" - , lrqfUser = Right uid + , lrqfUser = uid , lrqfSuper = Nothing , lrqfQuali = qual , lrqfIdent = LmsIdent "stuvwxyz" @@ -456,7 +451,7 @@ postPrintAckR ackDay numAck chksm = do -- | otherwise = pure "ERROR" saveApcident :: UTCTime -> Natural -> Text -> JobDB Natural -saveApcident t i apci = insert_ (PrintAcknowledge (Text.strip apci) t False) >> return (succ i) +saveApcident t i apci = insert_ (PrintAcknowledge apci t False) >> return (succ i) makeAckUploadForm :: Form FileInfo @@ -524,23 +519,25 @@ getPrintLogR = do dbtIdent = "lpr-log" :: Text dbtSQLQuery l = do - E.where_ $ E.val "LPR" E.==. l E.^. TransactionLogInfo E.->>. "interface-name" - -- E.&&. E.val "interface" E.==. l E.^. TransactionLogInfo E.->>. "transaction" -- not necessary + E.where_ $ (l E.^. TransactionLogInfo E.->>. "interface-name") `E.in_` E.valList ["LPR", "LETTER","APC", "Printer"] + -- E.&&. E.val "interface" E.==. l E.^. TransactionLogInfo E.->>. "transaction" -- not necessary return l dbtRowKey = (E.^. TransactionLogId) dbtProj = dbtProjSimple $ \(Entity _ l) -> do return (l, Aeson.fromJSON $ transactionLogInfo l) dbtColonnade = dbColonnade $ mconcat - [ sortable (Just "time") (i18nCell MsgSystemMessageTimestamp) $ \(view $ resultLog . to transactionLogTime -> t) -> dateTimeCell t - , sortable (Just "status") (textCell "Status") $ tCell (cellMaybe iconBoolCell . transactionInterfaceSuccess) - , sortable (Just "subtype") (i18nCell MsgInterfaceSubtype) $ tCell ( textCell . transactionInterfaceSubtype) - , sortable (Just "info") (i18nCell MsgSystemMessageContent) $ tCellErr ( textCell . transactionInterfaceInfo) + [ sortable (Just "time") (i18nCell MsgSystemMessageTimestamp) $ \(view $ resultLog . to transactionLogTime -> t) -> dateTimeCell t + , sortable (Just "status") (textCell "Status" ) $ tCell (cellMaybe iconBoolCell . transactionInterfaceSuccess) + , sortable (Just "interface") (i18nCell MsgInterfaceName ) $ tCell ( textCell . transactionInterfaceName) + , sortable (Just "subtype") (i18nCell MsgInterfaceSubtype ) $ tCell ( textCell . transactionInterfaceSubtype) + , sortable (Just "info") (i18nCell MsgSystemMessageContent ) $ tCellErr ( textCell . transactionInterfaceInfo) ] dbtSorting = mconcat - [ singletonMap "time" $ SortColumn (E.^. TransactionLogTime) - , singletonMap "status" $ SortColumn (\r -> r E.^. TransactionLogTime E.->>. "interface-success") - , singletonMap "subtype" $ SortColumn (\r -> r E.^. TransactionLogTime E.->>. "interface-subtype") - , singletonMap "info" $ SortColumn (\r -> r E.^. TransactionLogTime E.->>. "interface-info" ) + [ singletonMap "time" $ SortColumn (E.^. TransactionLogTime) + , singletonMap "status" $ SortColumn (\r -> r E.^. TransactionLogInfo E.->>. "interface-success") + , singletonMap "interface" $ SortColumn (\r -> r E.^. TransactionLogInfo E.->>. "interface-name" ) + , singletonMap "subtype" $ SortColumn (\r -> r E.^. TransactionLogInfo E.->>. "interface-subtype") + , singletonMap "info" $ SortColumn (\r -> r E.^. TransactionLogInfo E.->>. "interface-info" ) ] dbtFilter = mempty dbtFilterUI = mempty diff --git a/src/Handler/Profile.hs b/src/Handler/Profile.hs index 86d880727..a24f4f9a3 100644 --- a/src/Handler/Profile.hs +++ b/src/Handler/Profile.hs @@ -1,9 +1,12 @@ --- SPDX-FileCopyrightText: 2022 Gregor Kleen ,Sarah Vaupel ,Sarah Vaupel ,Steffen Jost ,Winnie Ros +-- SPDX-FileCopyrightText: 2022-2025 Gregor Kleen ,Sarah Vaupel ,Sarah Vaupel ,Steffen Jost ,Winnie Ros ,Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later {-# OPTIONS_GHC -fno-warn-orphans #-} -- needed for HasEntity and HasUser instances +-- OPTIONS_GHC -fno-warn-unused-top-binds -- only for develop +-- OPTIONS_GHC -fno-warn-unused-local-binds -- only for develop + module Handler.Profile ( getProfileR, postProfileR , getForProfileR, postForProfileR @@ -14,11 +17,13 @@ module Handler.Profile , getSetDisplayEmailR, postSetDisplayEmailR , getCsvOptionsR, postCsvOptionsR , postLangR + , getUserRecipientsR ) where import Import import Handler.Utils +import Handler.Utils.Avs import Handler.Utils.AvsUpdate import Handler.Utils.Profile import Handler.Utils.Users @@ -36,11 +41,22 @@ import qualified Database.Esqueleto.Experimental as E import qualified Database.Esqueleto.Legacy as EL (on,from) import qualified Database.Esqueleto.Utils as E import qualified Database.Esqueleto.PostgreSQL as E +import Database.Esqueleto.Utils.TH import qualified Data.Text as Text import Data.List (inits) -import qualified Data.CaseInsensitive as CI +import qualified Data.CaseInsensitive as CI-- data TableHasData = TableHasData{tableHasRows :: Bool, tableWidget :: Widget} +-- a poor man's record subsitute + +{- +type TableHasData = (Bool, Widget) +tableHasRows :: TableHasData -> Bool +tableHasRows = fst +tableWidget :: TableHasData -> Widget +tableWidget = snd +-} + import Jobs @@ -595,41 +611,6 @@ getForProfileDataR cID = do setTitleI $ MsgHeadingForProfileData $ userDisplayName user dataWidget --- data TableHasData = TableHasData{tableHasRows :: Bool, tableWidget :: Widget} --- a poor man's record subsitute - -{- -type TableHasData = (Bool, Widget) -tableHasRows :: TableHasData -> Bool -tableHasRows = fst -tableWidget :: TableHasData -> Widget -tableWidget = snd --} - --- | Given a header message, a bool and widget; display widget and header only if the boolean is true -maybeTable :: (RenderMessage UniWorX a) - => a -> (Bool, Widget) -> Widget -maybeTable m = maybeTable' m Nothing Nothing - -maybeTable' :: (RenderMessage UniWorX a) - => a -> Maybe a -> Maybe Widget -> (Bool, Widget) -> Widget -maybeTable' _ Nothing _ (False, _ ) = mempty -maybeTable' _ (Just nodata) _ (False, _ ) = - [whamlet| -
        - _{nodata} - |] -maybeTable' hdr _ mbRemark (True ,tbl) = - [whamlet| -
        -

        _{hdr} -
        - ^{tbl} - $maybe remark <- mbRemark - _{MsgProfileRemark} - \ ^{remark} - |] - makeProfileData :: Entity User -> DB Widget makeProfileData usrEnt@(Entity uid usrVal@User{..}) = do @@ -652,7 +633,7 @@ makeProfileData usrEnt@(Entity uid usrVal@User{..}) = do EL.on $ studyfeat E.^. StudyFeaturesDegree E.==. studydegree E.^. StudyDegreeId E.where_ $ studyfeat E.^. StudyFeaturesUser E.==. E.val uid return (studyfeat, studydegree, studyterms) - companies <- wgtCompanies uid + companies <- wgtCompanies False uid -- supervisors' <- E.select $ EL.from $ \(spvr `E.InnerJoin` usrSpvr) -> do -- EL.on $ spvr E.^. UserSupervisorSupervisor E.==. usrSpvr E.^. UserId -- E.where_ $ spvr E.^. UserSupervisorUser E.==. E.val uid @@ -677,6 +658,7 @@ makeProfileData usrEnt@(Entity uid usrVal@User{..}) = do submissionGroupTable <- mkSubmissionGroupTable uid -- Tabelle mit allen Abgabegruppen correctionsTable <- mkCorrectionsTable uid -- Tabelle mit allen Korrektor-Aufgaben qualificationsTable <- mkQualificationsTable now uid -- Tabelle mit allen Qualifikationen + examsTable <- mkExamsTable uid -- Tabelle mit allen angemeldeten Prüfungen und Prüfern supervisorsTable <- mkSupervisorsTable uid -- Tabelle mit allen Supervisors superviseesTable <- mkSuperviseesTable actualPrefersPostal uid -- Tabelle mit allen Supervisees countUnderlings <- E.select $ do @@ -801,7 +783,7 @@ mkEnrolledCoursesTable uid = do <*> view _courseSchool , sortable (Just "course") (i18nCell MsgTableCourse) $ courseCell <$> view (_dbrOutput . _1 . _entityVal) - , sortable (Just "time") (i18nCell MsgProfileRegistered) $ do + , sortable (Just "time") (i18nCell MsgCourseExamRegistrationTime) $ do regTime <- view $ _dbrOutput . _2 return $ dateTimeCell regTime , sortable Nothing (i18nCell MsgCourseTutorials) $ \(view $ _dbrOutput . _1 -> Entity{entityKey=cid, entityVal=Course{..}}) -> @@ -1042,6 +1024,85 @@ mkCorrectionsTable = in \uid -> let dbtSQLQuery = dbtSQLQuery' uid in (_1 %~ getAny) <$> dbTableWidget validator DBTable{..} +type TblExamsExpr = ( E.SqlExpr ( Entity Course) + `E.InnerJoin` E.SqlExpr ( Entity Exam) + `E.InnerJoin` E.SqlExpr ( Entity ExamRegistration) + `E.LeftOuterJoin` E.SqlExpr (Maybe (Entity ExamResult)) + `E.LeftOuterJoin` E.SqlExpr (Maybe (Entity ExamOccurrence)) + `E.LeftOuterJoin` E.SqlExpr (Maybe (Entity User)) + ) +-- due to GHC staging restrictions, we use the preprocessor instead +#define TABLE_EXAMS_JOIN "IILLL" + +type TblExamsData = DBRow (Entity Course, Entity Exam, Entity ExamRegistration, Maybe (Entity ExamResult), Maybe (Entity ExamOccurrence), Maybe (Entity User)) + +-- | Table listing all exams that the given user is enrolled in +mkExamsTable :: UserId -> DB (Bool, Widget) +mkExamsTable = + let dbtIdent = "exams-user" :: Text + dbtStyle = def + dbtSQLQuery' uid (crs `E.InnerJoin` exm `E.InnerJoin` reg `E.LeftOuterJoin` res `E.LeftOuterJoin` occ `E.LeftOuterJoin` xmr) = do + EL.on $ xmr E.?. UserId E.==. E.joinV (occ E.?. ExamOccurrenceExaminer) + EL.on $ reg E.^. ExamRegistrationOccurrence E.==. occ E.?. ExamOccurrenceId + EL.on $ reg E.^. ExamRegistrationExam E.=?. res E.?. ExamResultExam + E.&&. reg E.^. ExamRegistrationUser E.=?. res E.?. ExamResultUser + E.&&. E.isJust (exm E.^. ExamFinished) + EL.on $ reg E.^. ExamRegistrationExam E.==. exm E.^. ExamId + EL.on $ crs E.^. CourseId E.==. exm E.^. ExamCourse + E.where_ $ reg E.^. ExamRegistrationUser E.==. E.val uid + return (crs,exm,reg,res,occ,xmr) + queryCourse :: TblExamsExpr -> E.SqlExpr (Entity Course) + queryCourse = $(sqlMIXproj TABLE_EXAMS_JOIN 1) + queryExam :: TblExamsExpr -> E.SqlExpr (Entity Exam) + queryExam = $(sqlMIXproj TABLE_EXAMS_JOIN 2) + queryRegistration :: TblExamsExpr -> E.SqlExpr (Entity ExamRegistration) + queryRegistration = $(sqlMIXproj TABLE_EXAMS_JOIN 3) + queryResult :: TblExamsExpr -> E.SqlExpr (Maybe (Entity ExamResult)) + queryResult = $(sqlMIXproj TABLE_EXAMS_JOIN 4) + queryOccurrence :: TblExamsExpr -> E.SqlExpr (Maybe (Entity ExamOccurrence)) + queryOccurrence = $(sqlMIXproj TABLE_EXAMS_JOIN 5) + queryExaminer :: TblExamsExpr -> E.SqlExpr (Maybe (Entity User)) + queryExaminer = $(sqlMIXproj TABLE_EXAMS_JOIN 6) + resultCourse :: Lens' TblExamsData (Entity Course) + resultCourse = _dbrOutput . _1 + resultExam :: Lens' TblExamsData (Entity Exam) + resultExam = _dbrOutput . _2 + resultRegistration :: Lens' TblExamsData (Entity ExamRegistration) + resultRegistration = _dbrOutput . _3 + resultExamResult :: Traversal' TblExamsData ExamResult + resultExamResult = _dbrOutput . _4 . _Just . _entityVal + resultOccurrence :: Traversal' TblExamsData (Entity ExamOccurrence) + resultOccurrence = _dbrOutput . _5 . _Just + resultExaminer :: Traversal' TblExamsData (Entity User) + resultExaminer = _dbrOutput . _6 . _Just + dbtRowKey = queryRegistration >>> (E.^. ExamRegistrationId) + dbtProj = dbtProjId + dbtColonnade = mconcat + [ sortable (Just "course") (i18nCell MsgTableCourse) $ fmap addIndicatorCell courseCell <$> view (resultCourse . _entityVal) + , sortable (Just "exam") (i18nCell MsgCourseExam) $ \row -> examCell (row ^. resultCourse . _entityVal) (row ^. resultExam . _entityVal) + , sortable (Just "registration")(i18nCell MsgCourseExamRegistrationTime)$ dateCell . view (resultRegistration . _entityVal . _examRegistrationTime) + , sortable (Just "occurrence") (i18nCell MsgTableExamOccurrence) $ foldMap examOccurrenceCell . preview resultOccurrence + , sortable (Just "tester") (i18nCell MsgExamCorrectors) $ foldMap cellHasUser . preview resultExaminer + , sortable (Just "result") (i18nCell MsgTableExamResult) $ foldMap i18nCell . preview (resultExamResult . _examResultResult) + ] + validator = def & defaultSorting [SortAscBy "course", SortAscBy "exam", SortAscBy "tester"] -- [SortDescBy "registration"] + dbtSorting = Map.fromList + [ ( "course" , SortColumn $ queryCourse >>> (E.^. CourseName)) + , ( "exam" , SortColumn $ queryExam >>> (E.^. ExamName)) + , ( "registration", SortColumn $ queryRegistration >>> (E.^. ExamRegistrationTime)) + , ( "occurrence" , SortColumn $ queryOccurrence >>> (E.?. ExamOccurrenceName)) + , ( "tester" , SortColumn $ queryExaminer >>> (E.?. UserDisplayName)) + , ( "result" , SortColumn $ queryResult >>> (E.?. ExamResultResult)) + ] + dbtFilter = mempty + dbtFilterUI = mempty + dbtParams = def + dbtCsvEncode = noCsvEncode + dbtCsvDecode = Nothing + dbtExtraReps = [] + in \uid -> let dbtSQLQuery = dbtSQLQuery' uid + in (_1 %~ getAny) <$> dbTableWidget validator DBTable{..} + -- | Table listing all qualifications that the given user is enrolled in mkQualificationsTable :: UTCTime -> UserId -> DB Widget @@ -1127,11 +1188,10 @@ mkSupervisorsTable uid = dbTableWidget' validator DBTable{..} -- , sortable (Just "postal-pref") (i18nCell MsgPrefersPostal) $ \(view $ resultUser . _userPrefersPostal -> b) -> iconFixedCell $ iconLetterOrEmail b , sortable (Just "reroute") (i18nCell MsgTableRerouteActive) $ \row -> let isReroute = row ^. resultUserSupervisor . _entityVal ._userSupervisorRerouteNotifications - isLetter = row ^. resultUser . _userPrefersPostal in if isReroute - then iconCell IconReroute <> spacerCell <> iconFixedCell (iconLetterOrEmail isLetter) + then iconCell IconReroute <> blankCell <> cellMailPrefPin (row ^. resultUser) else mempty - , sortable (Just "cshort") (i18nCell MsgTableCompany) $ \(view $ resultUserSupervisor . _entityVal . _userSupervisorCompany -> mc) -> maybeCell mc (\(unCompanyKey -> c) -> anchorCell (FirmUsersR c) $ citext2widget c) + , sortable (Just "cshort") (i18nCell MsgTableCompany) $ \(view $ resultUserSupervisor . _entityVal . _userSupervisorCompany -> mc) -> maybeCell mc companyIdCell , sortable (Just "reason") (i18nCell MsgTableReason) $ \(view $ resultUserSupervisor . _entityVal . _userSupervisorReason -> mr) -> maybeCell mr textCell ] validator = def & defaultSorting [ SortAscBy "cshort", SortAscBy "user-name" ] @@ -1181,7 +1241,7 @@ mkSuperviseesTable userPrefersPostal uid = dbTableWidget' validator DBTable{..} , sortable (Just "reroute") (i18nCell MsgTableRerouteActive) $ \row -> let isReroute = row ^. resultUserSupervisor . _entityVal ._userSupervisorRerouteNotifications in boolCell isReroute $ iconCell IconReroute <> iconCellLetterOrEmail - , sortable (Just "cshort") (i18nCell MsgTableCompany) $ \(view $ resultUserSupervisor . _entityVal . _userSupervisorCompany -> mc) -> maybeCell mc (\(unCompanyKey -> c) -> anchorCell (FirmUsersR c) $ citext2widget c) + , sortable (Just "cshort") (i18nCell MsgTableCompany) $ \(view $ resultUserSupervisor . _entityVal . _userSupervisorCompany -> mc) -> maybeCell mc companyIdCell , sortable (Just "reason") (i18nCell MsgTableReason) $ \(view $ resultUserSupervisor . _entityVal . _userSupervisorReason -> mr) -> maybeCell mr textCell ] validator = def & defaultSorting [ SortAscBy "cshort", SortAscBy "user-name" ] @@ -1207,6 +1267,114 @@ mkSuperviseesTable userPrefersPostal uid = dbTableWidget' validator DBTable{..} dbtExtraReps = [] +type TblReceiverData = DBRow (Entity User, Maybe (Entity UserSupervisor)) +instance HasEntity TblReceiverData User where + hasEntity = _dbrOutput . _1 +instance HasUser TblReceiverData where + hasUser = _dbrOutput . _1 . _entityVal + +-- | Table listing all supervisor of the given user +mkReceiversTable :: UserId -> [CompanyShorthand] -> [Entity User] -> DB Widget +mkReceiversTable uid usrCmps receivers = dbTableDB' validator DBTable{..} + where + dbtIdent = "receivers" :: Text + dbtStyle = def + + queryReceiver :: E.SqlExpr (Entity User) `E.LeftOuterJoin` E.SqlExpr (Maybe (Entity UserSupervisor)) -> E.SqlExpr (Entity User) + queryReceiver = $(E.sqlLOJproj 2 1) + queryReceiverSupervisor :: E.SqlExpr (Entity User) `E.LeftOuterJoin` E.SqlExpr (Maybe (Entity UserSupervisor)) -> E.SqlExpr (Maybe (Entity UserSupervisor)) + queryReceiverSupervisor = $(E.sqlLOJproj 2 2) + + resultReceiver :: Lens' TblReceiverData (Entity User) + resultReceiver = _dbrOutput . _1 + resultReceiverSupervisor :: Traversal' TblReceiverData (Entity UserSupervisor) + resultReceiverSupervisor = _dbrOutput . _2 . _Just + + dbtSQLQuery (usr `E.LeftOuterJoin` spr) = do + EL.on $ spr E.?. UserSupervisorSupervisor E.?=. usr E.^. UserId + E.&&. spr E.?. UserSupervisorUser E.?=. E.val uid + E.where_ $ usr E.^. UserId `E.in_` E.vals (entityKey <$> receivers) + return (usr, spr) + -- dbtRowKey (usr `E.LeftOuterJoin` _) = usr E.^. UserId + dbtRowKey = (E.^. UserId) . queryReceiver + dbtProj = dbtProjId + + dbtColonnade = mconcat + [ colUserNameModalHdr MsgCommRecipients ForProfileDataR + -- , colUserEmail + -- , colUserLetterEmailPin + , sortable Nothing (i18nCell MsgAddress) $ \(view resultReceiver -> rcvr) -> sqlCell $ -- recall: requires dbTableDB' above! + getPostalPreferenceAndAddress' rcvr >>= \case + (False, _, (Just eml, auto)) -> do -- email + return [whamlet| +

        + ^{widgetMailPrefPin rcvr} # + ^{updateAutomatic auto} # +

        + #{mailtoHtml eml} + |] + (True, (Just postal, auto), _) -> do -- postal + return [whamlet| +

        + ^{widgetMailPrefPin rcvr} # + ^{updateAutomatic auto} +

        + #{postal} + |] + _ -> return $ msg2widget MsgNoContactAddress + , sortable (Just "user-company") (i18nCell MsgTableCompanies) $ \row -> sqlCell $ do + let ruid = row ^. resultReceiver . _entityKey + rcmp = row ^? resultReceiverSupervisor . _entityVal . _userSupervisorCompany . _Just . to unCompanyKey + errWgt fsh = let emsg = MsgCompanySupervisorCompanyMissing fsh + in [whamlet|^{messageTooltip =<< messageI Warning emsg} _{emsg}|] + cmps <- wgtCompanies' True ruid + return $ case (cmps, rcmp) of + (Just (cwgt, cmpsData), Just svcsh) + | svcsh `notElem` (cmpsData ^.. traverse . _1) -> + [whamlet|$newline never +

          + ^{cwgt} +

          + ^{errWgt svcsh} + |] + (Just (cwgt,_),_) -> [whamlet|

            ^{cwgt}|] + (Nothing, Just svcsh) -> errWgt svcsh + (Nothing, Nothing) -> mempty + , sortable (Just "reason") (i18nCell MsgUserSupervisorReason) $ \(preview $ resultReceiverSupervisor . _entityVal . _userSupervisorReason . _Just -> mr) -> maybeCell mr textCell + , sortable (Just "cshort") (i18nCell MsgUserSupervisorCompany) $ \row -> + let mc = row ^? resultReceiverSupervisor . _entityVal . _userSupervisorCompany . _Just + errWgt fsh = let emsg = MsgCompanySuperviseeCompanyMissing fsh + in [whamlet|

            ^{messageTooltip =<< messageI Warning emsg} _{emsg}|] + in case mc of + Nothing -> mempty + (Just sfid@(unCompanyKey -> sfsh)) + | notNull usrCmps + , sfsh `notElem` usrCmps -> companyIdCell sfid <> wgtCell (errWgt sfsh) + | otherwise -> companyIdCell sfid + ] + validator = def & defaultSorting [ SortAscBy "user-name" ] + dbtSorting = Map.fromList + [ sortUserNameLink queryReceiver + -- , sortUserLetterEmailPin queryReceiver + -- , sortUserEmail queryReceiver + , ("user-company" , SortColumn (\row -> E.subSelect $ do + (cmp :& usrCmp) <- E.from $ E.table @Company `E.innerJoin` E.table @UserCompany `E.on` (\(cmp :& usrCmp) -> cmp E.^. CompanyId E.==. usrCmp E.^. UserCompanyCompany) + E.where_ $ usrCmp E.^. UserCompanyUser E.==. queryReceiver row E.^. UserId + E.orderBy [E.asc $ cmp E.^. CompanyName] + return (cmp E.^. CompanyName) + )) + , ("reason", SortColumn $ queryReceiverSupervisor >>> (E.?. UserSupervisorReason)) + , ("cshort", SortColumn $ queryReceiverSupervisor >>> (E.?. UserSupervisorCompany)) + ] + dbtFilter = mconcat + [ singletonMap & uncurry $ fltrUserNameEmail queryReceiver + ] + dbtFilterUI = mempty + dbtParams = def + dbtCsvEncode = noCsvEncode + dbtCsvDecode = Nothing + dbtExtraReps = [] + getAuthPredsR, postAuthPredsR :: Handler Html getAuthPredsR = postAuthPredsR postAuthPredsR = do @@ -1358,3 +1526,23 @@ postLangR = do addMessage Success . toHtml $ mr MsgLanguageChanged redirect . fromMaybe NewsR =<< lookupGlobalGetParam GetReferer + + +getUserRecipientsR :: CryptoUUIDUser -> Handler Html +getUserRecipientsR uuid = do + uid <- decrypt uuid + (usr, receivers, usrReceives) <- updateReceivers uid -- use Handler.Utils.getReceivers instead to avoid AVS queries + mrtbl <- case receivers of + [] -> return Nothing -- no receivers + [_] | usrReceives -> return Nothing -- only user receives for themself + _ -> runDB $ do + usrCmps <- wgtCompanies' True uid + let fshs :: [CompanyShorthand] = usrCmps ^.. _Just . _2 . traverse . _1 + rtbl <- mkReceiversTable uid fshs receivers + return $ Just (rtbl, fst <$> usrCmps) + let heading = MsgUserRecipientsTitle $ usr ^. _userDisplayName + usrWgt = userWidget usr + hasPwd = isJust $ usr ^. _userPinPassword + siteLayoutMsg heading $ do + setTitleI heading + $(i18nWidgetFile "user-receivers") diff --git a/src/Handler/Qualification.hs b/src/Handler/Qualification.hs index e2934401d..0c0970a26 100644 --- a/src/Handler/Qualification.hs +++ b/src/Handler/Qualification.hs @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2022-23 Steffen Jost ,Steffen Jost +-- SPDX-FileCopyrightText: 2022-2025 Steffen Jost ,Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later @@ -9,6 +9,8 @@ module Handler.Qualification ( getQualificationAllR , getQualificationSchoolR , getQualificationR, postQualificationR + , getQualificationNewR, postQualificationNewR + , getQualificationEditR, postQualificationEditR ) where @@ -18,6 +20,7 @@ import Jobs import Handler.Utils import Handler.Utils.Users import Handler.Utils.LMS +import Handler.Utils.Company import qualified Data.Set as Set import qualified Data.Map as Map @@ -33,22 +36,22 @@ import qualified Database.Esqueleto.Legacy as E import qualified Database.Esqueleto.Utils as E import Database.Esqueleto.Utils.TH - -- import Handler.Utils.Qualification (validQualification) - --- avoids repetition of local definitions -single :: (k,a) -> Map k a -single = uncurry Map.singleton +import Handler.Qualification.Edit as Handler.Qualification getQualificationSchoolR :: SchoolId -> Handler Html -getQualificationSchoolR ssh = redirect (QualificationAllR, [("qualification-overview-school", toPathPiece ssh)]) +-- getQualificationSchoolR ssh = redirect (QualificationAllR, [("qualification-overview-school", toPathPiece ssh)]) +getQualificationSchoolR ssh = do + qualiTable <- runDB $ view _2 <$> mkQualificationAllTable (Just ssh) + let heading = SomeMessages " " [SomeMessage MsgMenuQualifications, SomeMessage $ unSchoolKey ssh] + siteLayoutMsg heading $ do + setTitleI heading + $(widgetFile "qualification-all") getQualificationAllR :: Handler Html getQualificationAllR = do - isAdmin <- hasReadAccessTo AdminR - qualiTable <- runDB $ do - view _2 <$> mkQualificationAllTable isAdmin + qualiTable <- runDB $ view _2 <$> mkQualificationAllTable Nothing siteLayoutMsg MsgMenuQualifications $ do setTitleI MsgMenuQualifications $(widgetFile "qualification-all") @@ -64,9 +67,10 @@ resultAllQualificationTotal :: Lens' AllQualificationTableData Word64 resultAllQualificationTotal = _dbrOutput . _3 . _unValue -mkQualificationAllTable :: Bool -> DB (Any, Widget) -mkQualificationAllTable isAdmin = do - svs <- getSupervisees +mkQualificationAllTable :: Maybe SchoolId -> DB (Any, Widget) +mkQualificationAllTable ssh = do + isAdmin <- hasReadAccessTo AdminR + svs <- getSupervisees False now <- liftIO getCurrentTime let resultDBTable = DBTable{..} @@ -80,6 +84,7 @@ mkQualificationAllTable isAdmin = do cactive = Ex.subSelectCount $ do quser <- Ex.from $ Ex.table @QualificationUser Ex.where_ $ filterSvs quser Ex.&&. validQualification now quser + whenIsJust ssh $ E.where_ . ((quali Ex.^. QualificationSchool) E.==.) . E.val return (quali, cactive, cusers) dbtRowKey = (Ex.^. QualificationId) dbtProj = dbtProjId @@ -128,13 +133,13 @@ mkQualificationAllTable isAdmin = do ] dbtFilter = mconcat [ - fltrSchool $ to (E.^. QualificationSchool) - , singletonMap "qelearning" . FilterColumn $ E.mkExactFilterLast (E.^. QualificationElearningStart) + -- fltrSchool $ to (E.^. QualificationSchool) + singletonMap "qelearning" . FilterColumn $ E.mkExactFilterLast (E.^. QualificationElearningStart) ] dbtFilterUI = mconcat [ - fltrSchoolUI - , \mPrev -> prismAForm (singletonFilter "qelearning" . maybePrism _PathPiece) mPrev $ aopt (boolField . Just $ SomeMessage MsgBoolIrrelevant) (fslI MsgTableLmsElearning) + -- guardMonoid (isNothing ssh) fltrSchoolUI + \mPrev -> prismAForm (singletonFilter "qelearning" . maybePrism _PathPiece) mPrev $ aopt (boolField . Just $ SomeMessage MsgBoolIrrelevant) (fslI MsgTableLmsElearning) ] dbtStyle = def { dbsFilterLayout = defaultDBSFilterLayout } dbtParams = def @@ -149,11 +154,6 @@ mkQualificationAllTable isAdmin = do dbTable resultDBTableValidator resultDBTable - --- getQualificationEditR, postQualificationEditR :: SchoolId -> QualificationShorthand -> Handler Html --- getQualificationEditR = postQualificationEditR --- postQualificationEditR = error "TODO" - data QualificationTableCsv = QualificationTableCsv -- Q..T..C.. -> qtc.. { qtcDisplayName :: UserDisplayName , qtcEmail :: UserEmail @@ -349,11 +349,7 @@ qualificationTableQuery now qid fltr (qualUser `E.InnerJoin` user `E.LeftOuterJo E.on $ user E.^. UserId E.==. qualUser E.^. QualificationUserUser E.where_ $ fltr qualUser E.&&. (E.val qid E.==. qualUser E.^. QualificationUserQualification) - let primeComp = E.subSelect . E.from $ \uc -> do - E.where_ $ user E.^. UserId E.==. uc E.^. UserCompanyUser - E.orderBy [E.desc $ uc E.^. UserCompanyPriority, E.asc $ uc E.^. UserCompanyCompany] - return (uc E.^. UserCompanyCompany) - return (qualUser, user, lmsUser, qualBlock, primeComp) + return (qualUser, user, lmsUser, qualBlock, selectCompanyUserPrime user) mkQualificationTable :: @@ -367,10 +363,10 @@ mkQualificationTable :: -> PSValidator (MForm Handler) (FormResult (First QualificationTableActionData, DBFormResult UserId Bool QualificationTableData)) -> DB (FormResult (QualificationTableActionData, Set UserId), Widget) mkQualificationTable isAdmin (Entity qid quali) acts cols psValidator = do - svs <- getSupervisees + svs <- getSupervisees True now <- liftIO getCurrentTime -- lookup all companies - cmpMap <- memcachedBy (Just . Right $ 15 * diffMinute) ("CompanyDictionary"::Text) $ do + cmpMap <- memcachedBy (Just . Right $ 30 * diffMinute) ("CompanyDictionary"::Text) $ do cmps <- selectList [] [] -- [Asc CompanyShorthand] return $ Map.fromList $ fmap (\Entity{..} -> (entityKey, entityVal)) cmps let @@ -386,40 +382,40 @@ mkQualificationTable isAdmin (Entity qid quali) acts cols psValidator = do dbtRowKey = queryUser >>> (E.^. UserId) dbtProj = dbtProjId dbtColonnade = cols getCompanyName - dbtSorting = mconcat - [ single $ sortUserNameLink queryUser - , single $ sortUserEmail queryUser - , single $ sortUserMatriclenr queryUser - , single ("first-held" , SortColumn $ queryQualUser >>> (E.^. QualificationUserFirstHeld)) - , single ("last-refresh" , SortColumn $ queryQualUser >>> (E.^. QualificationUserLastRefresh)) - , single ("last-notified" , SortColumn $ queryQualUser >>> (E.^. QualificationUserLastNotified)) - , single ("valid-until" , SortColumn $ queryQualUser >>> (E.^. QualificationUserValidUntil)) - , single ("blocked" , SortColumnNeverNull $ queryQualBlock >>> (E.?. QualificationUserBlockFrom)) - , single ("lms-status-plus",SortColumnNullsInv $ \row -> E.coalesce [ E.joinV (queryLmsUser row E.?. LmsUserStatusDay) + dbtSorting = Map.fromList + [ sortUserNameLink queryUser + , sortUserEmail queryUser + , sortUserMatriclenr queryUser + , ("first-held" , SortColumn $ queryQualUser >>> (E.^. QualificationUserFirstHeld)) + , ("last-refresh" , SortColumn $ queryQualUser >>> (E.^. QualificationUserLastRefresh)) + , ("last-notified" , SortColumn $ queryQualUser >>> (E.^. QualificationUserLastNotified)) + , ("valid-until" , SortColumn $ queryQualUser >>> (E.^. QualificationUserValidUntil)) + , ("blocked" , SortColumnNeverNull $ queryQualBlock >>> (E.?. QualificationUserBlockFrom)) + , ("lms-status-plus",SortColumnNullsInv $ \row -> E.coalesce [ E.joinV (queryLmsUser row E.?. LmsUserStatusDay) , E.joinV (queryLmsUser row E.?. LmsUserNotified) , queryLmsUser row E.?. LmsUserStarted]) - , single ("schedule-renew", SortColumnNullsInv $ queryQualUser >>> (E.^. QualificationUserScheduleRenewal)) - , single ("user-company" , SortColumn $ \row -> E.subSelect $ E.from $ \(usrComp `E.InnerJoin` comp) -> do + , ("schedule-renew", SortColumnNullsInv $ queryQualUser >>> (E.^. QualificationUserScheduleRenewal)) + , ("user-company" , SortColumn $ \row -> E.subSelect $ E.from $ \(usrComp `E.InnerJoin` comp) -> do E.on $ usrComp E.^. UserCompanyCompany E.==. comp E.^. CompanyId E.where_ $ usrComp E.^. UserCompanyUser E.==. queryUser row E.^. UserId E.orderBy [E.asc (comp E.^. CompanyName)] return (comp E.^. CompanyName) - ) - -- , single ("validity", SortColumn $ queryQualUser >>> validQualification now) + ) + -- , ("validity", SortColumn $ queryQualUser >>> validQualification now) ] - dbtFilter = mconcat - [ single $ fltrUserNameEmail queryUser - , single ("avs-number" , FilterColumn . E.mkExistsFilter $ \row criterion -> + dbtFilter = Map.fromList + [ fltrUserNameEmail queryUser + , ("avs-number" , FilterColumn . E.mkExistsFilter $ \row criterion -> E.from $ \usrAvs -> -- do E.where_ $ usrAvs E.^. UserAvsUser E.==. queryUser row E.^. UserId E.&&. ((E.val criterion :: E.SqlExpr (E.Value (CI Text))) E.==. (E.explicitUnsafeCoerceSqlExprValue "citext" (usrAvs E.^. UserAvsNoPerson) :: E.SqlExpr (E.Value (CI Text))) )) , fltrAVSCardNos queryUser - , single ("personal-number", FilterColumn $ \(queryUser -> user) (criteria :: Set.Set Text) -> if + , ("personal-number", FilterColumn $ \(queryUser -> user) (criteria :: Set.Set Text) -> if | Set.null criteria -> E.true | otherwise -> E.any (\c -> user E.^. UserCompanyPersonalNumber `E.hasInfix` E.val c) criteria ) - , single ("user-company", FilterColumn . E.mkExistsFilter $ \row criterion -> + , ("user-company", FilterColumn . E.mkExistsFilter $ \row criterion -> E.from $ \(usrComp `E.InnerJoin` comp) -> do let testname = (E.val criterion :: E.SqlExpr (E.Value (CI Text))) `E.isInfixOf` (E.explicitUnsafeCoerceSqlExprValue "citext" (comp E.^. CompanyName) :: E.SqlExpr (E.Value (CI Text))) @@ -428,26 +424,37 @@ mkQualificationTable isAdmin (Entity qid quali) acts cols psValidator = do E.on $ usrComp E.^. UserCompanyCompany E.==. comp E.^. CompanyId E.where_ $ usrComp E.^. UserCompanyUser E.==. queryUser row E.^. UserId E.&&. testcrit ) - , single ("validity" , FilterColumn . E.mkExactFilterLast $ views (to queryQualUser) (validQualification now)) - , single ("renewal-due" , FilterColumn $ \(queryQualUser -> quser) criterion -> - if | Just renewal <- mbRenewal - , Just True <- getLast criterion -> quser E.^. QualificationUserValidUntil E.<=. E.val renewal - E.&&. quser E.^. QualificationUserValidUntil E.>=. E.val nowaday - | otherwise -> E.true + , ("validity" , FilterColumn . E.mkExactFilterLast $ views (to queryQualUser) (validQualification now)) + , ("long-valid", + let cutoff = if + | Just refWithin <- qualificationRefreshWithin quali -> computeNewValidDate' (refWithin <> calendarDay) nowaday -- longer valid than renewal + | Just valDuration <- qualificationValidDuration quali -> computeNewValidDate (valDuration `div` 2) nowaday -- or longer valid than half the duration + | otherwise -> computeNewValidDate' (calendarYear <> calendarDay) nowaday -- or a year and a day + in FilterColumn . E.mkExactFilterLast $ views (to queryQualUser) ((E.>. E.val cutoff) . (E.^. QualificationUserValidUntil)) -- for use with boolField + -- in FilterColumn $ \(queryQualUser -> quser) (getLast -> criterion) -> if -- for use with checkboxField + -- | Just True <- criterion -> quser E.^. QualificationUserValidUntil E.>=. E.val cutoff + -- | otherwise -> E.true ) - , single ("tobe-notified", FilterColumn $ \row criterion -> - if | Just True <- getLast criterion -> quserToNotify now (queryQualUser row) (queryQualBlock row) - | otherwise -> E.true + , ("renewal-due" , FilterColumn $ \(queryQualUser -> quser) criterion -> if + | Just renewal <- mbRenewal + , Just True <- getLast criterion -> quser E.^. QualificationUserValidUntil E.<=. E.val renewal + E.&&. quser E.^. QualificationUserValidUntil E.>=. E.val nowaday + | otherwise -> E.true ) - , single ("status" , FilterColumn . E.mkExactFilterMaybeLast' (views (to queryLmsUser) (E.?. LmsUserId)) $ views (to queryLmsUser) (E.?. LmsUserStatus)) + , ("tobe-notified", FilterColumn $ \row criterion -> if + | Just True <- getLast criterion -> quserToNotify now (queryQualUser row) (queryQualBlock row) + | otherwise -> E.true + ) + , ("status" , FilterColumn . E.mkExactFilterMaybeLast' (views (to queryLmsUser) (E.?. LmsUserId)) $ views (to queryLmsUser) (E.?. LmsUserStatus)) ] dbtFilterUI mPrev = mconcat [ fltrUserNameEmailHdrUI MsgLmsUser mPrev - , prismAForm (singletonFilter "user-company") mPrev $ aopt textField (fslI MsgTableCompany) - , prismAForm (singletonFilter "personal-number" ) mPrev $ aopt textField (fslI MsgCompanyPersonalNumber) + , prismAForm (singletonFilter "user-company") mPrev $ aopt textField (fslI MsgTableCompany) + , prismAForm (singletonFilter "personal-number" ) mPrev $ aopt textField (fslI MsgCompanyPersonalNumberFraport) , fltrAVSCardNosUI mPrev - , prismAForm (singletonFilter "avs-number" ) mPrev $ aopt textField (fslI MsgAvsPersonNo) - , prismAForm (singletonFilter "validity" . maybePrism _PathPiece) mPrev $ aopt (boolField . Just $ SomeMessage MsgBoolIrrelevant) (fslI MsgFilterLmsValid) + , prismAForm (singletonFilter "avs-number" ) mPrev $ aopt textField (fslI MsgAvsPersonNo) + , prismAForm (singletonFilter "validity" . maybePrism _PathPiece) mPrev $ aopt boolField' (fslI MsgFilterLmsValid) + , prismAForm (singletonFilter "long-valid" . maybePrism _PathPiece) mPrev $ aopt boolField' (fslI MsgFilterLmsLongValid) , if isNothing mbRenewal then mempty else prismAForm (singletonFilter "renewal-due" . maybePrism _PathPiece) mPrev $ aopt checkBoxField (fslI MsgFilterLmsRenewal) , prismAForm (singletonFilter "tobe-notified" . maybePrism _PathPiece) mPrev $ aopt checkBoxField (fslI MsgFilterLmsNotificationDue) @@ -528,7 +535,7 @@ postQualificationR sid qsh = do qent@Entity{ entityKey=qid , entityVal=Qualification{ - qualificationAuditDuration=auditMonths + qualificationAuditDuration=lmsAuditDays , qualificationValidDuration=validMonths , qualificationLmsReuses =reuseQuali }} <- getBy404 $ SchoolQualificationShort sid qsh @@ -545,12 +552,10 @@ postQualificationR sid qsh = do Ex.orderBy [Ex.desc countRows'] Ex.limit 9 pure (qblock Ex.^. QualificationUserBlockReason) - mkOption :: Ex.Value Text -> Option Text - mkOption (Ex.unValue -> t) = Option{ optionDisplay = t, optionInternalValue = t, optionExternalValue = toPathPiece t } suggestionsBlock :: HandlerFor UniWorX (OptionList Text) - suggestionsBlock = mkOptionList . fmap mkOption <$> runDB (getBlockReasons Ex.not_) - suggestionsUnblock = mkOptionList . fmap mkOption <$> runDB (getBlockReasons id) - dayExpiry = flip addGregorianDurationClip nowaday . fromMonths <$> validMonths + suggestionsBlock = mkOptionListText <$> runDB (getBlockReasons Ex.not_) + suggestionsUnblock = mkOptionListText <$> runDB (getBlockReasons id) + dayExpiry = flip computeNewValidDate nowaday <$> validMonths acts :: Map QualificationTableAction (AForm Handler QualificationTableActionData) acts = mconcat $ [ singletonMap QualificationActExpire $ pure QualificationActExpireData @@ -592,7 +597,7 @@ postQualificationR sid qsh = do qualificationValidReasonCell' (Just $ LmsUserR sid qsh) isAdmin nowaday (row ^? resultQualBlock) row , sortable (Just "schedule-renew")(i18nCell MsgTableQualificationNoRenewal & cellTooltip MsgTableQualificationNoRenewalTooltip ) $ \( view $ resultQualUser . _entityVal . _qualificationUserScheduleRenewal -> b) -> ifIconCell (not b) IconNoNotification - , sortable (Just "lms-status-plus")(i18nCell MsgTableLmsStatus & cellTooltipWgt Nothing (lmsStatusInfoCell isAdmin auditMonths)) + , sortable (Just "lms-status-plus")(i18nCell MsgTableLmsStatus & cellTooltipWgt Nothing (lmsStatusInfoCell isAdmin lmsAuditDays)) $ \(preview $ resultLmsUser . _entityVal -> lu) -> foldMap (lmsStatusCell isAdmin linkLmsUser) lu -- QualificationUserLastNotified is about notification on actual validity changes. If a user's licence is about to expire and renewed before expiry via e-learning, this value does not change. -- NOTE: If this column is reinstatiated, header and tooltip were already updated to avoid any confusion! @@ -605,12 +610,19 @@ postQualificationR sid qsh = do formResult lmsRes $ \case (QualificationActRenewData renewReason, selectedUsers) | isAdmin -> do - noks <- runDB $ renewValidQualificationUsers qid (canonical $ Just $ Left renewReason) Nothing $ Set.toList selectedUsers + let selUsrs = Set.toList selectedUsers + (noks,nterm) <- runDB $ (,) + <$> renewValidQualificationUsers qid (canonical $ Just $ Left renewReason) Nothing selUsrs + <*> terminateLms (LmsOrphanReasonManualRenewal renewReason) qid selUsrs addMessageI (if noks > 0 && noks == Set.size selectedUsers then Success else Warning) $ MsgTutorialUserRenewedQualification noks + when (nterm >0) $ addMessageI Warning $ MsgLmsActTerminated nterm reloadKeepGetParams $ QualificationR sid qsh (QualificationActGrantData grantValidday, selectedUsers) | isAdmin -> do - runDB . forM_ selectedUsers $ upsertQualificationUser qid now grantValidday Nothing "Admin" + nterm <- runDB $ do + forM_ selectedUsers $ upsertQualificationUser qid now grantValidday Nothing "Admin" + terminateLms (LmsOrphanReasonManualGrant $ "bis " <> tshow grantValidday) qid $ Set.toList selectedUsers addMessageI (if 0 < Set.size selectedUsers then Success else Warning) . MsgTutorialUserGrantedQualification $ Set.size selectedUsers + when (nterm > 0) $ addMessageI Warning $ MsgLmsActTerminated nterm reloadKeepGetParams $ QualificationR sid qsh (QualificationActStartELearningData, Set.toList -> selectedUsers) | isAdmin -> do -- whenIsJust mbExpDay $ \expDay -> @@ -622,7 +634,7 @@ postQualificationR sid qsh = do jobs <- forM validQualHolders $ queueJob . JobLmsEnqueueUser qid let nrTodo = length selectedUsers nrEnqueued = length $ catMaybes jobs - addMessageI (bool Warning Success $ nrEnqueued > 0 && nrEnqueued == nrTodo) $ MsgQualificationActStartELearningStatus qsh nrEnqueued nrTodo + addMessageOutOfI (MsgQualificationActStartELearningStatus qsh) nrEnqueued nrTodo -- transaction audit identical to automatic start, performed by JobLmsEnqueueUser reloadKeepGetParams $ QualificationR sid qsh (action, selectedUsers) | isExpiryAct action -> do diff --git a/src/Handler/Qualification/Edit.hs b/src/Handler/Qualification/Edit.hs new file mode 100644 index 000000000..3ad537260 --- /dev/null +++ b/src/Handler/Qualification/Edit.hs @@ -0,0 +1,115 @@ +-- SPDX-FileCopyrightText: 2025 Steffen Jost +-- +-- SPDX-License-Identifier: AGPL-3.0-or-later + +{-# OPTIONS_GHC -fno-warn-orphans #-} -- needed for HasEntity instances +{-# LANGUAGE TypeApplications #-} + +module Handler.Qualification.Edit + ( getQualificationNewR, postQualificationNewR + , getQualificationEditR, postQualificationEditR + ) + where + +import Import + +import qualified Data.Text as Text +import qualified Control.Monad.State.Class as State + +import Handler.Utils +-- import Database.Esqueleto.Experimental ((:&)(..)) +-- import qualified Database.Esqueleto.Experimental as E -- needs TypeApplications Lang-Pragma + + +getQualificationNewR, postQualificationNewR :: SchoolId -> Handler Html +getQualificationNewR = postQualificationNewR +postQualificationNewR ssh = handleQualificationEdit ssh Nothing + +getQualificationEditR, postQualificationEditR :: SchoolId -> QualificationShorthand -> Handler Html +getQualificationEditR = postQualificationEditR +postQualificationEditR ssh qsh = do + qent <- runDBRead $ getBy404 $ SchoolQualificationShort ssh qsh + handleQualificationEdit ssh $ Just qent + + +mkQualificationForm :: SchoolId -> Maybe Qualification -> Form Qualification +mkQualificationForm ssh templ = identifyForm FIDQualificationEdit . validateForm (validateQualificationEdit ssh) $ \html -> + flip (renderAForm FormStandard) html $ reorderedQualification + <$> areq hiddenField "" (Just ssh) -- 1 -> 1 + <*> areq ciField (fslI MsgQualificationShort) (qualificationShorthand <$> templ) -- 2 -> 2 + <*> areq ciField (fslI MsgQualificationName) (qualificationName <$> templ) -- 3 -> 3 + <*> aopt htmlField (fslI MsgQualificationDescription) (qualificationDescription <$> templ) -- 4 -> 4 + <*> aopt_natFieldI MsgQualificationValidDuration (qualificationValidDuration <$> templ) -- 5 -> 5 + <*> aopt calendarDiffDaysField (fslI MsgQualificationRefreshWithin & + setTooltip MsgQualificationRefreshWithinTooltip) (qualificationRefreshWithin <$> templ) -- 6 -> 7 + + <*> areq checkBoxField (fslI MsgQualificationElearningStart) (qualificationElearningStart <$> templ) -- 7 -> 9 + <*> aopt calendarDiffDaysField (fslI MsgQualificationRefreshReminder & + setTooltip MsgQualificationRefreshReminderTooltip) (qualificationRefreshReminder <$> templ) -- 8 -> 8 + <*> areq checkBoxField (fslI MsgQualificationExpiryNotification) (qualificationExpiryNotification <$> templ) -- 9 -> 13 + <*> areq_natFieldI MsgQualificationAuditDuration (qualificationAuditDuration <$> templ) -- 10 -> 6 + <*> areq checkBoxField (fslI MsgQualificationElearningRenew) (qualificationElearningRenews <$> templ) -- 11 -> 10 + <*> aopt_natFieldI MsgQualificationElearningLimit (qualificationElearningLimit <$> templ) -- 12 -> 11 + <*> aopt qualificationField (fslI MsgTableQualificationLmsReuses & + setTooltip MsgTableQualificationLmsReusesTooltip) (qualificationLmsReuses <$> templ) -- 13 -> 12 + <*> aopt avsLicenceField (fslI MsgQualificationAvsLicence & + setTooltip MsgTableQualificationIsAvsLicenceTooltip) (qualificationAvsLicence <$> templ) -- 14 -> 14 + <*> aopt textField (fslI MsgQualificationSapId & + setTooltip MsgTableQualificationSapExportTooltip) (qualificationSapId <$> templ) -- 15 -> 15 + where + avsLicenceField :: Field Handler AvsLicence + avsLicenceField = selectFieldList [ (Text.singleton $ licence2char lic, lic) | lic <- universeF, lic /= AvsNoLicence ] + + aopt_natFieldI msg = aopt (natFieldI $ SomeMessages " " [SomeMessage msg, SomeMessage MsgMustBePositive]) (fslI msg) + areq_natFieldI msg = areq (natFieldI $ SomeMessages " " [SomeMessage msg, SomeMessage MsgMustBePositive]) (fslI msg) + -- [ 1, 2, 3, 4, 5, 6, 7, 8, 9,10,11,12,13,14,15] + reorderedQualification = $(permuteFun [ 1, 2, 3, 4, 5,10, 6, 8, 7,11,12,13, 9,14,15]) Qualification -- == inversePermutation [1,2,3,4,5,7,9,8,13,6,10,11,12,14,15] + +validateQualificationEdit :: SchoolId -> FormValidator Qualification Handler () +validateQualificationEdit ssh = do + canonise + Qualification{..} <- State.get + guardValidation MsgQualFormErrorSshMismatch $ qualificationSchool == ssh + guardValidation MsgLmsErrorNoRefreshElearning $ not qualificationElearningStart || isJust qualificationRefreshWithin + guardValidation MsgLmsErrorNoRenewElearning $ not qualificationElearningStart || isJust qualificationValidDuration + when (isJust qualificationLmsReuses) $ + liftHandler $ addMessageI Info MsgQualificationAuditDurationReuseInfo + where + canonise = do -- i.e. map Just 0 to Nothing + Qualification{..} <- State.get + -- canonisation, i.e. map Just 0 to Nothing + when (qualificationRefreshWithin == Just mempty) $ State.modify $ set _qualificationRefreshWithin Nothing + when (qualificationRefreshReminder == Just mempty) $ State.modify $ set _qualificationRefreshReminder Nothing + when (qualificationValidDuration == Just 0) $ State.modify $ set _qualificationValidDuration Nothing + when (qualificationElearningLimit == Just 0) $ State.modify $ set _qualificationElearningLimit Nothing + + + +handleQualificationEdit :: SchoolId -> Maybe (Entity Qualification) -> Handler Html +handleQualificationEdit ssh templ = do + ((qRes, qWgt), qEnc) <- runFormPost $ mkQualificationForm ssh $ entityVal <$> templ + let qForm = wrapForm qWgt def + { formEncoding = qEnc + } + formResult qRes $ \resQuali -> do + uniqViolation <- runDB $ case templ of + Just Entity{entityKey=qid} -> replaceUnique qid resQuali -- edit old qualification + _ -> maybeM (checkUnique resQuali) (const $ return Nothing) (insertUnique resQuali) -- insert new qualification + case uniqViolation of + Just (SchoolQualificationShort _ nconflict) -> addMessageI Error $ MsgQualFormErrorDuplShort $ ciOriginal nconflict + Just (SchoolQualificationName _ nconflict) -> addMessageI Error $ MsgQualFormErrorDuplName $ ciOriginal nconflict + Nothing -> do + let qshort = qualificationShorthand resQuali + qmsg = if isNothing templ then MsgQualificationCreated else MsgQualificationEdit + addMessageI Success $ qmsg $ ciOriginal qshort + redirect $ QualificationR ssh qshort + let heading = bool MsgMenuQualificationNew MsgMenuQualificationEdit $ isJust templ + siteLayoutMsg heading $ do + setTitleI heading + [whamlet| +

            + ^{qForm} + $maybe _ <- templ +

            + _{MsgQualificationEditNote} + |] \ No newline at end of file diff --git a/src/Handler/School/DayTasks.hs b/src/Handler/School/DayTasks.hs new file mode 100644 index 000000000..e968e2022 --- /dev/null +++ b/src/Handler/School/DayTasks.hs @@ -0,0 +1,877 @@ + +-- SPDX-FileCopyrightText: 2024-2025 Steffen Jost +-- +-- SPDX-License-Identifier: AGPL-3.0-or-later + +{-# OPTIONS_GHC -fno-warn-orphans #-} +{-# OPTIONS_GHC -fno-warn-unused-top-binds #-} + +module Handler.School.DayTasks + ( getSchoolDayR, postSchoolDayR + , getSchoolDayCheckR + ) where + +import Import + +import Handler.Utils +import Handler.Utils.Company +-- import Handler.Utils.Occurrences +import Handler.Utils.Avs +import Handler.Utils.Course.Cache + +import qualified Data.Set as Set +import qualified Data.Map as Map +import qualified Data.Text as Text + +-- import Database.Persist.Sql (updateWhereCount) +import Database.Esqueleto.Experimental ((:&)(..)) +import qualified Database.Esqueleto.Experimental as E +import qualified Database.Esqueleto.PostgreSQL as E +import qualified Database.Esqueleto.Legacy as EL (on, from) -- only `on` and `from` are different, needed for dbTable using Esqueleto.Legacy +import qualified Database.Esqueleto.Utils as E +-- import Database.Esqueleto.PostgreSQL.JSON ((@>.)) +-- import qualified Database.Esqueleto.PostgreSQL.JSON as E hiding ((?.)) +import Database.Esqueleto.Utils.TH + + +-- | Maximal number of suggestions for note fields in Day Task view +maxSuggestions :: Int64 +maxSuggestions = 7 + +-- data DailyTableAction = DailyActDummy -- just a dummy, since we don't now yet which actions we will be needing +-- deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic) + +-- instance Universe DailyTableAction +-- instance Finite DailyTableAction +-- nullaryPathPiece ''DailyTableAction $ camelToPathPiece' 2 +-- embedRenderMessage ''UniWorX ''DailyTableAction id + +-- data DailyTableActionData = DailyActDummyData +-- deriving (Eq, Ord, Read, Show, Generic) + + +type DailyTableExpr = + ( E.SqlExpr (Entity Course) + `E.InnerJoin` E.SqlExpr (Entity Tutorial) + `E.InnerJoin` E.SqlExpr (Entity TutorialParticipant) + `E.InnerJoin` E.SqlExpr (Entity User) + `E.LeftOuterJoin` E.SqlExpr (Maybe (Entity UserAvs)) + `E.LeftOuterJoin` E.SqlExpr (Maybe (Entity UserDay)) + `E.LeftOuterJoin` E.SqlExpr (Maybe (Entity TutorialParticipantDay)) + ) + +type DailyTableOutput = E.SqlQuery + ( E.SqlExpr (Entity Course) + , E.SqlExpr (Entity Tutorial) + , E.SqlExpr (Entity TutorialParticipant) + , E.SqlExpr (Entity User) + , E.SqlExpr (Maybe (Entity UserAvs)) + , E.SqlExpr (Maybe (Entity UserDay)) + , E.SqlExpr (Maybe (Entity TutorialParticipantDay)) + , E.SqlExpr (E.Value (Maybe CompanyId)) + , E.SqlExpr (E.Value (Maybe [QualificationId])) + ) + +type DailyTableData = DBRow + ( Entity Course + , Entity Tutorial + , Entity TutorialParticipant + , Entity User + , Maybe (Entity UserAvs) + , Maybe (Entity UserDay) + , Maybe (Entity TutorialParticipantDay) + , E.Value (Maybe CompanyId) + , E.Value (Maybe [QualificationId]) + ) + +data DailyFormData = DailyFormData + { dailyFormDrivingPermit :: Maybe UserDrivingPermit + , dailyFormEyeExam :: Maybe UserEyeExam + , dailyFormParticipantNote :: Maybe Text + , dailyFormAttendance :: Bool + , dailyFormAttendanceNote :: Maybe Text + , dailyFormParkingToken :: Bool + } deriving (Eq, Show) + +makeLenses_ ''DailyFormData + +-- force declarations before this point to avoid staging restrictions +$(return []) + + +queryCourse :: DailyTableExpr -> E.SqlExpr (Entity Course) +queryCourse = $(sqlMIXproj' ''DailyTableExpr 1) + +queryTutorial :: DailyTableExpr -> E.SqlExpr (Entity Tutorial) +queryTutorial = $(sqlMIXproj' ''DailyTableExpr 2) + +queryParticipant :: DailyTableExpr -> E.SqlExpr (Entity TutorialParticipant) +queryParticipant = $(sqlMIXproj' ''DailyTableExpr 3) +-- queryParticipant = $(sqlMIXproj DAILY_TABLE_JOIN 3) -- reify seems problematic for now + +queryUser :: DailyTableExpr -> E.SqlExpr (Entity User) +queryUser = $(sqlMIXproj' ''DailyTableExpr 4) + +queryUserAvs :: DailyTableExpr -> E.SqlExpr (Maybe (Entity UserAvs)) +queryUserAvs = $(sqlMIXproj' ''DailyTableExpr 5) + +queryUserDay :: DailyTableExpr -> E.SqlExpr (Maybe (Entity UserDay)) +queryUserDay = $(sqlMIXproj' ''DailyTableExpr 6) + +queryParticipantDay :: DailyTableExpr -> E.SqlExpr (Maybe (Entity TutorialParticipantDay)) +queryParticipantDay = $(sqlMIXproj' ''DailyTableExpr 7) + +resultCourse :: Lens' DailyTableData (Entity Course) +resultCourse = _dbrOutput . _1 + +resultTutorial :: Lens' DailyTableData (Entity Tutorial) +resultTutorial = _dbrOutput . _2 + +resultParticipant :: Lens' DailyTableData (Entity TutorialParticipant) +resultParticipant = _dbrOutput . _3 + +resultUser :: Lens' DailyTableData (Entity User) +resultUser = _dbrOutput . _4 + +resultUserAvs :: Traversal' DailyTableData UserAvs +resultUserAvs = _dbrOutput . _5 . _Just . _entityVal + +resultUserDay :: Traversal' DailyTableData UserDay +resultUserDay = _dbrOutput . _6 . _Just . _entityVal + +resultParticipantDay :: Traversal' DailyTableData TutorialParticipantDay +resultParticipantDay = _dbrOutput . _7 . _Just . _entityVal + +resultCompanyId :: Traversal' DailyTableData CompanyId +resultCompanyId = _dbrOutput . _8 . _unValue . _Just + +resultCourseQualis :: Traversal' DailyTableData [QualificationId] +resultCourseQualis = _dbrOutput . _9 . _unValue . _Just + + +instance HasEntity DailyTableData User where + hasEntity = resultUser + +instance HasUser DailyTableData where + hasUser = resultUser . _entityVal + +-- see colRatedField' for an example of formCell usage + +drivingPermitField :: (RenderMessage (HandlerSite m) FormMessage, MonadHandler m, HandlerSite m ~ UniWorX) => Field m UserDrivingPermit +drivingPermitField = selectField' (Just $ SomeMessage MsgBoolIrrelevant) optionsFinite + +eyeExamField :: (RenderMessage (HandlerSite m) FormMessage, MonadHandler m, HandlerSite m ~ UniWorX) => Field m UserEyeExam +eyeExamField = selectField' (Just $ SomeMessage MsgBoolIrrelevant) optionsFinite + +mkDailyFormColumn :: (RenderMessage UniWorX msg) => Text -> msg -> Lens' DailyTableData a -> ASetter' DailyFormData a -> Field _ a -> Colonnade Sortable DailyTableData (DBCell _ (FormResult (DBFormResult TutorialParticipantId DailyFormData DailyTableData))) +mkDailyFormColumn k msg lg ls f = sortable (Just $ SortingKey $ stripCI k) (i18nCell msg) $ formCell + id -- lens focussing on the form result within the larger DBResult; id iff the form delivers the only result of the table + (views (resultParticipant . _entityKey) return) -- generate row identfifiers for use in form result + (\(view lg -> x) mkUnique -> + over (_1.mapped) (ls .~) . over _2 fvWidget <$> mreq f (fsUniq mkUnique k) (Just x) + ) -- Given the row data and a callback to make an input name suitably unique generate the MForm + +colParticipantPermitField :: Colonnade Sortable DailyTableData (DBCell _ (FormResult (DBFormResult TutorialParticipantId DailyFormData DailyTableData))) +colParticipantPermitField = colParticipantPermitField' _dailyFormDrivingPermit + +colParticipantPermitField' :: ASetter' a (Maybe UserDrivingPermit) -> Colonnade Sortable DailyTableData (DBCell _ (FormResult (DBFormResult TutorialParticipantId a DailyTableData))) +colParticipantPermitField' l = sortable (Just "permit") (i18nCell MsgTutorialDrivingPermit) $ (cellAttrs <>~ [("style","width:1%")]) <$> formCell + id -- lens focussing on the form result within the larger DBResult; id iff the form delivers the only result of the table + (views (resultParticipant . _entityKey) return) -- generate row identfifiers for use in form result + (\(view (resultParticipant . _entityVal . _tutorialParticipantDrivingPermit) -> x) mkUnique -> + over (_1.mapped) (l .~) . over _2 fvWidget <$> mopt drivingPermitField (fsUniq mkUnique "permit" & addClass' "uwx-narrow") (Just x) + ) -- Given the row data and a callback to make an input name suitably unique generate the MForm + +colParticipantEyeExamField :: Colonnade Sortable DailyTableData (DBCell _ (FormResult (DBFormResult TutorialParticipantId DailyFormData DailyTableData))) +colParticipantEyeExamField = colParticipantEyeExamField' _dailyFormEyeExam + +colParticipantEyeExamField' :: ASetter' a (Maybe UserEyeExam) -> Colonnade Sortable DailyTableData (DBCell _ (FormResult (DBFormResult TutorialParticipantId a DailyTableData))) +colParticipantEyeExamField' l = sortable (Just "eye-exam") (i18nCell MsgTutorialEyeExam) $ (cellAttrs <>~ [("style","width:1%")]) <$> formCell id + (views (resultParticipant . _entityKey) return) + (\(view (resultParticipant . _entityVal . _tutorialParticipantEyeExam) -> x) mkUnique -> + over (_1.mapped) (l .~) . over _2 fvWidget <$> mopt eyeExamField (fsUniq mkUnique "eye-exam" & addClass' "uwx-narrow") (Just x) + ) + +-- colParticipantNoteField :: Colonnade Sortable DailyTableData (DBCell _ (FormResult (DBFormResult TutorialParticipantId DailyFormData DailyTableData))) +-- colParticipantNoteField = sortable (Just "note-tutorial") (i18nCell MsgTutorialNote) $ (cellAttrs <>~ [("style","width:60%")]) <$> formCell id +-- (views (resultParticipant . _entityKey) return) +-- (\(view (resultParticipant . _entityVal . _tutorialParticipantNote) -> note) mkUnique -> +-- over (_1.mapped) ((_dailyFormParticipantNote .~) . assertM (not . null) . fmap (Text.strip . unTextarea)) . over _2 fvWidget <$> +-- mopt textareaField (fsUniq mkUnique "note-tutorial") (Just $ Textarea <$> note) +-- ) + +colParticipantNoteField :: Colonnade Sortable DailyTableData (DBCell _ (FormResult (DBFormResult TutorialParticipantId DailyFormData DailyTableData))) +colParticipantNoteField = sortable (Just "note-tutorial") (i18nCell MsgTutorialNote) $ (cellAttrs <>~ [("style","min-width:12em")]) <$> + formCell id + (views (resultParticipant . _entityKey) return) + (\row mkUnique -> + let note = row ^. resultParticipant . _entityVal . _tutorialParticipantNote + sid = row ^. resultCourse . _entityVal . _courseSchool + cid = row ^. resultCourse . _entityKey + tid = row ^. resultTutorial . _entityKey + in over (_1.mapped) ((_dailyFormParticipantNote .~) . assertM (not . null) . fmap Text.strip) . over _2 fvWidget <$> + mopt (textField & cfStrip & addDatalist (suggsParticipantNote sid cid tid)) (fsUniq mkUnique "note-tutorial") (Just note) + ) + +suggsParticipantNote :: SchoolId -> CourseId -> TutorialId -> Handler (OptionList Text) +suggsParticipantNote sid cid tid = do + ol <- memcachedBy (Just . Right $ 2 * diffHour) (CacheKeySuggsParticipantNote sid tid) $ do + suggs <- runDB $ E.select $ do + let countRows' :: E.SqlExpr (E.Value Int64) = E.countRows + (tpn, prio) <- E.from $ + ( do + tpa <- E.from $ E.table @TutorialParticipant + E.where_ $ E.isJust (tpa E.^. TutorialParticipantNote) + E.&&. tpa E.^. TutorialParticipantTutorial E.==. E.val tid + E.groupBy $ tpa E.^. TutorialParticipantNote + E.orderBy [E.desc countRows'] + E.limit maxSuggestions + pure (tpa E.^. TutorialParticipantNote, E.val (1 :: Int64)) + ) `E.unionAll_` + ( do + (tpa :& tut) <- E.from $ E.table @TutorialParticipant + `E.innerJoin` E.table @Tutorial + `E.on` (\(tpa :& tut) -> tut E.^. TutorialId E.==. tpa E.^. TutorialParticipantTutorial) + E.where_ $ E.isJust (tpa E.^. TutorialParticipantNote) + E.&&. tpa E.^. TutorialParticipantTutorial E.!=. E.val tid + E.&&. tut E.^. TutorialCourse E.==. E.val cid + E.groupBy (tut E.^. TutorialLastChanged, tpa E.^. TutorialParticipantNote) + E.orderBy [E.desc $ tut E.^. TutorialLastChanged, E.desc countRows'] + E.limit maxSuggestions + pure (tpa E.^. TutorialParticipantNote, E.val 2) + ) `E.unionAll_` + ( do + tpa :& tut :& crs <- E.from $ E.table @TutorialParticipant + `E.innerJoin` E.table @Tutorial + `E.on` (\(tpa :& tut) -> tut E.^. TutorialId E.==. tpa E.^. TutorialParticipantTutorial) + `E.innerJoin` E.table @Course + `E.on` (\(_ :& tut :& crs) -> tut E.^. TutorialCourse E.==. crs E.^. CourseId) + E.where_ $ E.isJust (tpa E.^. TutorialParticipantNote) + E.&&. tpa E.^. TutorialParticipantTutorial E.!=. E.val tid + E.&&. tut E.^. TutorialCourse E.!=. E.val cid + E.&&. crs E.^. CourseSchool E.==. E.val sid + E.groupBy (tut E.^. TutorialLastChanged, tpa E.^. TutorialParticipantNote) + E.orderBy [E.desc $ tut E.^. TutorialLastChanged, E.desc countRows'] + E.limit maxSuggestions + pure (tpa E.^. TutorialParticipantNote, E.val 3) + ) + E.groupBy (tpn, prio) + E.orderBy [E.asc prio, E.asc tpn] + E.limit maxSuggestions + pure $ E.coalesceDefault [tpn] $ E.val "" -- default never used due to where_ condtions, but conveniently changes type + -- $logInfoS "NOTE-SUGGS *** A: " $ tshow suggs + pure $ mkOptionListCacheable $ mkOptionText <$> nubOrd suggs + -- $logInfoS "NOTE-SUGGS *** B: " $ tshow ol + pure $ mkOptionListFromCacheable ol + +suggsAttendanceNote :: SchoolId -> CourseId -> TutorialId -> Handler (OptionList Text) +suggsAttendanceNote sid cid tid = do + ol <- memcachedBy (Just . Right $ 2 * diffHour) (CacheKeySuggsAttendanceNote sid tid) $ do + suggs <- runDB $ E.select $ do + let countRows' :: E.SqlExpr (E.Value Int64) = E.countRows + (tpn, prio) <- E.from $ + ( do + tpa <- E.from $ E.table @TutorialParticipantDay + E.where_ $ E.isJust (tpa E.^. TutorialParticipantDayNote) + E.&&. tpa E.^. TutorialParticipantDayTutorial E.==. E.val tid + E.groupBy (tpa E.^. TutorialParticipantDayNote, tpa E.^. TutorialParticipantDayDay) + E.orderBy [E.desc $ tpa E.^. TutorialParticipantDayDay, E.desc countRows'] + E.limit maxSuggestions + pure (tpa E.^. TutorialParticipantDayNote, E.val (1 :: Int64)) + ) `E.unionAll_` + ( do + (tpa :& tut) <- E.from $ E.table @TutorialParticipantDay + `E.innerJoin` E.table @Tutorial + `E.on` (\(tpa :& tut) -> tut E.^. TutorialId E.==. tpa E.^. TutorialParticipantDayTutorial) + E.where_ $ E.isJust (tpa E.^. TutorialParticipantDayNote) + E.&&. tpa E.^. TutorialParticipantDayTutorial E.!=. E.val tid + E.&&. tut E.^. TutorialCourse E.==. E.val cid + E.groupBy (tpa E.^. TutorialParticipantDayNote, tpa E.^. TutorialParticipantDayDay, tut E.^. TutorialLastChanged) + E.orderBy [E.desc $ tpa E.^. TutorialParticipantDayDay, E.desc $ tut E.^. TutorialLastChanged, E.desc countRows'] + E.limit maxSuggestions + pure (tpa E.^. TutorialParticipantDayNote, E.val 2) + ) `E.unionAll_` + ( do + tpa :& tut :& crs <- E.from $ E.table @TutorialParticipantDay + `E.innerJoin` E.table @Tutorial + `E.on` (\(tpa :& tut) -> tut E.^. TutorialId E.==. tpa E.^. TutorialParticipantDayTutorial) + `E.innerJoin` E.table @Course + `E.on` (\(_ :& tut :& crs) -> tut E.^. TutorialCourse E.==. crs E.^. CourseId) + E.where_ $ E.isJust (tpa E.^. TutorialParticipantDayNote) + E.&&. tpa E.^. TutorialParticipantDayTutorial E.!=. E.val tid + E.&&. tut E.^. TutorialCourse E.!=. E.val cid + E.&&. crs E.^. CourseSchool E.==. E.val sid + E.groupBy (tpa E.^. TutorialParticipantDayNote, tpa E.^. TutorialParticipantDayDay, tut E.^. TutorialLastChanged) + E.orderBy [E.desc $ tpa E.^. TutorialParticipantDayDay, E.desc $ tut E.^. TutorialLastChanged, E.desc countRows'] + E.limit maxSuggestions + pure (tpa E.^. TutorialParticipantDayNote, E.val 3) + ) + E.groupBy (tpn, prio) + E.orderBy [E.asc prio, E.asc tpn] + E.limit maxSuggestions + pure $ E.coalesceDefault [tpn] $ E.val "" -- default never used due to where_ condtions, but conveniently changes type + pure $ mkOptionListCacheable $ mkOptionText <$> nubOrd suggs -- NOTE: datalist does not work on textarea inputs + pure $ mkOptionListFromCacheable ol + + +colAttendanceField :: Text -> Colonnade Sortable DailyTableData (DBCell _ (FormResult (DBFormResult TutorialParticipantId DailyFormData DailyTableData))) +colAttendanceField dday = sortable (Just "attendance") (i18nCell $ MsgTutorialDayAttendance dday) $ (cellAttrs %~ addAttrsClass "text--center") <$> formCell id + (views (resultParticipant . _entityKey) return) + (\(preview (resultParticipantDay . _tutorialParticipantDayAttendance) -> attendance) mkUnique -> + over (_1.mapped) (_dailyFormAttendance .~) . over _2 fvWidget <$> mreq checkBoxField (fsUniq mkUnique "attendance") attendance + ) + +colAttendanceNoteField :: Text -> Colonnade Sortable DailyTableData (DBCell _ (FormResult (DBFormResult TutorialParticipantId DailyFormData DailyTableData))) +colAttendanceNoteField dday = sortable (Just "note-attend") (i18nCell $ MsgTutorialDayNote dday) $ (cellAttrs <>~ [("style","min-width:12em")]) <$> + formCell id + (views (resultParticipant . _entityKey) return) + (\row mkUnique -> + let note = row ^? resultParticipantDay . _tutorialParticipantDayNote + sid = row ^. resultCourse . _entityVal . _courseSchool + cid = row ^. resultCourse . _entityKey + tid = row ^. resultTutorial . _entityKey + in over (_1.mapped) ((_dailyFormAttendanceNote .~) . assertM (not . null) . fmap Text.strip) . over _2 fvWidget <$> -- For Textarea use: fmap (Text.strip . unTextarea) + mopt (textField & cfStrip & addDatalist (suggsAttendanceNote sid cid tid)) (fsUniq mkUnique "note-attendance") note + ---- Version für Textare + -- mopt (textareaField) -- & addDatalist (suggsAttendanceNote sid cid tid)) -- NOTE: datalist does not work on textarea inputs + -- (fsUniq mkUnique "note-attendance" & addClass' "uwx-short" + -- -- & addAttr "rows" "2" -- does not work without class uwx-short + -- -- & addAttr "cols" "12" -- let it stretch + -- -- & addAutosubmit -- submits while typing + -- ) (Textarea <<$>> note) + ) + +colParkingField :: Text -> Colonnade Sortable DailyTableData (DBCell _ (FormResult (DBFormResult TutorialParticipantId DailyFormData DailyTableData))) +colParkingField = colParkingField' _dailyFormParkingToken + +-- colParkingField' :: ASetter' a Bool -> Colonnade Sortable DailyTableData (DBCell _ (FormResult (DBFormResult TutorialParticipantId a DailyTableData))) +-- colParkingField' l = sortable (Just "parking") (i18nCell MsgTableUserParkingToken) $ formCell id +-- (views (resultParticipant . _entityKey) return) +-- (\(preview (resultUserDay . _userDayParkingToken) -> parking) mkUnique -> +-- over (_1.mapped) (l .~) . over _2 fvWidget <$> mreq checkBoxField (fsUniq mkUnique "parktoken") parking +-- ) + +colParkingField' :: ASetter' a Bool -> Text -> Colonnade Sortable DailyTableData (DBCell _ (FormResult (DBFormResult TutorialParticipantId a DailyTableData))) +colParkingField' l dday = sortable (Just "parking") (i18nCell $ MsgTableUserParkingToken dday) $ (cellAttrs %~ addAttrsClass "text--center") <$> formCell + id -- TODO: this should not be id! Refactor to simplify the third argument below + (views (resultParticipant . _entityKey) return) + (\(preview (resultUserDay . _userDayParkingToken) -> parking) mkUnique -> + over (_1.mapped) (l .~) . over _2 fvWidget <$> mreq checkBoxField (fsUniq mkUnique "parktoken") parking + ) + +mkDailyTable :: Bool -> SchoolId -> Day -> Maybe DayCheckResults -> DB (FormResult (DBFormResult TutorialParticipantId DailyFormData DailyTableData), Maybe Widget) +mkDailyTable isAdmin ssh nd dcrs = getDayTutorials ssh (nd,nd) >>= \case + tutLessons + | Map.null tutLessons -> return (FormMissing, Nothing) + | otherwise -> do + dday <- formatTime SelFormatDate nd + let + tutIds = Map.keys tutLessons + dbtSQLQuery :: DailyTableExpr -> DailyTableOutput + dbtSQLQuery (crs `E.InnerJoin` tut `E.InnerJoin` tpu `E.InnerJoin` usr `E.LeftOuterJoin` avs `E.LeftOuterJoin` udy `E.LeftOuterJoin` tdy) = do + EL.on $ tut E.^. TutorialId E.=?. tdy E.?. TutorialParticipantDayTutorial + E.&&. usr E.^. UserId E.=?. tdy E.?. TutorialParticipantDayUser + E.&&. E.val nd E.=?. tdy E.?. TutorialParticipantDayDay + EL.on $ usr E.^. UserId E.=?. udy E.?. UserDayUser + E.&&. E.val nd E.=?. udy E.?. UserDayDay + EL.on $ usr E.^. UserId E.=?. avs E.?. UserAvsUser + EL.on $ usr E.^. UserId E.==. tpu E.^. TutorialParticipantUser + EL.on $ tut E.^. TutorialId E.==. tpu E.^. TutorialParticipantTutorial + EL.on $ tut E.^. TutorialCourse E.==. crs E.^. CourseId + E.where_ $ tut E.^. TutorialId `E.in_` E.valList tutIds + let associatedQualifications = E.subSelectMaybe . EL.from $ \cq -> do + E.where_ $ cq E.^. CourseQualificationCourse E.==. crs E.^. CourseId + let cqQual = cq E.^. CourseQualificationQualification + cqOrder = [E.asc $ cq E.^. CourseQualificationSortOrder, E.asc cqQual] + return $ E.arrayAggWith E.AggModeAll cqQual cqOrder + return (crs, tut, tpu, usr, avs, udy, tdy, selectCompanyUserPrime usr, associatedQualifications) + dbtRowKey = queryParticipant >>> (E.^. TutorialParticipantId) + dbtProj = dbtProjId + dbtColonnade = formColonnade $ mconcat + [ -- dbSelect (applying _2) id (return . view (resultTutorial . _entityKey)) + sortable (Just "course") (i18nCell MsgTableCourse) $ \(view $ resultCourse . _entityVal -> c) -> courseCell c + , sortable (Just "tutorial") (i18nCell MsgCourseTutorial) $ \row -> + let Course{courseTerm=tid, courseSchool=cssh, courseShorthand=csh} + = row ^. resultCourse . _entityVal + tutName = row ^. resultTutorial . _entityVal . _tutorialName + in anchorCell (CTutorialR tid cssh csh tutName TUsersR) $ citext2widget tutName + , sortable Nothing (i18nCell MsgTableTutorialOccurrence) $ \(view $ resultTutorial . _entityKey -> tutId) -> cellMaybe (lessonTimesCell False . snd) $ Map.lookup tutId tutLessons + , sortable Nothing (i18nCell MsgTableTutorialRoom) $ \(view $ resultTutorial . _entityKey -> tutId) -> + -- listInlineCell (nubOrd . concat $ mapMM lessonRoom $ Map.lookup tutId tutLessons) roomReferenceCell + cellMaybe ((`listInlineCell` roomReferenceCell) . nubOrd) $ mapMM lessonRoom $ snd <$> Map.lookup tutId tutLessons + -- , sortable Nothing (i18nCell MsgTableTutorialRoom) $ \(view $ resultTutorial . _entityKey -> _) -> listCell ["A","D","C","B"] textCell -- DEMO: listCell reverses the order, for list-types! listInlineCell is fixed now + , sortable Nothing (i18nCell $ MsgCourseQualifications 3) $ \(preview resultCourseQualis -> cqs) -> maybeCell cqs $ flip listInlineCell qualificationIdShortCell + -- , sortable (Just "user-company") (i18nCell MsgTablePrimeCompany) $ \(preview resultCompanyId -> mcid) -> cellMaybe companyIdCell mcid + -- , sortable (Just "booking-firm") (i18nCell MsgTableBookingCompany) $ \(view $ resultParticipant . _entityVal . _tutorialParticipantCompany -> mcid) -> cellMaybe companyIdCell mcid + , sortable (Just "booking-firm") (i18nCell MsgTableBookingCompany) $ \row -> + let bookComp = row ^. resultParticipant . _entityVal . _tutorialParticipantCompany + primComp = row ^? resultCompanyId + bookLink = cellMaybe companyIdCell bookComp + result + | primComp /= bookComp + , Just (unCompanyKey -> csh) <- primComp + = cell (iconTooltip [whamlet|_{MsgAvsPrimaryCompany}: ^{companyWidget True (csh, csh, False)}|] + (Just IconCompany) True) + <> spacerCell + <> bookLink + | otherwise = bookLink + in result + -- , sortable (Just "booking-firm") (i18nCell MsgTableBookingCompany) $ \row -> + -- let bookComp = row ^. resultParticipant . _entityVal . _tutorialParticipantCompany + -- primComp = row ^? resultCompanyId + -- bookLink = cellMaybe companyIdCell bookComp + -- warnIcon = \csh -> iconTooltip [whamlet|_{MsgAvsPrimaryCompany}: ^{companyWidget True (csh, csh, False)}|] (Just IconCompanyWarning) True + -- result + -- | primComp /= bookComp + -- , Just (unCompanyKey -> csh) <- primComp + -- = bookLink + -- <> spacerCell + -- <> cell (modal (warnIcon csh) (Right -- maybe just use iconCompanyWarning instead of modal? + -- [whamlet| + --

            + -- ^{userWidget row} + --

            + -- _{MsgAvsPrimaryCompany}: ^{companyWidget True (csh, csh, False)} + -- |] + -- )) + -- | otherwise = bookLink + -- in result + , maybeEmpty dcrs $ \DayCheckResults{..} -> + sortable (Just "check-fail") (timeCell dcrTimestamp) $ \(view $ resultParticipant . _entityKey -> tpid) -> + maybeCell (Map.lookup tpid dcrResults) $ wgtCell . dcr2widgetIcn Nothing + , colUserNameModalHdr MsgCourseParticipant ForProfileDataR + , colUserMatriclenr isAdmin + , sortable (Just "card-no") (i18nCell MsgAvsCardNo) $ \(preview $ resultUserAvs . _userAvsLastCardNo . _Just -> cn :: Maybe AvsFullCardNo) -> cellMaybe (textCell . tshowAvsFullCardNo) cn + , colParticipantEyeExamField + , colParticipantPermitField + , colParticipantNoteField + , colAttendanceField dday + , colAttendanceNoteField dday + , colParkingField dday + -- FOR DEBUGGING ONLY: + -- , sortable (Just "permit") (i18nCell MsgTutorialDrivingPermit) $ \(view $ resultParticipant . _entityVal . _tutorialParticipantDrivingPermit -> x) -> x & cellMaybe i18nCell + -- , sortable (Just "eye-exam") (i18nCell MsgTutorialEyeExam) $ \(view $ resultParticipant . _entityVal . _tutorialParticipantEyeExam -> x) -> x & cellMaybe i18nCell + -- , sortable (Just "note-tutorial") (i18nCell MsgTutorialNote) $ \(view $ resultParticipant . _entityVal . _tutorialParticipantNote -> x) -> x & cellMaybe textCell + -- , sortable (Just "attendance") (i18nCell $ MsgTutorialDayAttendance dday) $ \(preview $ resultParticipantDay . _tutorialParticipantDayAttendance -> x) -> x & cellMaybe tickmarkCell + -- , sortable (Just "note-attend") (i18nCell $ MsgTutorialDayNote dday) $ \(preview $ resultParticipantDay . _tutorialParticipantDayNote . _Just -> x) -> x & cellMaybe textCell + -- , sortable (Just "parking") (i18nCell MsgTableUserParkingToken) $ \(preview $ resultUserDay . _userDayParkingToken -> x) -> maybeCell x tickmarkCell + ] + dbtSorting = Map.fromList + [ sortUserNameLink queryUser + , sortUserMatriclenr queryUser + , ("course" , SortColumn $ queryCourse >>> (E.^. CourseName)) + , ("tutorial" , SortColumn $ queryTutorial >>> (E.^. TutorialName)) + , ("user-company" , SortColumn $ queryUser >>> selectCompanyUserPrime) + , ("booking-firm" , SortColumn $ queryParticipant >>> (E.^. TutorialParticipantCompany)) + , ("card-no" , SortColumn $ queryUserAvs >>> (E.?. UserAvsLastCardNo)) + , ("permit" , SortColumnNullsInv $ queryParticipant >>> (E.^. TutorialParticipantDrivingPermit)) + , ("eye-exam" , SortColumnNullsInv $ queryParticipant >>> (E.^. TutorialParticipantEyeExam)) + , ("note-tutorial" , SortColumn $ queryParticipant >>> (E.^. TutorialParticipantNote)) + , ("attendance" , SortColumnNullsInv $ queryParticipantDay >>> (E.?. TutorialParticipantDayAttendance)) + , ("note-attend" , SortColumn $ queryParticipantDay >>> (E.?. TutorialParticipantDayNote)) + , ("parking" , SortColumnNullsInv $ queryUserDay >>> (E.?. UserDayParkingToken)) + -- , ("check-fail" , SortColumn $ queryParticipant >>> (\pid -> pid E.^. TutorialParticipantId `E.in_` E.vals (maybeEmpty dcrs $ dcrResults >>> Map.keys))) + , let dcrsLevels = maybeEmpty dcrs $ dcrSeverityGroups . dcrResults in + ("check-fail" , SortColumn $ queryParticipant >>> (\((E.^. TutorialParticipantId) -> pid) -> E.case_ + [ E.when_ (pid `E.in_` E.vals (dcrsLevels ^. _1)) E.then_ (E.val 1) + , E.when_ (pid `E.in_` E.vals (dcrsLevels ^. _2)) E.then_ (E.val 2) + , E.when_ (pid `E.in_` E.vals (dcrsLevels ^. _3)) E.then_ (E.val 3) + , E.when_ (pid `E.in_` E.vals (dcrsLevels ^. _4)) E.then_ (E.val 4) + , E.when_ (pid `E.in_` E.vals (dcrsLevels ^. _5)) E.then_ (E.val 5) + ] (E.else_ E.val (99 :: Int64)) + )) + ] + dbtFilter = Map.fromList + [ fltrUserNameEmail queryUser + , fltrUserMatriclenr queryUser + , ("course" , FilterColumn . E.mkContainsFilter $ queryCourse >>> (E.^. CourseName)) + , ("tutorial" , FilterColumn . E.mkContainsFilter $ queryTutorial >>> (E.^. TutorialName)) + , ("booking-firm" , FilterColumn . E.mkContainsFilterWith Just $ queryParticipant >>> (E.^. TutorialParticipantCompany)) + , ("user-company" , FilterColumn . E.mkContainsFilterWith Just $ queryUser >>> selectCompanyUserPrime) + ] + dbtFilterUI mPrev = mconcat + [ prismAForm (singletonFilter "course" . maybePrism _PathPiece) mPrev $ aopt (hoistField lift textField) (fslI MsgFilterCourse) + , prismAForm (singletonFilter "tutorial" . maybePrism _PathPiece) mPrev $ aopt (hoistField lift textField) (fslI MsgCourseTutorial) + , prismAForm (singletonFilter "booking-firm" . maybePrism _PathPiece) mPrev $ aopt (hoistField lift textField) (fslI MsgTableBookingCompanyShort) + , prismAForm (singletonFilter "user-company" . maybePrism _PathPiece) mPrev $ aopt (hoistField lift textField) (fslI MsgTablePrimeCompanyShort) + , fltrUserNameEmailUI mPrev + , fltrUserMatriclenrUI mPrev + ] + dbtStyle = def { dbsFilterLayout = defaultDBSFilterLayout} + dbtIdent :: Text + dbtIdent = "daily" + dbtCsvEncode = noCsvEncode + dbtCsvDecode = Nothing + dbtExtraReps = [] + dbtParams = def { dbParamsFormAction = Just $ SomeRoute $ SchoolR ssh $ SchoolDayR nd } + -- dbtParams = DBParamsForm + -- { dbParamsFormMethod = POST + -- , dbParamsFormAction = Nothing -- Just $ SomeRoute currentRoute + -- , dbParamsFormAttrs = [] + -- , dbParamsFormSubmit = FormSubmit + -- , dbParamsFormAdditional = \frag -> do + -- let acts :: Map DailyTableAction (AForm Handler DailyTableActionData) + -- acts = mconcat + -- [ singletonMap DailyActDummy $ pure DailyActDummyData + -- ] + -- (actionRes, action) <- multiActionM acts "" Nothing mempty + -- return ((, mempty) . Last . Just <$> actionRes, toWidget frag <> action) + -- -- , dbParamsFormAdditional + -- -- = let acts :: Map DailyTableAction (AForm Handler DailyTableActionData) + -- -- acts = mconcat + -- -- [ singletonMap DailyActDummy $ pure DailyActDummyData + -- -- ] + -- -- in renderAForm FormStandard + -- -- $ (, mempty) . First . Just + -- -- <$> multiActionA acts (fslI MsgTableAction) Nothing + -- , dbParamsFormEvaluate = liftHandler . runFormPost + -- , dbParamsFormResult = _1 + -- , dbParamsFormIdent = def + -- } + -- postprocess :: FormResult (First DailyTableActionData, DBFormResult TutorialParticipantId Bool DailyTableData) + -- -> FormResult ( DailyTableActionData, Set TutorialId) + -- postprocess inp = do + -- (First (Just act), jobMap) <- inp + -- let jobSet = Map.keysSet . Map.filter id $ getDBFormResult (const False) jobMap + -- return (act, jobSet) + psValidator = def & defaultSorting [SortAscBy "user-name", SortAscBy "course", SortAscBy "tutorial"] + -- over _1 postprocess <$> dbTable psValidator DBTable{..} + over _2 Just <$> dbTable psValidator DBTable{..} + + +getSchoolDayR, postSchoolDayR :: SchoolId -> Day -> Handler Html +getSchoolDayR = postSchoolDayR +postSchoolDayR ssh nd = do + isAdmin <- hasReadAccessTo AdminR + dday <- formatTime SelFormatDate nd + let unFormResult = getDBFormResult $ \row -> let tpt = row ^. resultParticipant . _entityVal + in DailyFormData + { dailyFormDrivingPermit = tpt ^. _tutorialParticipantDrivingPermit + , dailyFormEyeExam = tpt ^. _tutorialParticipantEyeExam + , dailyFormParticipantNote = tpt ^. _tutorialParticipantNote + , dailyFormAttendance = row ^? resultParticipantDay ._tutorialParticipantDayAttendance & fromMaybe False + , dailyFormAttendanceNote = row ^? resultParticipantDay ._tutorialParticipantDayNote . _Just + , dailyFormParkingToken = row ^? resultUserDay . _userDayParkingToken & fromMaybe False + } + dcrs <- memcachedByGet (CacheKeyTutorialCheckResults ssh nd) + (fmap unFormResult -> tableRes, tableDaily) <- runDB $ mkDailyTable isAdmin ssh nd dcrs + -- logInfoS "****DailyTable****" $ tshow tableRes + formResult tableRes $ \resMap -> do + tuts <- runDB $ forM (Map.toList resMap) $ \(tpid, DailyFormData{..}) -> do + -- logDebugS "TableForm" (tshow dfd) + TutorialParticipant{..} <- get404 tpid -- needed anyway to find the ParticipantDay/UserDay updated + when ( tutorialParticipantDrivingPermit /= dailyFormDrivingPermit + || tutorialParticipantEyeExam /= dailyFormEyeExam + || tutorialParticipantNote /= dailyFormParticipantNote) $ + update tpid [ TutorialParticipantDrivingPermit =. dailyFormDrivingPermit + , TutorialParticipantEyeExam =. dailyFormEyeExam + , TutorialParticipantNote =. dailyFormParticipantNote + ] + let tpdUq = UniqueTutorialParticipantDay tutorialParticipantTutorial tutorialParticipantUser nd + if not dailyFormAttendance && isNothing (canonical dailyFormAttendanceNote) + then deleteBy tpdUq + else upsertBy_ tpdUq (TutorialParticipantDay tutorialParticipantTutorial tutorialParticipantUser nd dailyFormAttendance dailyFormAttendanceNote) + [ TutorialParticipantDayAttendance =. dailyFormAttendance + , TutorialParticipantDayNote =. dailyFormAttendanceNote + ] + let udUq = UniqueUserDay tutorialParticipantUser nd + updateUserDay = if dailyFormParkingToken + then flip upsertBy_ (UserDay tutorialParticipantUser nd dailyFormParkingToken) -- upsert if a permit was issued + else updateBy -- only update to no permit, if the record exists, but do not create a fresh record with parkingToken==False + updateUserDay udUq [ UserDayParkingToken =. dailyFormParkingToken] + return tutorialParticipantTutorial + forM_ tuts $ \tid -> do + memcachedByInvalidate (CacheKeySuggsParticipantNote ssh tid) $ Proxy @(OptionListCacheable Text) + memcachedByInvalidate (CacheKeySuggsAttendanceNote ssh tid) $ Proxy @(OptionListCacheable Text) + -- audit log? Currently decided against. + memcachedByInvalidate (CacheKeyTutorialCheckResults ssh nd) $ Proxy @DayCheckResults + addMessageI Success $ MsgTutorialParticipantsDayEdits dday + redirect $ SchoolR ssh $ SchoolDayR nd + + siteLayoutMsg (MsgMenuSchoolDay ssh dday) $ do + let consistencyBtn = btnModal MsgMenuSchoolDayCheck [BCIsButton, BCDefault] (Left $ SomeRoute $ SchoolR ssh $ SchoolDayCheckR nd) + setTitleI (MsgMenuSchoolDay ssh dday) + $(i18nWidgetFile "day-view") + + +-- | A wrapper for several check results on tutorial participants +data DayCheckResult = DayCheckResult + { dcAvsKnown :: Bool + , dcApronAccess :: Bool + , dcBookingFirmOk :: Bool + , dcEyeFitsPermit :: Maybe Bool + } + deriving (Eq, Show, Generic, Binary) + +data DayCheckResults = DayCheckResults + { dcrTimestamp :: UTCTime + , dcrResults :: Map TutorialParticipantId DayCheckResult + } + deriving (Show, Generic, Binary) + +-- | True iff there is no problem at all +dcrIsOk :: DayCheckResult -> Bool +dcrIsOk (DayCheckResult True True True (Just True)) = True +dcrIsOk _ = False + +-- | defines categories on DayCheckResult, implying an ordering, with most severe being least +dcrSeverity :: DayCheckResult -> Int +dcrSeverity DayCheckResult{dcAvsKnown = False } = 1 +dcrSeverity DayCheckResult{dcApronAccess = False } = 2 +dcrSeverity DayCheckResult{dcBookingFirmOk = False } = 3 +dcrSeverity DayCheckResult{dcEyeFitsPermit = Nothing } = 4 +dcrSeverity DayCheckResult{dcEyeFitsPermit = Just False} = 5 +dcrSeverity _ = 99 + +instance Ord DayCheckResult where + compare = compare `on` dcrSeverity + +type DayCheckGroups = ( Set TutorialParticipantId -- 1 severity + , Set TutorialParticipantId -- 2 + , Set TutorialParticipantId -- 3 + , Set TutorialParticipantId -- 4 + , Set TutorialParticipantId -- 5 + ) + +dcrSeverityGroups :: Map TutorialParticipantId DayCheckResult -> DayCheckGroups +dcrSeverityGroups = Map.foldMapWithKey groupBySeverity + where + groupBySeverity :: TutorialParticipantId -> DayCheckResult -> DayCheckGroups + groupBySeverity tpid dcr = + let sempty = mempty :: DayCheckGroups + in case dcrSeverity dcr of + 1 -> set _1 (Set.singleton tpid) sempty + 2 -> set _2 (Set.singleton tpid) sempty + 3 -> set _3 (Set.singleton tpid) sempty + 4 -> set _4 (Set.singleton tpid) sempty + 5 -> set _5 (Set.singleton tpid) sempty + _ -> sempty + +-- | Possible outcomes for DayCheckResult +dcrMessages :: [SomeMessage UniWorX] +dcrMessages = [ SomeMessage MsgAvsPersonSearchEmpty + , SomeMessage MsgAvsNoApronCard + , SomeMessage $ MsgAvsNoCompanyCard Nothing + , SomeMessage MsgCheckEyePermitMissing + , SomeMessage MsgCheckEyePermitIncompatible + ] + +-- | Show most important problem as text +dcr2widgetTxt :: Maybe CompanyName -> DayCheckResult -> Widget +dcr2widgetTxt _ DayCheckResult{dcAvsKnown=False} = i18n MsgAvsPersonSearchEmpty +dcr2widgetTxt _ DayCheckResult{dcApronAccess=False} = i18n MsgAvsNoApronCard +dcr2widgetTxt mcn DayCheckResult{dcBookingFirmOk=False} = i18n $ MsgAvsNoCompanyCard mcn +dcr2widgetTxt _ DayCheckResult{dcEyeFitsPermit=Nothing} = i18n MsgCheckEyePermitMissing +dcr2widgetTxt _ DayCheckResult{dcEyeFitsPermit=Just False}= i18n MsgCheckEyePermitIncompatible +dcr2widgetTxt _ _ = i18n MsgNoProblem + +-- | Show all problems as icon with tooltip +dcr2widgetIcn :: Maybe CompanyName -> DayCheckResult -> Widget +dcr2widgetIcn mcn DayCheckResult{..} = mconcat [avsChk, apronChk, bookChk, permitChk] + where + mkTooltip ico msg = iconTooltip msg (Just ico) True + + avsChk = guardMonoid (not dcAvsKnown) $ mkTooltip IconUserUnknown (i18n MsgAvsPersonSearchEmpty) + apronChk = guardMonoid (not dcApronAccess) $ mkTooltip IconUserBadge (i18n MsgAvsNoApronCard) + bookChk = guardMonoid (not dcBookingFirmOk) $ mkTooltip IconCompanyWarning (i18n $ MsgAvsNoCompanyCard mcn) + permitChk | isNothing dcEyeFitsPermit = mkTooltip IconFileMissing (i18n MsgCheckEyePermitMissing) + | dcEyeFitsPermit == Just False = mkTooltip IconGlasses (i18n MsgCheckEyePermitIncompatible) + | otherwise = mempty + +type ParticipantCheckData = (Entity TutorialParticipant, UserDisplayName, UserSurname, Maybe AvsPersonId, Maybe CompanyName) + +dayCheckParticipant :: Map AvsPersonId AvsDataPerson + -> ParticipantCheckData + -> DayCheckResult +dayCheckParticipant avsStats (Entity {entityVal=TutorialParticipant{..}}, _udn, _usn, mapi, mcmp) = + let dcEyeFitsPermit = liftM2 eyeExamFitsDrivingPermit tutorialParticipantEyeExam tutorialParticipantDrivingPermit + (dcAvsKnown, (dcApronAccess, dcBookingFirmOk)) + | Just AvsDataPerson{avsPersonPersonCards = apcs} <- lookupMaybe avsStats mapi + = (True , mapBoth getAny $ foldMap (hasApronAccess &&& fitsBooking mcmp) apcs) + | otherwise + = (False, (False, False)) + in DayCheckResult{..} + where + hasApronAccess :: AvsDataPersonCard -> Any + hasApronAccess AvsDataPersonCard{avsDataValid=True, avsDataCardColor=AvsCardColorGelb} = Any True + hasApronAccess AvsDataPersonCard{avsDataValid=True, avsDataCardColor=AvsCardColorRot} = Any True + hasApronAccess _ = Any False + + fitsBooking :: Maybe CompanyName -> AvsDataPersonCard -> Any + fitsBooking (Just cn) AvsDataPersonCard{avsDataValid=True,avsDataFirm=Just df} = Any $ cn == stripCI df + fitsBooking _ _ = Any False + +-- | Prüft die Teilnehmer der Tagesansicht: AVS online aktualisieren, gültigen Vorfeldausweis prüfen, buchende Firma mit Ausweisnummer aus AVS abgleichen +getSchoolDayCheckR :: SchoolId -> Day -> Handler Html +getSchoolDayCheckR ssh nd = do + -- isAdmin <- hasReadAccessTo AdminR + now <- liftIO getCurrentTime + let nowaday = utctDay now + dday <- formatTime SelFormatDate nd + + (tuts, parts_avs, examProblemsTbl) <- runDB $ do + tuts <- getDayTutorials ssh (nd,nd) + parts_avs :: [ParticipantCheckData] <- $(unValueNIs 5 [2..5]) <<$>> E.select (do + (tpa :& usr :& avs :& cmp) <- E.from $ E.table @TutorialParticipant + `E.innerJoin` E.table @User + `E.on` (\(tpa :& usr) -> tpa E.^. TutorialParticipantUser E.==. usr E.^. UserId) + `E.leftJoin` E.table @UserAvs + `E.on` (\(tpa :& _ :& avs) -> tpa E.^. TutorialParticipantUser E.=?. avs E.?. UserAvsUser) + `E.leftJoin` E.table @Company + `E.on` (\(tpa :& _ :& _ :& cmp) -> tpa E.^. TutorialParticipantCompany E.==. cmp E.?. CompanyId) + E.where_ $ tpa E.^. TutorialParticipantTutorial `E.in_` E.vals (Map.keys tuts) + -- E.orderBy [E.asc $ tpa E.^. TutorialParticipantTutorial, E.asc $ usr E.^. UserDisplayName] -- order no longer needed + return (tpa, usr E.^. UserDisplayName, usr E.^. UserSurname, avs E.?. UserAvsPersonId, cmp E.?. CompanyName) + ) + -- additionally queue proper AVS synchs for all users, unless there were already done today + void $ queueAvsUpdateByUID (foldMap (^. _1 . _entityVal . _tutorialParticipantUser . to Set.singleton) parts_avs) (Just nowaday) + -- check for double examiners + examProblemsTbl <- mkExamProblemsTable ssh nd + return (tuts, parts_avs, examProblemsTbl) + let getApi :: ParticipantCheckData -> Set AvsPersonId + getApi = foldMap Set.singleton . view _4 + avsStats :: Map AvsPersonId AvsDataPerson <- catchAVShandler False False True mempty $ lookupAvsUsers $ foldMap getApi parts_avs -- query AVS, but does not affect DB (no update) + -- gültigen Vorfeldausweis prüfen, buchende Firma mit Ausweisnummer aus AVS abgleichen + let toPartMap :: ParticipantCheckData -> Map TutorialParticipantId DayCheckResult + toPartMap pcd = Map.singleton (pcd ^. _1 . _entityKey) $ dayCheckParticipant avsStats pcd + participantResults = foldMap toPartMap parts_avs + memcachedBySet (Just . Right $ 2 * diffHour) (CacheKeyTutorialCheckResults ssh nd) $ DayCheckResults now participantResults + + -- the following is only for displaying results neatly + let sortBadParticipant acc pcd = + let tid = pcd ^. _1 . _entityVal . _tutorialParticipantTutorial + pid = pcd ^. _1 . _entityKey + udn = pcd ^. _2 + ok = maybe False dcrIsOk $ Map.lookup pid participantResults + in if ok then acc else Map.insertWith (<>) tid (Map.singleton (udn,pid) pcd) acc + badTutPartMap :: Map TutorialId (Map (UserDisplayName, TutorialParticipantId) ParticipantCheckData) -- UserDisplayName as Key ensures proper sort order + badTutPartMap = foldl' sortBadParticipant mempty parts_avs + + mkBaddieWgt :: TutorialParticipantId -> ParticipantCheckData -> Widget + mkBaddieWgt pid pcd = + let name = nameWidget (pcd ^. _2) (pcd ^. _3) + bookFirm = pcd ^. _5 + problemText = maybe (text2widget "???") (dcr2widgetTxt bookFirm) (Map.lookup pid participantResults) + problemIcons = maybe mempty (dcr2widgetIcn bookFirm) (Map.lookup pid participantResults) + in [whamlet|^{name}: ^{problemIcons} ^{problemText}|] + + siteLayoutMsg MsgMenuSchoolDayCheck $ do + setTitleI MsgMenuSchoolDayCheck + [whamlet| +

            +

            + _{MsgMenuSchoolDay ssh dday} +

            + $if Map.null badTutPartMap + _{MsgNoProblem}. + $else +

            + $forall (tid,badis) <- Map.toList badTutPartMap +
            + #{maybe "???" fst (Map.lookup tid tuts)} +
            +
              + $forall ((_udn,pid),pcd) <- Map.toList badis +
            • + ^{mkBaddieWgt pid pcd} +
              +

              +

              + _{MsgPossibleCheckResults} +

              +

                + $forall msg <- dcrMessages +
              • _{msg} +

                + _{MsgAvsUpdateDayCheck} +

                + ^{maybeTable' MsgExamProblemReoccurrence (Just MsgExamNoProblemReoccurrence) Nothing examProblemsTbl} +
                + ^{linkButton mempty (i18n MsgBtnCloseReload) [BCIsButton, BCPrimary] (SomeRoute (SchoolR ssh (SchoolDayR nd)))} + |] + + +type TblExamPrbsExpr = ( E.SqlExpr (Entity Course) + `E.InnerJoin` E.SqlExpr (Entity Exam) + `E.InnerJoin` E.SqlExpr (Entity ExamRegistration) + `E.InnerJoin` E.SqlExpr (Entity ExamOccurrence) + `E.InnerJoin` E.SqlExpr (Entity User) + `E.InnerJoin` E.SqlExpr (Entity User) + ) +type TblExamPrbsData = DBRow (Entity Course, Entity Exam, Entity ExamRegistration, Entity ExamOccurrence, Entity User, Entity User) + +-- | Table listing double examiner problems for a given school and day +mkExamProblemsTable :: SchoolId -> Day -> DB (Bool, Widget) +mkExamProblemsTable = + let dbtIdent = "exams-user" :: Text + dbtStyle = def + dbtSQLQuery' exOccs (crs `E.InnerJoin` exm `E.InnerJoin` reg `E.InnerJoin` occ `E.InnerJoin` usr `E.InnerJoin` xmr) = do + EL.on $ xmr E.^. UserId E.=?. occ E.^. ExamOccurrenceExaminer + EL.on $ usr E.^. UserId E.==. reg E.^. ExamRegistrationUser + EL.on $ occ E.^. ExamOccurrenceId E.=?. reg E.^. ExamRegistrationOccurrence + EL.on $ exm E.^. ExamId E.==. reg E.^. ExamRegistrationExam + EL.on $ exm E.^. ExamCourse E.==. crs E.^. CourseId + E.where_ $ occ E.^. ExamOccurrenceId `E.in_` E.vals exOccs + E.&&. E.exists (do + altReg :& altOcc <- E.from $ E.table @ExamRegistration `E.innerJoin` E.table @ExamOccurrence + `E.on` (\(altReg :& altOcc) -> altReg E.^. ExamRegistrationOccurrence E.?=. altOcc E.^. ExamOccurrenceId) + E.where_ $ altReg E.^. ExamRegistrationUser E.==. reg E.^. ExamRegistrationUser + E.&&. altReg E.^. ExamRegistrationId E.!=. reg E.^. ExamRegistrationId + E.&&. altOcc E.^. ExamOccurrenceExaminer E.==. occ E.^. ExamOccurrenceExaminer + E.&&. altOcc E.^. ExamOccurrenceId E.!=. occ E.^. ExamOccurrenceId + ) + return (crs,exm,reg,occ,usr,xmr) + queryExmCourse :: TblExamPrbsExpr -> E.SqlExpr (Entity Course) + queryExmCourse = $(sqlIJproj 6 1) + queryExam :: TblExamPrbsExpr -> E.SqlExpr (Entity Exam) + queryExam = $(sqlIJproj 6 2) + queryRegistration :: TblExamPrbsExpr -> E.SqlExpr (Entity ExamRegistration) + queryRegistration = $(sqlIJproj 6 3) + queryOccurrence :: TblExamPrbsExpr -> E.SqlExpr (Entity ExamOccurrence) + queryOccurrence = $(sqlIJproj 6 4) + queryTestee :: TblExamPrbsExpr -> E.SqlExpr (Entity User) + queryTestee = $(sqlIJproj 6 5) + queryExaminer :: TblExamPrbsExpr -> E.SqlExpr (Entity User) + queryExaminer = $(sqlIJproj 6 6) + resultExmCourse :: Lens' TblExamPrbsData (Entity Course) + resultExmCourse = _dbrOutput . _1 + resultExam :: Lens' TblExamPrbsData (Entity Exam) + resultExam = _dbrOutput . _2 + resultRegistration :: Lens' TblExamPrbsData (Entity ExamRegistration) + resultRegistration = _dbrOutput . _3 + resultOccurrence :: Lens' TblExamPrbsData (Entity ExamOccurrence) + resultOccurrence = _dbrOutput . _4 + resultTestee :: Lens' TblExamPrbsData (Entity User) + resultTestee = _dbrOutput . _5 + resultExaminer :: Lens' TblExamPrbsData (Entity User) + resultExaminer = _dbrOutput . _6 + dbtRowKey = queryRegistration >>> (E.^. ExamRegistrationId) + dbtProj = dbtProjId + dbtColonnade = mconcat + [ sortable (Just "course") (i18nCell MsgTableCourse) $ fmap addIndicatorCell courseCell <$> view (resultExmCourse . _entityVal) + , sortable (Just "exam") (i18nCell MsgCourseExam) $ \row -> examCell (row ^. resultExmCourse . _entityVal) (row ^. resultExam . _entityVal) + , sortable (Just "registration")(i18nCell MsgCourseExamRegistrationTime)$ dateCell . view (resultRegistration . _entityVal . _examRegistrationTime) + , sortable (Just "occurrence") (i18nCell MsgTableExamOccurrence) $ examOccurrenceCell . view resultOccurrence + , sortable (Just "testee") (i18nCell MsgExamParticipant) $ cellHasUserLink ForProfileDataR . view resultTestee + , sortable (Just "examiner") (i18nCell MsgExamCorrectors) $ cellHasUser . view resultExaminer + ] + validator = def & defaultSorting [SortAscBy "course", SortAscBy "exam", SortAscBy "testee"] -- [SortDescBy "registration"] + dbtSorting = Map.fromList + [ ( "course" , SortColumn $ queryExmCourse >>> (E.^. CourseName)) + , ( "exam" , SortColumn $ queryExam >>> (E.^. ExamName)) + , ( "registration", SortColumn $ queryRegistration >>> (E.^. ExamRegistrationTime)) + , ( "occurrence" , SortColumn $ queryOccurrence >>> (E.^. ExamOccurrenceName)) + , ( "testee" , SortColumn $ queryTestee >>> (E.^. UserDisplayName)) + , ( "examiner" , SortColumn $ queryExaminer >>> (E.^. UserDisplayName)) + ] + dbtFilter = mempty + dbtFilterUI = mempty + dbtParams = def + dbtCsvEncode = noCsvEncode + dbtCsvDecode = Nothing + dbtExtraReps = [] + in \ssh nd -> do + exOccs <- getDayExamOccurrences False ssh Nothing (nd,nd) + let dbtSQLQuery = dbtSQLQuery' $ Map.keys exOccs + (_1 %~ getAny) <$> dbTableWidget validator DBTable{..} + diff --git a/src/Handler/Sheet/Download.hs b/src/Handler/Sheet/Download.hs index 08830b584..40bdded68 100644 --- a/src/Handler/Sheet/Download.hs +++ b/src/Handler/Sheet/Download.hs @@ -66,7 +66,7 @@ getSArchiveR tid ssh csh shn = do | otherwise = f & _FileReference . _1 . _fileReferenceTitle %~ (unpack (mr $ SheetArchiveFileTypeDirectory (sft f)) ) sftDirectories <- if | not multipleSFTs -> return mempty - | otherwise -> runDB . fmap (mapMaybe $ \(sft, mTime) -> (sft, ) <$> mTime) . forM allowedSFTs $ \sft -> fmap ((sft, ) . (=<<) E.unValue) . E.selectMaybe . E.from $ \(sFile `E.FullOuterJoin` psFile) -> do + | otherwise -> runDB . fmap (mapMaybe $ \(sft, mTime) -> (sft, ) <$> mTime) . forM allowedSFTs $ \sft -> fmap ((sft, ) . (=<<) E.unValue) . E.selectOne . E.from $ \(sFile `E.FullOuterJoin` psFile) -> do E.on $ sFile E.?. SheetFileSheet E.==. psFile E.?. PersonalisedSheetFileSheet E.&&. sFile E.?. SheetFileType E.==. psFile E.?. PersonalisedSheetFileType E.&&. sFile E.?. SheetFileTitle E.==. psFile E.?. PersonalisedSheetFileTitle @@ -78,7 +78,7 @@ getSArchiveR tid ssh csh shn = do [ sFile E.?. SheetFileModified , psFile E.?. PersonalisedSheetFileModified ] - + serveZipArchive archiveName $ do forM_ sftDirectories $ \(sft, mTime) -> yield . Left $ SheetFile { sheetFileType = sft diff --git a/src/Handler/Sheet/Form.hs b/src/Handler/Sheet/Form.hs index ee01d5d4e..4bc636607 100644 --- a/src/Handler/Sheet/Form.hs +++ b/src/Handler/Sheet/Form.hs @@ -60,7 +60,7 @@ data SheetPersonalisedFilesForm = SheetPersonalisedFilesForm , spffAllowNonPersonalisedSubmission :: Bool } - + getFtIdMap :: Key Sheet -> DB (SheetFileType -> Set FileReference) getFtIdMap sId = do allSheetFiles <- E.select . E.from $ \sheetFile -> do @@ -84,7 +84,7 @@ makeSheetForm cId msId template = identifyForm FIDsheet . validateForm validateS return ((school, mSchoolAuthorshipStatement), course) sheetPersonalisedFilesForm <- makeSheetPersonalisedFilesForm $ template >>= sfPersonalF - let mkSheetForm + let mkSheetForm sfName sfDescription sfRequireExamRegistration @@ -130,7 +130,7 @@ makeSheetForm cId msId template = identifyForm FIDsheet . validateForm validateS if | isn't _SchoolAuthorshipStatementModeNone schoolSheetAuthorshipStatementMode -> do wformSection MsgSheetAuthorshipStatementSection - let + let reqContentField :: AForm Handler I18nStoredMarkup reqContentField = formResultUnOpt mr' MsgSheetAuthorshipStatementContent `fmapAForm` i18nFieldA htmlField True (\_ -> Nothing) ("authorship-statement" :: Text) @@ -143,7 +143,7 @@ makeSheetForm cId msId template = identifyForm FIDsheet . validateForm validateS if | not schoolSheetAuthorshipStatementAllowOther -> (pure SheetAuthorshipStatementModeEnabled, pure sfAuthorshipStatementExam', ) - <$> (fmap (traverse $ fmap authorshipStatementDefinitionContent) . traverse forcedContentField $ entityVal <$> mSchoolAuthorshipStatement) + <$> fmap (traverse $ fmap authorshipStatementDefinitionContent) (traverse (forcedContentField . entityVal) mSchoolAuthorshipStatement) | otherwise -> do examOpts <- let examFieldQuery = E.from $ \exam -> do @@ -205,7 +205,7 @@ makeSheetForm cId msId template = identifyForm FIDsheet . validateForm validateS #{iconFileZip} \ _{MsgSheetPersonalisedFilesDownload} |] - listRoute <- for mbSheet $ \(sheetName -> shn) -> toTextUrl + listRoute <- for mbSheet $ \(sheetName -> shn) -> toTextUrl ( CourseR courseTerm courseSchool courseShorthand CUsersR , [ ("courseUsers-has-personalised-sheet-files" , toPathPiece shn diff --git a/src/Handler/Sheet/Show.hs b/src/Handler/Sheet/Show.hs index 62a25cf60..1bdc42880 100644 --- a/src/Handler/Sheet/Show.hs +++ b/src/Handler/Sheet/Show.hs @@ -128,7 +128,7 @@ getSShowR tid ssh csh shn = do [ wouldHaveWriteAccessToIff [(AuthExamRegistered, True)] $ CSheetR tid ssh csh shn SubmissionNewR , wouldHaveReadAccessToIff [(AuthExamRegistered, True)] $ CSheetR tid ssh csh shn SArchiveR ] - mRequiredExam <- fmap join . for (guardOnM checkExamRegistration $ sheetRequireExamRegistration sheet) $ \eId -> fmap (fmap $(E.unValueN 4)) . runDB . E.selectMaybe . E.from $ \(exam `E.InnerJoin` course) -> do + mRequiredExam <- fmap join . for (guardOnM checkExamRegistration $ sheetRequireExamRegistration sheet) $ \eId -> fmap (fmap $(E.unValueN 4)) . runDB . E.selectOne . E.from $ \(exam `E.InnerJoin` course) -> do E.on $ exam E.^. ExamCourse E.==. course E.^. CourseId E.where_ $ exam E.^. ExamId E.==. E.val eId return (course E.^. CourseTerm, course E.^. CourseSchool, course E.^. CourseShorthand, exam E.^. ExamName) diff --git a/src/Handler/Submission/Assign.hs b/src/Handler/Submission/Assign.hs index 916db5e82..a82f002b1 100644 --- a/src/Handler/Submission/Assign.hs +++ b/src/Handler/Submission/Assign.hs @@ -129,9 +129,9 @@ assignHandler tid ssh csh cid assignSids = do alert_ok = toMaybe (nr_ok > 0) $ SomeMessage $ MsgUpdatedSheetCorrectorsAutoAssigned nr_ok alert_fail = toMaybe (nr_fail > 0) $ SomeMessage $ MsgUpdatedSheetCorrectorsAutoFailed nr_fail msg_status = bool Success Error $ nr_fail > 0 - msg_header = SomeMessage $ shn <> ":" + msg_header = SomeMessage $ shn <> ": " if | nr_ok > 0 || nr_fail > 0 -> do - addMessageI msg_status $ UniWorXMessages $ msg_header : catMaybes [alert_ok, alert_fail] + addMessageI msg_status $ SomeMessages " " $ msg_header : catMaybes [alert_ok, alert_fail] return $ Just status | otherwise -> do addMessageI Error $ MsgSheetsUnassignable $ CI.original shn diff --git a/src/Handler/Term.hs b/src/Handler/Term.hs index 345f0d882..569abfb37 100644 --- a/src/Handler/Term.hs +++ b/src/Handler/Term.hs @@ -29,7 +29,7 @@ import qualified Control.Monad.State.Class as State validateTerm :: (MonadHandler m, HandlerSite m ~ UniWorX) => FormValidator TermForm m () validateTerm = do - TermForm{..} <- State.get + TermForm{..} <- State.get guardValidation MsgTermEndMustBeAfterStart $ tfStart < tfEnd guardValidation MsgTermLectureEndMustBeAfterStart $ tfLectureStart < tfLectureEnd guardValidation MsgTermStartMustBeBeforeLectureStart $ tfStart <= tfLectureStart @@ -64,17 +64,17 @@ getTermShowR = do #{iconMenuAdmin} |] , sortable (Just "lecture-start") (i18nCell MsgLectureStart) $ \(Entity _ Term{..},_,_) - -> cell $ formatTime SelFormatDate termLectureStart >>= toWidget + -> dayCell termLectureStart , sortable (Just "lecture-end") (i18nCell MsgTermLectureEnd) $ \(Entity _ Term{..},_,_) - -> cell $ formatTime SelFormatDate termLectureEnd >>= toWidget + -> dayCell termLectureEnd , sortable Nothing (i18nCell MsgTermActive) $ \(_, _, E.Value isActive) -> tickmarkCell isActive , sortable Nothing (i18nCell MsgTermCourseCount) $ \(_, E.Value numCourses, _) -> cell [whamlet|_{MsgNumCourses numCourses}|] , sortable (Just "start") (i18nCell MsgTermStart) $ \(Entity _ Term{..},_, _) - -> cell $ formatTime SelFormatDate termStart >>= toWidget + -> dayCell termStart , sortable (Just "end") (i18nCell MsgTermEnd) $ \(Entity _ Term{..},_, _) - -> cell $ formatTime SelFormatDate termEnd >>= toWidget + -> dayCell termEnd , sortable Nothing (i18nCell MsgTermHolidays) $ \(Entity _ Term{..},_, _) -> cell $ do let termHolidays' = groupHolidays termHolidays @@ -87,7 +87,7 @@ getTermShowR = do $of Left singleHoliday ^{formatTimeW SelFormatDate singleHoliday} $of Right (startD, endD) - ^{formatTimeRangeW SelFormatDate startD (Just endD)} + ^{formatTimeRangeW SelFormatDate startD (Just endD)} |] ] dbtSorting = Map.fromList @@ -150,11 +150,11 @@ postTermEditR = do Set.unions $ bankHolidaysAreaSet Fraport <$> [getYear tStart..getYear tEnd] in mempty { tftName = Just ntid - , tftStart = Just tStart - , tftEnd = Just tEnd + , tftStart = Just tStart + , tftEnd = Just tEnd , tftLectureStart = Just tLecStart , tftLectureEnd = Just tLecEnd - , tftHolidays = Just tHolys + , tftHolidays = Just tHolys } termEditHandler Nothing template @@ -201,6 +201,7 @@ termEditHandler mtid template = do , termActiveFor = tafFor } lift . audit $ TransactionTermEdit tid + memcachedInvalidateClass MemcachedKeyClassTutorialOccurrences addMessageI Success $ MsgTermEdited tid redirect TermShowR FormMissing -> return () @@ -332,7 +333,7 @@ newTermForm mtid template = validateForm validateTerm $ \html -> do (fromRes, fromView) <- mpreq utcTimeField ("" & addName (mkUnique "from")) Nothing (toRes, toView) <- mopt utcTimeField ("" & addName (mkUnique "to")) Nothing (forRes, forView) <- mopt (checkMap (first $ const MsgTermFormActiveUserNotFound) Right $ userField False Nothing) ("" & addName (mkUnique "for") & addPlaceholder (mr MsgTermActiveForPlaceholder)) Nothing - + let res = TermActiveForm <$> fromRes <*> toRes <*> forRes res' = res <&> \newDat oldDat -> if | newDat `elem` oldDat diff --git a/src/Handler/Tutorial/Edit.hs b/src/Handler/Tutorial/Edit.hs index 65d616e0a..7e8ed7d13 100644 --- a/src/Handler/Tutorial/Edit.hs +++ b/src/Handler/Tutorial/Edit.hs @@ -25,21 +25,20 @@ getTEditR, postTEditR :: TermId -> SchoolId -> CourseShorthand -> TutorialName - getTEditR = postTEditR postTEditR tid ssh csh tutn = do (cid, tutid, template) <- runDB $ do - (cid, Entity tutid Tutorial{..}) <- fetchCourseIdTutorial tid ssh csh tutn + (cid, Entity tutid Tutorial{..}) <- fetchCourseIdTutorial tid ssh csh tutn tutorIds <- fmap (map E.unValue) . E.select . E.from $ \tutor -> do E.where_ $ tutor E.^. TutorTutorial E.==. E.val tutid return $ tutor E.^. TutorUser tutorInvites <- sourceInvitationsF @Tutor tutid - let + let template = TutorialForm { tfName = tutorialName , tfType = tutorialType , tfCapacity = tutorialCapacity - , tfRoom = tutorialRoom , tfRoomHidden = tutorialRoomHidden - , tfTime = tutorialTime + , tfTime = tutorialTime & unJSONB , tfRegGroup = tutorialRegGroup , tfRegisterFrom = tutorialRegisterFrom , tfRegisterTo = tutorialRegisterTo @@ -62,9 +61,8 @@ postTEditR tid ssh csh tutn = do , tutorialCourse = cid , tutorialType = tfType , tutorialCapacity = tfCapacity - , tutorialRoom = tfRoom , tutorialRoomHidden = tfRoomHidden - , tutorialTime = tfTime + , tutorialTime = tfTime & JSONB , tutorialRegGroup = tfRegGroup , tutorialRegisterFrom = tfRegisterFrom , tutorialRegisterTo = tfRegisterTo @@ -88,6 +86,7 @@ postTEditR tid ssh csh tutn = do case insertRes of Just _ -> addMessageI Error $ MsgTutorialNameTaken tfName Nothing -> do + memcachedInvalidateClass MemcachedKeyClassTutorialOccurrences addMessageI Success $ MsgTutorialEdited tfName redirect $ CourseR tid ssh csh CTutorialListR diff --git a/src/Handler/Tutorial/Form.hs b/src/Handler/Tutorial/Form.hs index 8c4743ea2..f5fda2d55 100644 --- a/src/Handler/Tutorial/Form.hs +++ b/src/Handler/Tutorial/Form.hs @@ -25,7 +25,6 @@ data TutorialForm = TutorialForm , tfRegGroup :: Maybe (CI Text) , tfTutorControlled :: Bool , tfCapacity :: Maybe Int - , tfRoom :: Maybe RoomReference , tfRoomHidden :: Bool , tfTime :: Occurrences , tfRegisterFrom :: Maybe UTCTime @@ -75,7 +74,6 @@ tutorialForm cid template html = do <*> aopt (textField & cfStrip & cfCI) (fslI MsgTutorialRegGroup & setTooltip MsgTutorialRegGroupTip) ((tfRegGroup <$> template) <|> Just (Just "tutorial")) <*> apopt checkBoxField (fslI MsgTutorialTutorControlled & setTooltip MsgTutorialTutorControlledTip) (tfTutorControlled <$> template) <*> aopt (natFieldI MsgTutorialCapacityNonPositive) (fslpI MsgTutorialCapacity (mr MsgTutorialCapacity) & setTooltip MsgTutorialCapacityTip) (tfCapacity <$> template) - <*> roomReferenceFormOpt (fslI MsgTableTutorialRoom) (tfRoom <$> template) <*> apopt checkBoxField (fslI MsgTableTutorialRoomHidden & setTooltip MsgTutorialRoomHiddenTip) (tfRoomHidden <$> template <|> Just False) <*> occurrencesAForm ("occurrences" :: Text) (tfTime <$> template) <*> aopt utcTimeField (fslpI MsgRegisterFrom (mr MsgTutorialDate) diff --git a/src/Handler/Tutorial/List.hs b/src/Handler/Tutorial/List.hs index 3f0c6a48d..9f50c1182 100644 --- a/src/Handler/Tutorial/List.hs +++ b/src/Handler/Tutorial/List.hs @@ -29,18 +29,18 @@ getCTutorialListR tid ssh csh = do tutorialDBTable = DBTable{..} where resultTutorial :: Lens' (DBRow (Entity Tutorial, Int, Bool)) (Entity Tutorial) - resultTutorial = _dbrOutput . _1 + resultTutorial = _dbrOutput . _1 resultParticipants = _dbrOutput . _2 - resultShowRoom = _dbrOutput . _3 - + resultHideRoom = _dbrOutput . _3 + dbtSQLQuery tutorial = do E.where_ $ tutorial E.^. TutorialCourse E.==. E.val cid let participants :: E.SqlExpr (E.Value Int) participants = E.subSelectCount . E.from $ \tutorialParticipant -> E.where_ $ tutorialParticipant E.^. TutorialParticipantTutorial E.==. tutorial E.^. TutorialId - let showRoom = maybe E.false (flip showTutorialRoom tutorial . E.val) muid - E.||. E.not_ (tutorial E.^. TutorialRoomHidden) - return (tutorial, participants, showRoom) + let hideRoom = maybe E.true (E.not__ . flip showTutorialRoom tutorial . E.val) muid + E.&&. (tutorial E.^. TutorialRoomHidden) + return (tutorial, participants, hideRoom) dbtRowKey = (E.^. TutorialId) dbtProj = over (_dbrOutput . _2) E.unValue . over (_dbrOutput . _3) E.unValue <$> dbtProjId dbtColonnade = dbColonnade $ mconcat @@ -61,10 +61,10 @@ getCTutorialListR tid ssh csh = do |] , sortable (Just "participants") (i18nCell MsgTutorialParticipants) $ \(view $ $(multifocusL 2) (resultTutorial . _entityVal) resultParticipants -> (Tutorial{..}, n)) -> anchorCell (CTutorialR tid ssh csh tutorialName TUsersR) $ tshow n , sortable (Just "capacity") (i18nCell MsgTutorialCapacity) $ \(view $ resultTutorial . _entityVal -> Tutorial{..}) -> maybe mempty (textCell . tshow) tutorialCapacity - , sortable (Just "room") (i18nCell MsgTableTutorialRoom) $ \res -> if - | res ^. resultShowRoom -> maybe (i18nCell MsgTableTutorialRoomIsUnset) roomReferenceCell $ views (resultTutorial . _entityVal) tutorialRoom res - | otherwise -> i18nCell MsgTableTutorialRoomIsHidden & addCellClass ("explanation" :: Text) - , sortable Nothing (i18nCell MsgTableTutorialTime) $ \(view $ resultTutorial . _entityVal -> Tutorial{..}) -> occurrencesCell tutorialTime + , sortable Nothing (i18nCell MsgTableTutorialOccurrence) $ \res -> + let roomHidden = res ^. resultHideRoom + ttime = res ^. resultTutorial . _entityVal . _tutorialTime + in occurrencesCell roomHidden ttime , sortable (Just "register-group") (i18nCell MsgTutorialRegGroup) $ \(view $ resultTutorial . _entityVal -> Tutorial{..}) -> maybe mempty (textCell . CI.original) tutorialRegGroup , sortable (Just "register-from") (i18nCell MsgRegisterFrom) $ \(view $ resultTutorial . _entityVal -> Tutorial{..}) -> maybeDateTimeCell tutorialRegisterFrom , sortable (Just "register-to") (i18nCell MsgRegisterTo) $ \(view $ resultTutorial . _entityVal -> Tutorial{..}) -> maybeDateTimeCell tutorialRegisterTo @@ -89,7 +89,6 @@ getCTutorialListR tid ssh csh = do in participantCount ) , ("capacity", SortColumn $ \tutorial -> tutorial E.^. TutorialCapacity ) - , ("room", SortColumn $ \tutorial -> tutorial E.^. TutorialRoom ) , ("register-group", SortColumn $ \tutorial -> tutorial E.^. TutorialRegGroup ) , ("register-from" , SortColumnNullsInv $ \tutorial -> tutorial E.^. TutorialRegisterFrom ) , ("register-to" , SortColumnNullsInv $ \tutorial -> tutorial E.^. TutorialRegisterTo ) diff --git a/src/Handler/Tutorial/New.hs b/src/Handler/Tutorial/New.hs index 4fa98b0d6..34ccbdab4 100644 --- a/src/Handler/Tutorial/New.hs +++ b/src/Handler/Tutorial/New.hs @@ -25,7 +25,7 @@ postCTutorialNewR tid ssh csh = do ((newTutResult, newTutWidget), newTutEnctype) <- runFormPost $ tutorialForm cid Nothing formResult newTutResult $ \TutorialForm{..} -> do - insertRes <- runDBJobs $ do + insertRes <- runDBJobs $ do now <- liftIO getCurrentTime term <- get404 $ course ^. _courseTerm insertRes <- insertUnique Tutorial @@ -33,9 +33,8 @@ postCTutorialNewR tid ssh csh = do , tutorialCourse = cid , tutorialType = tfType , tutorialCapacity = tfCapacity - , tutorialRoom = tfRoom , tutorialRoomHidden = tfRoomHidden - , tutorialTime = tfTime + , tutorialTime = JSONB tfTime , tutorialRegGroup = tfRegGroup , tutorialRegisterFrom = tfRegisterFrom , tutorialRegisterTo = tfRegisterTo diff --git a/src/Handler/Tutorial/Register.hs b/src/Handler/Tutorial/Register.hs index 06471ead8..1db091e07 100644 --- a/src/Handler/Tutorial/Register.hs +++ b/src/Handler/Tutorial/Register.hs @@ -9,6 +9,7 @@ module Handler.Tutorial.Register import Import import Handler.Utils import Handler.Utils.Tutorial +import Handler.Utils.Company postTRegisterR :: TermId -> SchoolId -> CourseShorthand -> TutorialName -> Handler () @@ -21,8 +22,12 @@ postTRegisterR tid ssh csh tutn = do formResult btnResult $ \case BtnRegister -> do - runDB . void . insert $ TutorialParticipant tutid uid - addMessageI Success $ MsgTutorialRegisteredSuccess tutorialName + ok <- runDB $ do + fsh <- selectCompanyUserPrime' uid + insertUnique $ TutorialParticipant tutid uid fsh Nothing Nothing Nothing + if isJust ok + then addMessageI Success $ MsgTutorialRegisteredSuccess tutorialName + else addMessageI Error $ MsgTutorialRegisteredFail tutorialName -- cannot happen, but it is nonetheless better to be safe than crashing redirect $ CourseR tid ssh csh CShowR BtnDeregister -> do runDB . deleteBy $ UniqueTutorialParticipant tutid uid diff --git a/src/Handler/Tutorial/Users.hs b/src/Handler/Tutorial/Users.hs index 1f068722e..e1489e808 100644 --- a/src/Handler/Tutorial/Users.hs +++ b/src/Handler/Tutorial/Users.hs @@ -1,24 +1,30 @@ --- SPDX-FileCopyrightText: 2022-23 Gregor Kleen ,Sarah Vaupel ,Steffen Jost +-- SPDX-FileCopyrightText: 2022-2025 Gregor Kleen ,Sarah Vaupel ,Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later -{-# LANGUAGE TypeApplications #-} +{-# LANGUAGE TypeApplications, BlockArguments #-} + module Handler.Tutorial.Users ( getTUsersR, postTUsersR + , getTExamR, postTExamR ) where import Import +import Control.Monad.Zip (munzip) import Utils.Form import Utils.Print import Handler.Utils import Handler.Utils.Course +import Handler.Utils.Course.Cache import Handler.Utils.Tutorial +import Handler.Exam.Form (ExamOccurrenceForm(..), examOccurrenceMultiForm, upsertExamOccurrences, copyExamOccurrences) import Database.Persist.Sql (deleteWhereCount) import qualified Data.CaseInsensitive as CI +-- import qualified Data.Text as Text import qualified Data.Set as Set import qualified Data.Map as Map import qualified Data.ByteString.Lazy as LBS @@ -26,12 +32,14 @@ import qualified Data.ByteString.Lazy as LBS import Database.Esqueleto.Experimental ((:&)(..)) import qualified Database.Esqueleto.Experimental as E -- needs TypeApplications Lang-Pragma +import qualified Database.Esqueleto.Utils as E import Handler.Course.Users data TutorialUserAction - = TutorialUserPrintQualification + = TutorialUserAssignExam + | TutorialUserPrintQualification | TutorialUserRenewQualification | TutorialUserGrantQualification | TutorialUserSendMail @@ -52,44 +60,111 @@ data TutorialUserActionData , tuValidUntil :: Day } | TutorialUserSendMailData - | TutorialUserDeregisterData{} + | TutorialUserDeregisterData + | TutorialUserAssignExamData + { tuOccurrenceId :: ExamOccurrenceId + , tuExaminerAgain :: Bool + , tuReassign :: Bool + } deriving (Eq, Ord, Read, Show, Generic) +-- non-table form for general tutorial actions +data GenTutAction + = GenTutActShowExam + | GenTutActOccCopyWeek + | GenTutActOccCopyLast + | GenTutActOccEdit + deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic) + deriving anyclass (Universe, Finite) + +nullaryPathPiece ''GenTutAction $ camelToPathPiece' 1 +embedRenderMessage ''UniWorX ''GenTutAction id + +data GenTutActionData = GenTutActionData { gtaAct :: GenTutAction, gtaExam :: ExamId } + deriving (Eq, Ord, Show, Generic) + +-- mkGenTutForm :: [Filter Exam] -> Form GenTutActionData +-- mkGenTutForm fltr = renderAForm FormStandard maa +-- where +-- maa = multiActionA acts (fslI MsgCourseExam) Nothing + +-- acts :: Map GenTutAction (AForm Handler GenTutActionData) +-- acts = Map.fromList +-- [ (GenTutActOccCopy, GenTutActOccCopyData <$> areq (examFieldFilter (Just $ SomeMessage MsgMenuExamNew) fltr) (fslI MsgCourseExam) Nothing) +-- , (GenTutActOccEdit, GenTutActOccEditData <$> aopt (examFieldFilter (Just $ SomeMessage MsgMenuExamNew) fltr) (fslI MsgCourseExam) Nothing) +-- ] +mkGenTutForm :: [Filter Exam] -> Form GenTutActionData +mkGenTutForm fltr html = do + (actRes, actView) <- mreq (selectFieldList ((\a->(a,a)) <$> universeF)) (fslI MsgCourseExam) Nothing + (exmRes, exmView) <- mreq (examFieldFilter (Just $ SomeMessage MsgMenuExamNew) fltr) (fslI MsgCourseExam) Nothing + let res :: FormResult GenTutAction -> FormResult ExamId -> FormResult GenTutActionData + res (FormSuccess gta) (FormSuccess eid) = FormSuccess $ GenTutActionData{gtaAct=gta, gtaExam=eid} + res (FormFailure e1) (FormFailure e2) = FormFailure $ e1 <> e2 + res (FormFailure e) _ = FormFailure e + res _ (FormFailure e) = FormFailure e + res _ _ = FormMissing + viw = [whamlet| +

                + #{html}^{fvInput actView} _{MsgFor} ^{fvInput exmView} + |] + return (res actRes exmRes, viw) + getTUsersR, postTUsersR :: TermId -> SchoolId -> CourseShorthand -> TutorialName -> Handler TypedContent getTUsersR = postTUsersR postTUsersR tid ssh csh tutn = do + let heading = prependCourseTitle tid ssh csh $ CI.original tutn + croute = CTutorialR tid ssh csh tutn TUsersR + now <- liftIO getCurrentTime isAdmin <- hasReadAccessTo AdminR - (Entity tutid tut@Tutorial{..}, (participantRes, participantTable), qualifications) <- runDB $ do - cid <- getKeyBy404 $ TermSchoolCourseShort tid ssh csh - tutEnt@(Entity tutid _) <- fetchTutorial tid ssh csh tutn + (Entity tutid tut@Tutorial{..}, (participantRes, participantTable), qualifications, dbegin, hasExams, exmFltr, exOccs) <- runDB do + trm <- get404 tid + -- cid <- getKeyBy404 $ TermSchoolCourseShort tid ssh csh + -- tutEnt@(Entity tutid _) <- fetchTutorial tid ssh csh tutn + (cid, tutEnt@(Entity tutid _)) <- fetchCourseIdTutorial tid ssh csh tutn qualifications <- getCourseQualifications cid - now <- liftIO getCurrentTime let nowaday = utctDay now minDur :: Maybe Int = minimumMaybe $ mapMaybe (view _qualificationValidDuration) qualifications -- no instance Ord CalendarDiffDays - dayExpiry = flip addGregorianDurationClip nowaday . fromMonths <$> minDur - colChoices = mconcat $ catMaybes - [ pure $ dbSelect (applying _2) id (return . view (hasEntity . _entityKey)) - , pure $ colUserNameModalHdr MsgTableCourseMembers ForProfileDataR - , pure colUserEmail - , pure $ colUserMatriclenr isAdmin - , pure $ colUserQualifications nowaday - , pure $ colUserQualificationBlocked isAdmin nowaday + dayExpiry = flip computeNewValidDate nowaday <$> minDur + colChoices = mconcat $ + [ dbSelect (applying _2) id (return . view (hasEntity . _entityKey)) + , colUserNameModalHdr MsgTableCourseMembers ForProfileDataR + , colUserEmail + , colUserMatriclenr isAdmin + ] <> + [ colUserQualificationBlocked isAdmin nowaday q | q <- qualifications] <> + [ colUserExamOccurrencesCheck tid ssh csh + , colUserExams tid ssh csh ] psValidator = def & defaultSortingByName & restrictSorting (\name _ -> none (== name) ["note", "registration", "tutorials", "exams", "submission-group", "state"]) -- We need to be careful to restrict allowed sorting/filter to not expose sensitive information & restrictFilter (\name _ -> none (== name) ["tutorial", "exam", "submission-group", "active", "has-personalised-sheet-files"]) - isInTut q = E.exists $ do + isInTut q = E.exists do tutorialParticipant <- E.from $ E.table @TutorialParticipant E.where_ $ tutorialParticipant E.^. TutorialParticipantUser E.==. queryUser q E.^. UserId E.&&. tutorialParticipant E.^. TutorialParticipantTutorial E.==. E.val tutid csvColChoices = flip elem ["name", "matriculation", "email", "qualifications"] - qualOptions = qualificationsOptionList qualifications + lessons = occurringLessons trm $ tutEnt ^. _entityVal . _tutorialTime . _Wrapped' + timespan = lessonTimesSpan lessons + (dbegin, dend) = munzip timespan + tbegin = toMidnight . succ <$> dbegin + tend = toMidnight <$> dend + exmFltr = ([ExamEnd >=. tbegin] ||. [ExamEnd ==. Nothing]) ++ [ExamCourse ==. cid, ExamStart <=. tend] + -- $logInfoS "ExamOccurrenceForm" [st|Exams from #{tshow tbegin} until #{tshow tend}.|] + exOccs <- flip foldMapM timespan $ getDayExamOccurrences False ssh $ Just cid -- :: ExamOccurrenceMap + hasExams <- if null exOccs then exists exmFltr else pure True let acts :: Map TutorialUserAction (AForm Handler TutorialUserActionData) acts = Map.fromList $ + bcons (not $ null exOccs) + ( TutorialUserAssignExam + , TutorialUserAssignExamData + <$> apopt (selectField $ pure $ mkExamOccurrenceOptions exOccs) (fslI MsgCourseUserExamOccurrences) Nothing + <*> apopt checkBoxField (fslI MsgCourseUserExamOccurrenceAgainExaminer) (Just False) + <*> apopt checkBoxField (fslI MsgCourseUserExamOccurrenceOverride) (Just False) + ) $ (if null qualifications then mempty else [ ( TutorialUserRenewQualification , TutorialUserRenewQualificationData @@ -107,7 +182,7 @@ postTUsersR tid ssh csh tutn = do , ( TutorialUserPrintQualification, pure TutorialUserPrintQualificationData ) ] table <- makeCourseUserTable cid acts isInTut colChoices psValidator (Just csvColChoices) - return (tutEnt, table, qualifications) + return (tutEnt, table, qualifications, dbegin, hasExams, exmFltr, exOccs) let courseQids = Set.fromList (entityKey <$> qualifications) tcontent <- formResultMaybe participantRes $ \case @@ -117,9 +192,8 @@ postTUsersR tid ssh csh tutn = do letters <- runDB $ makeCourseCertificates tut Nothing $ toList selectedUsers let mbAletter = anyone letters case mbAletter of - Nothing -> addMessageI Error MsgErrorUnknownFormAction >> return Nothing -- TODO: better error message + Nothing -> addMessageI Error MsgErrorUnknownFormAction >> return Nothing -- cannot really happen Just aletter -> do - now <- liftIO getCurrentTime apcIdent <- letterApcIdent aletter encRcvr now let fName = letterFileName aletter renderLetters rcvr letters apcIdent >>= \case @@ -134,37 +208,161 @@ postTUsersR tid ssh csh tutn = do -- today <- localDay . TZ.utcToLocalTimeTZ appTZ <$> liftIO getCurrentTime today <- liftIO getCurrentTime let reason = "Kurs " <> CI.original (unSchoolKey ssh) <> "-" <> CI.original csh <> "-" <> CI.original tutn - runDB . forM_ selectedUsers $ upsertQualificationUser tuQualification today tuValidUntil Nothing reason + selUsrs = Set.toList selectedUsers + nterm <- runDB $ do + forM_ selUsrs $ upsertQualificationUser tuQualification today tuValidUntil Nothing reason + terminateLms (LmsOrphanReasonManualGrant [st|bis #{tshow tuValidUntil}, #{reason}|]) tuQualification selUsrs addMessageI (if 0 < Set.size selectedUsers then Success else Warning) . MsgTutorialUserGrantedQualification $ Set.size selectedUsers - redirect $ CTutorialR tid ssh csh tutn TUsersR + when (nterm > 0) $ addMessageI Warning $ MsgLmsActTerminated nterm + reloadKeepGetParams croute (TutorialUserRenewQualificationData{..}, selectedUsers) | tuQualification `Set.member` courseQids -> do - noks <- runDB $ renewValidQualificationUsers tuQualification Nothing Nothing $ Set.toList selectedUsers + let selUsrs = Set.toList selectedUsers + mr <- getMessageRender + (noks,nterm) <- runDB $ (,) + <$> renewValidQualificationUsers tuQualification Nothing Nothing selUsrs + <*> terminateLms (LmsOrphanReasonManualGrant $ mr heading) tuQualification selUsrs addMessageI (if noks > 0 && noks == Set.size selectedUsers then Success else Warning) $ MsgTutorialUserRenewedQualification noks - redirect $ CTutorialR tid ssh csh tutn TUsersR - (TutorialUserSendMailData{}, selectedUsers) -> do + when (nterm > 0) $ addMessageI Warning $ MsgLmsActTerminated nterm + reloadKeepGetParams croute + (TutorialUserSendMailData, selectedUsers) -> do cids <- traverse encrypt $ Set.toList selectedUsers :: Handler [CryptoUUIDUser] redirect (CTutorialR tid ssh csh tutn TCommR, [(toPathPiece GetRecipient, toPathPiece cID) | cID <- cids]) - (TutorialUserDeregisterData{},selectedUsers) -> do + (TutorialUserDeregisterData, selectedUsers) -> do nrDel <- runDB $ deleteWhereCount [ TutorialParticipantTutorial ==. tutid , TutorialParticipantUser <-. Set.toList selectedUsers ] addMessageI Success $ MsgTutorialUsersDeregistered nrDel - redirect $ CTutorialR tid ssh csh tutn TUsersR + reloadKeepGetParams croute + (TutorialUserAssignExamData{..}, setSelectedUsers) + | (Just (ExamOccurrence{..}, _, (eid,_))) <- Map.lookup tuOccurrenceId exOccs -> do + assignRes <- runDB $ do + (Set.toList &&& Set.size -> (selectedUsers, nr_usrs)) <- if -- remove duplicate examiners, if desired + | isJust examOccurrenceExaminer && not tuExaminerAgain -> do + conflictingUsers <- E.select $ do + reg :& occ <- E.from $ E.table @ExamRegistration + `E.innerJoin` E.table @ExamOccurrence + `E.on` (\(reg :& occ) -> occ E.^. ExamOccurrenceId E.=?. reg E.^. ExamRegistrationOccurrence) + E.where_ $ occ E.^. ExamOccurrenceExaminer E.==. E.val examOccurrenceExaminer + E.&&. occ E.^. ExamOccurrenceExam E.!=. E.val examOccurrenceExam + E.&&. (reg E.^. ExamRegistrationUser `E.in_` E.vals setSelectedUsers) + E.orderBy [E.asc $ reg E.^. ExamRegistrationUser] + E.distinct $ pure $ reg E.^. ExamRegistrationUser + return $ setSelectedUsers `Set.difference` Set.fromAscList (E.unValue <$> conflictingUsers) + | otherwise -> return setSelectedUsers + runExceptT $ do + whenIsJust examOccurrenceCapacity $ \(fromIntegral -> totalCap) -> do + usedCap <- lift $ count [ExamRegistrationOccurrence ==. Just tuOccurrenceId, ExamRegistrationUser /<-. selectedUsers] + let remCap = totalCap - usedCap + when (nr_usrs > remCap) $ throwE $ MsgExamRoomCapacityInsufficient remCap + let regTemplate uid = ExamRegistration eid uid (Just tuOccurrenceId) now + lift $ if tuReassign + then putMany [regTemplate uid | uid <- selectedUsers] >> pure nr_usrs + else forM selectedUsers (insertUnique . regTemplate) <&> (length . catMaybes) + case assignRes of + Left errm -> do + addMessageI Error errm + return Nothing + Right nrOk -> do + let total = Set.size setSelectedUsers + allok = bool Warning Success $ nrOk == total + addMessageI allok $ MsgTutorialUserExamAssignedFor nrOk total $ ciOriginal examOccurrenceName + reloadKeepGetParams croute _other -> addMessageI Error MsgErrorUnknownFormAction >> return Nothing case tcontent of - Just act -> act -- abort and return produced content - Nothing -> do - tutors <- runDB $ E.select $ do - (tutor :& user) <- E.from $ E.table @Tutor `E.innerJoin` E.table @User - `E.on` (\(tutor :& user) -> tutor E.^. TutorUser E.==. user E.^. UserId) - E.where_ $ tutor E.^. TutorTutorial E.==. E.val tutid - return user + Just act -> act -- execute action and return produced content (i.e. pdf) + Nothing -> do -- no table action content to return, continue normally + let mkExamCreateBtn = linkButton mempty (msg2widget MsgMenuExamNew) [BCIsButton, BCPrimary] $ SomeRoute $ CourseR tid ssh csh CExamNewR + ((gtaRes, gtaWgt), gtaEnctype) <- runFormPost . identifyForm ("FIDGeneralTutorialAction"::Text) $ mkGenTutForm exmFltr + let gtaAnchor = "general-tutorial-action-form" :: Text + gtaRoute = croute :#: gtaAnchor + gtaForm = wrapForm' BtnPerform gtaWgt FormSettings + { formMethod = POST + , formAction = Just . SomeRoute $ gtaRoute + , formEncoding = gtaEnctype + , formAttrs = [] + , formSubmit = FormSubmit + , formAnchor = Just gtaAnchor + } + copyAction eId step = case dbegin of + Nothing -> addMessageI Error MsgExamOccurrenceCopyNoStartDate + Just dto -> + let cfailure = addMessageI Error MsgExamOccurrenceCopyFail + csuccess n = addMessageI Success (MsgExamOccurrencesCopied n) >> reloadKeepGetParams croute + copyFrom dfrom = copyExamOccurrences eId dfrom dto <&> (toMaybe =<< (> 0)) + step_dto = addDays (negate step) dto + in maybeM cfailure csuccess $ + runDB $ firstJustM $ map copyFrom $ take 69 $ drop 1 [dto, step_dto..] -- search for up to 2 months / 1 year backwards + formResult gtaRes $ \GenTutActionData{..} -> case gtaAct of + GenTutActOccCopyWeek -> copyAction gtaExam 7 + GenTutActOccCopyLast -> copyAction gtaExam 1 + GenTutActOccEdit -> do + Exam{examName=ename} <- runDBRead $ get404 gtaExam + redirect $ CTutorialR tid ssh csh tutn $ TExamR ename + GenTutActShowExam -> do + Exam{examName=ename} <- runDBRead $ get404 gtaExam + redirect (CExamR tid ssh csh ename EUsersR, [("exam-users-tutorial", toPathPiece tutn)]) - let heading = prependCourseTitle tid ssh csh $ CI.original tutorialName - html <- siteLayoutMsg heading $ do + tutors <- runDBRead $ E.select do + (tutor :& user) <- E.from $ E.table @Tutor `E.innerJoin` E.table @User + `E.on` (\(tutor :& user) -> tutor E.^. TutorUser E.==. user E.^. UserId) + E.where_ $ tutor E.^. TutorTutorial E.==. E.val tutid + return user + -- $(i18nWidgetFile "exam-missing") + html <- siteLayoutMsg heading do setTitleI heading $(widgetFile "tutorial-participants") return $ toTypedContent html + + +getTExamR, postTExamR :: TermId -> SchoolId -> CourseShorthand -> TutorialName -> ExamName -> Handler Html +getTExamR = postTExamR +postTExamR tid ssh csh tutn exmName = do + let baseroute = CTutorialR tid ssh csh tutn + (Entity{entityKey=eId,entityVal=exm},exOccs) <- runDB do + trm <- get404 tid + (cid, tutEnt) <- fetchCourseIdTutorial tid ssh csh tutn + exm <- getBy404 $ UniqueExam cid exmName + let lessons = occurringLessons trm $ tutEnt ^. _entityVal . _tutorialTime . _Wrapped' + timespan = lessonTimesSpan lessons + -- (fmap (toMidnight . succ) -> tbegin, fmap toMidnight -> tend) = munzip timespan + -- exms <- selectList ([ExamCourse ==. cid, ExamStart <=. tend] ++ ([ExamEnd >=. tbegin] ||. [ExamEnd ==. Nothing])) [Asc ExamName] + exOccs <- flip foldMapM timespan $ getDayExamOccurrences False ssh $ Just cid + return (exm,exOccs) + cueId :: CryptoUUIDExam <- encrypt eId + let eid2eos = convertExamOccurrenceMap exOccs + (cuEoIds, eos) = munzip $ Map.lookup eId eid2eos + exOcForm = (,,) + <$> areq hiddenField "" (Just cueId) + <*> areq (mkSetField hiddenField) "" cuEoIds + <*> examOccurrenceMultiForm eos + ((eofRes, eofWgt), eofEnctype) <- runFormPost $ identifyForm FIDTutorialExamOccurrences $ renderAForm FormStandard exOcForm + let eofForm = wrapForm eofWgt def{formEncoding = eofEnctype} + formResult eofRes $ \(edCEId, edCEOIds, edOccs) -> do + let ceoidsDelete = edCEOIds `Set.difference` setMapMaybe eofId edOccs + $logInfoS "ExamOccurrenceEdit" [st|Exam-Edit: #{length edCEOIds} old occurrences, #{length ceoidsDelete} to delete, #{length $ Set.filter (isNothing . eofId) edOccs} to insert, #{length $ Set.filter (isJust . eofId) edOccs} to edit|] + reId <- decrypt edCEId + eoIdsDelete <- mapM decrypt $ Set.toList ceoidsDelete + when (reId == eId) $ do + (fromIntegral -> nrDel, nrUps) <- runDB $ (,) + <$> deleteWhereCount [ExamOccurrenceExam ==. reId, ExamOccurrenceId <-. eoIdsDelete] + <*> upsertExamOccurrences eId (Set.toList edOccs) + let nr = nrUps + nrDel + mstat = if nr > 0 then Success else Warning + addMessageI mstat $ MsgExamOccurrencesEdited nrUps nrDel + reload $ baseroute $ TExamR exmName + + let csh_tutn = csh <> "-" <> tutn -- hack to reuse prependCourseTitle + heading = prependCourseTitle tid ssh csh_tutn $ MsgMenuTutorialExam exmName + siteLayoutMsg heading do + -- setTitle $ citext2Html exmName + setTitleI heading + [whamlet| +

                +

                #{CI.original exmName} +

                #{examDescription exm} +

                + ^{eofForm} + |] diff --git a/src/Handler/Users.hs b/src/Handler/Users.hs index 95a4dbada..bb287930f 100644 --- a/src/Handler/Users.hs +++ b/src/Handler/Users.hs @@ -58,7 +58,7 @@ instance HasEntity (DBRow (Entity User)) User where instance HasUser (DBRow (Entity User)) where hasUser = _dbrOutput . _entityVal -data UserAction = UserAvsSync | UserLdapSync | UserAddSupervisor | UserSetSupervisor | UserRemoveSupervisor | UserRemoveSubordinates +data UserAction = UserAvsSync | UserLdapSync | UserAddSupervisor | UserSetSupervisor | UserRemoveSupervisor | UserRemoveClients deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic) deriving anyclass (Universe, Finite) @@ -71,7 +71,7 @@ data UserActionData = UserAvsSyncData | UserAddSupervisorData { getActionSupervisors :: Set Text, getActionRerouteNotifications :: Bool, getActionSupervisorReason :: Maybe Text } | UserSetSupervisorData { getActionSupervisors :: Set Text, getActionRerouteNotifications :: Bool, getActionSupervisorReason :: Maybe Text } | UserRemoveSupervisorData - | UserRemoveSubordinatesData + | UserRemoveClientsData deriving (Eq, Ord, Read, Show, Generic) @@ -107,7 +107,7 @@ postUsersR = do (nameWidget userDisplayName userSurname) , sortable (Just "matriculation") (i18nCell MsgTableMatrikelNr) $ \DBRow{ dbrOutput = entUsr } -> cellHasMatrikelnummerLinkedAdmin entUsr , sortable (Just "user-company") (i18nCell MsgTableCompanies) $ \DBRow{ dbrOutput = Entity uid _ } -> flip (set' cellContents) mempty $ liftHandler $ runDB $ -- why does sqlCell not work here? Mismatch "YesodDB UniWorX" and "RWST (Maybe (Env,FileEnv), UniWorX, [Lang]) Enctype Ints (HandlerFor UniWorX" - maybeMonoid <$> wgtCompanies uid + maybeMonoid <$> wgtCompanies False uid -- , sortable (Just "personal-number") (i18nCell MsgCompanyPersonalNumber) $ \DBRow{ dbrOutput = Entity uid User{..} } -> anchorCellM -- redundant -- (AdminUserR <$> encrypt uid) -- (toWgt userCompanyPersonalNumber) @@ -130,6 +130,7 @@ postUsersR = do -- , sortable (Just "auth-ldap") (i18nCell MsgAuthMode) $ \DBRow{ dbrOutput = Entity _ User{..} } -> i18nCell userAuthentication -- TODO: reintroduce via ExternalUser -- , sortable (Just "ldap-sync") (i18nCell MsgLdapSynced) $ \DBRow{ dbrOutput = Entity _ User{..} } -> maybe mempty dateTimeCell userLastLdapSynchronisation -- TODO: reintroduce via ExternalUser , colUserEmail + , colUserLetterEmailPin , flip foldMap universeF $ \function -> sortable (Just $ SortingKey $ CI.mk $ toPathPiece function) (i18nCell function) $ \DBRow{ dbrOutput = Entity uid _ } -> flip (set' cellContents) mempty $ do schools <- liftHandler . runDBRead . E.select . E.from $ \(school `E.InnerJoin` userFunction) -> do @@ -203,7 +204,7 @@ postUsersR = do <*> apopt boolField' (fslI MsgMailSupervisorReroute & setTooltip MsgMailSupervisorRerouteTooltip) (Just False) <*> aopt (textField & cfStrip & addDatalist superReasons) (fslI MsgUserSupervisorReason & setTooltip MsgUserSupervisorReasonTooltip) Nothing , singletonMap UserRemoveSupervisor $ pure UserRemoveSupervisorData - , singletonMap UserRemoveSubordinates $ pure UserRemoveSubordinatesData + , singletonMap UserRemoveClients $ pure UserRemoveClientsData ] over _1 postprocess <$> dbTable psValidator DBTable @@ -220,6 +221,7 @@ postUsersR = do ) | function <- universeF ] ++ [ sortUserEmail id + , sortUserLetterEmailPin id , ( "name" , SortColumn (E.^. UserSurname) ) @@ -344,8 +346,8 @@ postUsersR = do -- , prismAForm (singletonFilter "user-search") mPrev $ aopt textField (fslI MsgName) -- , prismAForm (singletonFilter "user-ident") mPrev $ aopt textField (fslI MsgAdminUserIdent) -- , prismAForm (singletonFilter "user-email") mPrev $ aopt textField (fslI MsgAdminUserEmail) - , prismAForm (singletonFilter "personal-number" ) mPrev $ aopt textField (fslI MsgCompanyPersonalNumber & setTooltip MsgTableFilterCommaPlusShort) - , prismAForm (singletonFilter "matriculation") mPrev $ aopt matriculationField (fslI MsgTableMatrikelNr & setTooltip MsgTableFilterCommaPlusShort) -- contains filter on UserMatrikelnummer + , prismAForm (singletonFilter "personal-number" ) mPrev $ aopt textField (fslI MsgCompanyPersonalNumberFraport & setTooltip MsgTableFilterCommaPlusShort) + , prismAForm (singletonFilter "matriculation") mPrev $ aopt matriculationField (fslI MsgTableMatrikelNr & setTooltip MsgTableFilterCommaPlusShort) -- contains filter on UserMatrikelnummer -- , prismAForm (singletonFilter "avs-number" ) mPrev $ aopt textField (fslI MsgAvsPersonNo & setTooltip MsgTableFilterCommaPlusShort) -- exact filter on table UserAvs , prismAForm (singletonFilter "company-department") mPrev $ aopt textField (fslI MsgCompanyDepartment) , prismAForm (singletonFilter "user-company") mPrev $ aopt textField (fslI MsgTableCompany) @@ -395,9 +397,9 @@ postUsersR = do runDB $ deleteWhere [UserSupervisorUser <-. Set.toList userSet] addMessageI Success $ MsgUsersRemoveSupervisors $ Set.size userSet redirectKeepGetParams UsersR - (UserRemoveSubordinatesData, userSet) -> do + (UserRemoveClientsData, userSet) -> do runDB $ deleteWhere [UserSupervisorSupervisor <-. Set.toList userSet] - addMessageI Success $ MsgUsersRemoveSubordinates $ Set.size userSet + addMessageI Success $ MsgUsersRemoveClients $ Set.size userSet redirectKeepGetParams UsersR (act, usersSet) | isActionSupervisor act -> do diff --git a/src/Handler/Utils.hs b/src/Handler/Utils.hs index 4abcd0ce2..c11651123 100644 --- a/src/Handler/Utils.hs +++ b/src/Handler/Utils.hs @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2023 Gregor Kleen ,Steffen Jost ,Winnie Ros +-- SPDX-FileCopyrightText: 2023-2025 Gregor Kleen ,Steffen Jost ,Winnie Ros ,Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later @@ -26,7 +26,7 @@ import Handler.Utils.I18n as Handler.Utils import Handler.Utils.Widgets as Handler.Utils import Handler.Utils.Database as Handler.Utils import Handler.Utils.Occurrences as Handler.Utils -import Handler.Utils.Memcached as Handler.Utils hiding (manageMemcachedLocalInvalidations) +import Handler.Utils.Memcached as Handler.Utils import Handler.Utils.Files as Handler.Utils import Handler.Utils.Download as Handler.Utils import Handler.Utils.AuthorshipStatement as Handler.Utils @@ -44,13 +44,12 @@ import Control.Monad.Logger checkAdmin :: (MonadHandler m, MonadAP (HandlerFor (HandlerSite m) )) => m Bool checkAdmin = liftHandler $ hasReadAccessTo AdminR - -- | Prefix a message with a short course id, -- eg. for window title bars, etc. -- This function should help to make this consistent everywhere -prependCourseTitle :: (RenderMessage UniWorX msg) => - TermId -> SchoolId -> CourseShorthand -> msg -> UniWorXMessages -prependCourseTitle tid ssh csh msg = UniWorXMessages +prependCourseTitle :: (RenderMessage master msg) => + TermId -> SchoolId -> CourseShorthand -> msg -> SomeMessages master +prependCourseTitle tid ssh csh msg = JoinMsgs [ SomeMessage $ toPathPiece tid , SomeMessage dashText , SomeMessage $ toPathPiece ssh @@ -64,7 +63,7 @@ prependCourseTitle tid ssh csh msg = UniWorXMessages dashText = "-" colonText :: Text - colonText = ":" + colonText = ": " warnTermDays :: (RenderMessage UniWorX msg) => TermId -> Map UTCTime msg -> DB () warnTermDays tid timeNames = do @@ -196,14 +195,14 @@ adminProblem2Text adprob = do AdminProblemNewCompany{} -> return $ mr MsgAdminProblemNewCompany AdminProblemSupervisorNewCompany{adminProblemSupervisorReroute, adminProblemCompanyNew} - -> return $ mr $ SomeMessages [SomeMessage $ MsgAdminProblemSupervisorNewCompany adminProblemSupervisorReroute, company2msg adminProblemCompanyNew] + -> return $ mr $ SomeMsgs [SomeMessage $ MsgAdminProblemSupervisorNewCompany adminProblemSupervisorReroute, company2msg adminProblemCompanyNew] AdminProblemSupervisorLeftCompany{adminProblemSupervisorReroute} -> return $ mr (MsgAdminProblemSupervisorLeftCompany adminProblemSupervisorReroute) AdminProblemCompanySuperiorChange{adminProblemUserOld=mbuid} -> maybeT (return $ mr MsgAdminProblemCompanySuperiorChange) $ do uid <- MaybeT $ pure mbuid User{userDisplayName = udn, userSurname = usn} <- MaybeT $ get uid - pure $ mr $ SomeMessages [SomeMessage MsgAdminProblemCompanySuperiorChange, SomeMessage MsgAdminProblemCompanySuperiorPrevious, SomeMessage udn, SomeMessage usn] + pure $ mr $ SomeMsgs [SomeMessage MsgAdminProblemCompanySuperiorChange, SomeMessage MsgAdminProblemCompanySuperiorPrevious, SomeMessage udn, SomeMessage usn] -- AdminProblemCompanySuperiorChange{adminProblemUserOld=Nothing} -- -> return $ mr MsgAdminProblemCompanySuperiorChange -- AdminProblemCompanySuperiorChange{adminProblemUserOld=Just uid} @@ -211,32 +210,32 @@ adminProblem2Text adprob = do -- Nothing -> -- return $ mr MsgAdminProblemCompanySuperiorChange -- Just User{userDisplayName = udn, userSurname = usn} -> - -- return $ mr $ SomeMessages [SomeMessage MsgAdminProblemCompanySuperiorChange, SomeMessage MsgAdminProblemCompanySuperiorPrevious, SomeMessage udn, SomeMessage usn] + -- return $ mr $ SomeMsgs [SomeMessage MsgAdminProblemCompanySuperiorChange, SomeMessage MsgAdminProblemCompanySuperiorPrevious, SomeMessage udn, SomeMessage usn] AdminProblemCompanySuperiorNotFound{adminProblemUserOld=mbuid, adminProblemEmail=eml} -> let basemsg = MsgAdminProblemCompanySuperiorNotFound $ fromMaybe "???" eml in maybeT (return $ mr basemsg) $ do uid <- MaybeT $ pure mbuid User{userDisplayName = udn, userSurname = usn} <- MaybeT $ get uid - pure $ mr $ SomeMessages [SomeMessage basemsg, SomeMessage MsgAdminProblemCompanySuperiorPrevious, SomeMessage udn, SomeMessage usn] + pure $ mr $ SomeMsgs [SomeMessage basemsg, SomeMessage MsgAdminProblemCompanySuperiorPrevious, SomeMessage udn, SomeMessage usn] AdminProblemNewlyUnsupervised{adminProblemCompanyNew} - -> return $ mr $ SomeMessages [SomeMessage MsgAdminProblemNewlyUnsupervised, company2msg adminProblemCompanyNew] + -> return $ mr $ SomeMsgs [SomeMessage MsgAdminProblemNewlyUnsupervised, company2msg adminProblemCompanyNew] AdminProblemUnknown{adminProblemText} -> return $ "Problem: " <> adminProblemText -- | Show AdminProblem as message, used in message pop-up after manually switching companies for a user msgAdminProblem :: AdminProblem -> DB (SomeMessages UniWorX) msgAdminProblem AdminProblemNewCompany{adminProblemCompany=comp} = return $ - SomeMessages [SomeMessage MsgAdminProblemNewCompany, text2message ": ", company2msg comp] + SomeMsgs [SomeMessage MsgAdminProblemNewCompany, text2message ": ", company2msg comp] msgAdminProblem AdminProblemSupervisorNewCompany{adminProblemCompany=comp, adminProblemCompanyNew=newComp, adminProblemSupervisorReroute=rer} = return $ - SomeMessages [SomeMessage $ MsgAdminProblemSupervisorNewCompany rer, text2message ": ", company2msg comp, text2message " -> ", company2msg newComp] + SomeMsgs [SomeMessage $ MsgAdminProblemSupervisorNewCompany rer, text2message ": ", company2msg comp, text2message " -> ", company2msg newComp] msgAdminProblem AdminProblemSupervisorLeftCompany{adminProblemCompany=comp, adminProblemSupervisorReroute=rer} = return $ - SomeMessages [SomeMessage $ MsgAdminProblemSupervisorLeftCompany rer, text2message ": ", company2msg comp] + SomeMsgs [SomeMessage $ MsgAdminProblemSupervisorLeftCompany rer, text2message ": ", company2msg comp] msgAdminProblem AdminProblemCompanySuperiorChange{adminProblemCompany=comp} = return $ - SomeMessages [SomeMessage MsgAdminProblemCompanySuperiorChange, text2message ": ", company2msg comp] + SomeMsgs [SomeMessage MsgAdminProblemCompanySuperiorChange, text2message ": ", company2msg comp] msgAdminProblem AdminProblemCompanySuperiorNotFound{adminProblemCompany=comp, adminProblemEmail=eml} = return $ - SomeMessages [SomeMessage $ MsgAdminProblemCompanySuperiorNotFound $ fromMaybe "???" eml, text2message ": ", company2msg comp] + SomeMsgs [SomeMessage $ MsgAdminProblemCompanySuperiorNotFound $ fromMaybe "???" eml, text2message ": ", company2msg comp] msgAdminProblem AdminProblemNewlyUnsupervised{adminProblemCompanyOld=comp, adminProblemCompanyNew=newComp} = return $ - SomeMessages [SomeMessage MsgAdminProblemNewlyUnsupervised, text2message ": ", maybe (text2message "???") company2msg comp, text2message " -> ", company2msg newComp] + SomeMsgs [SomeMessage MsgAdminProblemNewlyUnsupervised, text2message ": ", maybe (text2message "???") company2msg comp, text2message " -> ", company2msg newComp] msgAdminProblem AdminProblemUnknown{adminProblemText=err} = return $ someMessages ["Problem: ", err] diff --git a/src/Handler/Utils/AuthorshipStatement.hs b/src/Handler/Utils/AuthorshipStatement.hs index 2832bdd86..ddc455e1a 100644 --- a/src/Handler/Utils/AuthorshipStatement.hs +++ b/src/Handler/Utils/AuthorshipStatement.hs @@ -18,7 +18,7 @@ import qualified Data.Map.Strict as Map import Handler.Utils.Form (i18nLangMap, I18nLang(..)) import qualified Database.Esqueleto.Legacy as E -import qualified Database.Esqueleto.Utils as E +-- import qualified Database.Esqueleto.Utils as E import qualified Data.ByteString.Base64.URL as Base64 import qualified Data.ByteArray as BA @@ -81,7 +81,7 @@ getSheetAuthorshipStatement :: MonadIO m => Entity Sheet -> SqlReadT m (Maybe (Entity AuthorshipStatementDefinition)) getSheetAuthorshipStatement (Entity _ Sheet{..}) = withCompatibleBackend @SqlBackend $ traverse getJustEntity <=< runMaybeT $ do - Entity _ School{..} <- MaybeT . E.selectMaybe . E.from $ \(school `E.InnerJoin` course) -> do + Entity _ School{..} <- MaybeT . E.selectOne . E.from $ \(school `E.InnerJoin` course) -> do E.on $ school E.^. SchoolId E.==. course E.^. CourseSchool E.where_ $ course E.^. CourseId E.==. E.val sheetCourse return school diff --git a/src/Handler/Utils/Avs.hs b/src/Handler/Utils/Avs.hs index 5d00cf5fa..2341405a6 100644 --- a/src/Handler/Utils/Avs.hs +++ b/src/Handler/Utils/Avs.hs @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2022-2024 Sarah Vaupel , Steffen Jost +-- SPDX-FileCopyrightText: 2022-2025 Sarah Vaupel , Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later @@ -13,6 +13,7 @@ module Handler.Utils.Avs , upsertAvsUserByCard , upsertAvsUserById , updateAvsUserByIds + , updateAvsUserByADC , linktoAvsUserByUIDs , queueAvsUpdateByUID, queueAvsUpdateByAID -- , getLicence, getLicenceDB, getLicenceByAvsId -- not supported by interface @@ -29,6 +30,7 @@ module Handler.Utils.Avs -- CR3 , SomeAvsQuery(..) , queryAvsCardNo, queryAvsCardNos + , catchAVShandler ) where import Import @@ -160,10 +162,12 @@ lookupAvsUsers :: ( MonadThrow m, MonadHandler m, HandlerSite m ~ UniWorX ) => Set AvsPersonId -> m (Map AvsPersonId AvsDataPerson) lookupAvsUsers apis = do AvsResponseStatus statuses <- avsQuery $ AvsQueryStatus apis + -- $logInfoS "AVS-2" [st|Statuses for #{tshow apis} lieferte #{tshow statuses} |] -- DEBUG let forFoldlM = $(permuteFun [3,2,1]) foldlM forFoldlM statuses mempty $ \acc1 AvsStatusPerson{avsStatusPersonCardStatus=cards} -> forFoldlM cards acc1 $ \acc2 AvsDataPersonCard{avsDataCardNo, avsDataVersionNo} -> do AvsResponsePerson adps <- avsQuery $ def{avsPersonQueryCardNo = Just avsDataCardNo, avsPersonQueryVersionNo = Just avsDataVersionNo} + -- $logInfoS "AVS-3" [st|PersonSearch for card #{tshow $ avsCardNo avsDataCardNo}.#{avsDataVersionNo} lieferte #{tshow adps} |] -- DEBUG return $ mergeByPersonId adps acc2 @@ -172,7 +176,7 @@ updateReceivers :: UserId -> Handler (Entity User, [Entity User], Bool) updateReceivers uid = do -- First perform AVS update for receiver runDBRead (getBy (UniqueUserAvsUser uid)) >>= \case - Just Entity{entityVal=UserAvs{userAvsPersonId = apid}} -> catchAll2log $ upsertAvsUserById apid + Just Entity{entityVal=UserAvs{userAvsPersonId = apid}} -> catchAll2log $ runDB $ updateAvsUserById apid Nothing -> return () -- Retrieve updated user and supervisors now (underling :: Entity User, avsSupers :: [(E.Value UserId, E.Value (Maybe AvsPersonId))]) <- runDBRead $ (,) @@ -190,7 +194,7 @@ updateReceivers uid = do receiverIDs :: [UserId] = E.unValue <$> superVs toUpdate = Set.fromList $ mapMaybe E.unValue avsIds directResult = return (underling, pure underling, True) -- already contains updated address - forM_ toUpdate (catchAll2log . upsertAvsUserById) -- attempt to update postaddress from AVS + runDB $ forM_ toUpdate (catchAll2log . updateAvsUserById) -- attempt to update postaddress from AVS if null receiverIDs then directResult else do @@ -221,7 +225,7 @@ avsQueryNoCacheDefault qry = do qfun <- maybeThrowM AvsInterfaceUnavailable $ getsYesod $ preview (_appAvsQuery . _Just . to pickQuery) throwLeftM $ qfun qry -avsQueryCached :: (SomeAvsQuery q, Binary q, Binary (SomeAvsResponse q), Typeable (SomeAvsResponse q), NFData (SomeAvsResponse q) +avsQueryCached :: (SomeAvsQuery q, Binary q, Binary (SomeAvsResponse q), Typeable (SomeAvsResponse q) , MonadHandler m, HandlerSite m ~ UniWorX, MonadThrow m) => q -> m (SomeAvsResponse q) avsQueryCached qry = getsYesod (preview $ _appAvsConf . _Just . _avsCacheExpiry) >>= \case @@ -281,8 +285,6 @@ queryAvsFullCardNo :: (MonadHandler m, HandlerSite m ~ UniWorX, MonadCatch m) = queryAvsFullCardNo = fmap (fmap getFullCardNo) . queryAvsPrimaryCard - - -- | Like `updateAvsUserByIds`, but exceptions are not caught here to allow rollbacks updateAvsUserById :: AvsPersonId -> DB (Maybe UserId) updateAvsUserById apid = do @@ -370,9 +372,9 @@ updateAvsUserByADC newAvsDataContact@(AvsDataContact apid newAvsPersonInfo newAv eml_up = mkUpdate usr newAvsDataContact oldAvsDataContact $ mkCheckUpdate CU_ADC_UserDisplayEmail -- DisplayEmail updates erfolgen nur, wenn identisch. Für Firmen-Email leer lassen. -- eml_up2 = mkUpdate usr newAvsFirmInfo oldAvsFirmInfo $ mkCheckUpdate CU_AFI_UserEmail -- Email update erfolgt nur, wenn hier die SuperiorEmail als Fallback gespeichert wurde; UserEmail Uniqueness nicht gewährleistet frm_up = mkUpdate' usr newAvsFirmInfo oldAvsFirmInfo $ mkCheckUpdate CU_AFI_UserPostAddress -- Legacy, if company postal is stored in user; should no longer be true for new users, since company address should now be referenced with UserCompany instead - pin_up = mkUpdate' usr newAvsCardNo oldAvsCardNo $ -- Maybe update PDF pin to latest card + pin_up0 = mkUpdate usr newAvsCardNo oldAvsCardNo $ -- Maybe update PDF pin to latest card CheckUpdate UserPinPassword $ to $ fmap avsFullCardNo2pin -- _Just . to avsFullCardNo2pin . re _Just - usr_up1 = mconss [eml_up, frm_up, pin_up] $ ldap_ups <> per_ups + usr_up1 = mconss [eml_up, frm_up] $ ldap_ups <> per_ups avs_ups = ((UserAvsNoPerson =.) <$> readMay (avsInfoPersonNo newAvsPersonInfo)) `mcons` [ UserAvsLastSynch =. now , UserAvsLastSynchError =. Nothing @@ -400,37 +402,49 @@ updateAvsUserByADC newAvsDataContact@(AvsDataContact apid newAvsPersonInfo newAv -- -> Nothing superReasonComDef = tshow SupervisorReasonCompanyDefault newUserComp = UserCompany usrId newCompanyId False False 1 True Nothing -- default value for new company insertion, if no update can be done + -- pin_up :: Maybe (Update User) + -- pin_up = guardOnM (newCompanyEnt ^. _entityVal . _companyPinPassword) pin_up0 + -- base_up :: [Update User] + -- base_up = maybeToList pin_up -- catMaybes [pin_up] + -- -- Use above if we gain more base updates -- + base_up :: [Update User] + base_up = guardMonoid (newCompanyEnt ^. _entityVal . _companyPinPassword) (maybeToList pin_up0) case oldAvsFirmInfo of _ | Just newCompanyId == oldCompanyId -- company unchanged entirely - -> return mempty -- => do nothing + -> do -- => do nothing + $logInfoS "Supervision" [st|updateAvsUserByADC for #{tshow usrId} to new company #{unCompanyKey newCompanyId} from old company #{oldCompanyId} having primary company #{primaryCompanyId}. Company id unchanged.|] + return base_up (Just oafi) | isJust (view _avsFirmPostAddressSimple oafi) && ((==) `on` view _avsFirmPostAddressSimple) oafi newAvsFirmInfo -- non-empty company address unchanged OR || isJust (view _avsFirmPrimaryEmail oafi) && ((==) `on` view _avsFirmPrimaryEmail) oafi newAvsFirmInfo -- non-empty company primary email unchanged -> do -- => just update user company association, keeping supervision privileges + $logInfoS "Supervision" [st|updateAvsUserByADC for #{tshow usrId} to new company #{unCompanyKey newCompanyId} from old company #{oldCompanyId} having primary company #{primaryCompanyId}. Company address unchanged, just updating.|] case oldCompanyId of Nothing -> void $ insertUnique newUserComp -- it's ok if this already exists Just ocid -> do void $ upsertBySafe (UniqueUserCompany usrId ocid) newUserComp (_userCompanyCompany .~ newCompanyId) -- keep default supervisor settings void $ updateWhere [ UserSupervisorSupervisor ==. usrId -- update company-related supervisions - , UserSupervisorCompany ==. Just ocid -- to new company, regardless of - , UserSupervisorReason ==. Just superReasonComDef] -- user - [ UserSupervisorCompany =. Just newCompanyId] - return mempty + , UserSupervisorCompany ==. Just ocid -- to new company, regardless of + , UserSupervisorReason ==. Just superReasonComDef] -- user + [ UserSupervisorCompany =. Just newCompanyId] + return base_up _ | Just newCompanyId == primaryCompanyId -- old primaryCompany is now also AVS-company -> do + $logInfoS "Supervision" [st|updateAvsUserByADC for #{tshow usrId} to new company #{unCompanyKey newCompanyId} from old company #{oldCompanyId} having primary company #{primaryCompanyId}. Primary company unchanged.|] whenIsJust oldCompanyId $ \oldCid -> do deleteBy $ UniqueUserCompany usrId oldCid deleteWhere $ (UserSupervisorUser ==. usrId):(UserSupervisorCompany ==. oldCompanyId):(UserSupervisorReason ~=. superReasonComDef) - return mempty + return base_up _ -- company changed completely -> do (pst_up, problems) <- switchAvsUserCompany False False usrId newCompanyId + $logInfoS "Supervision" [st|updateAvsUserByADC for #{tshow usrId} to new company #{unCompanyKey newCompanyId} from old company #{oldCompanyId} having primary company #{primaryCompanyId}. Company switched. #{length pst_up} updates. #{length problems} problems.|] mapM_ reportAdminProblem problems -- Following line does not type, hence additional parameter needed -- return [ u | u@Update{updateField=f} <- pst_up, f /= UserPostAddress ] -- already computed in frm_up above, duplicate update must be prevented (version above accounts for legacy updates) - return pst_up + return $ base_up <> pst_up -- SPECIALISED CODE, PROBABLY DEPRECATED -- switch user company, keeping old priority -- (getBy . UniqueUserCompany usrId) `traverseJoin` oldCompanyId >>= \case @@ -440,7 +454,7 @@ updateAvsUserByADC newAvsDataContact@(AvsDataContact apid newAvsPersonInfo newAv -- when userCompanySupervisor $ reportAdminProblem $ AdminProblemSupervisorNewCompany usrId userCompanyCompany newCompanyId userCompanySupervisorReroute -- delete ucidOld -- void $ insertUnique newUserComp{userCompanyPriority} -- keep priority, if insert succeeds - -- -- adjust supervison + -- -- adjust supervision -- let oldCompDefSuperFltr = mconcat [UserSupervisorCompany ~~. oldCompanyId, UserSupervisorReason ~=. superReasonComDef] -- deleteWhere $ (UserSupervisorSupervisor ==. usrId) : oldCompDefSuperFltr -- oldAPs <- deleteWhereCount $ (UserSupervisorUser ==. usrId) : oldCompDefSuperFltr @@ -532,7 +546,7 @@ createAvsUserById muid api = do | otherwise -> return uid (Nothing, Nothing) -> do -- create fresh user Entity{entityKey=cid, entityVal=cmp} <- runDB $ upsertAvsCompany firmInfo Nothing -- individual runDB, since no need to rollback - let pinPass = avsFullCardNo2pin <$> usrCardNo + let pinPass = guardMonoid (cmp ^. _companyPinPassword) (avsFullCardNo2pin <$> usrCardNo) -- superiorEmail = filterMaybe validEmail $ adc ^. _avsContactFirmInfo . _avsFirmEMailSuperior newUserData = AddUserData { audTitle = Nothing @@ -605,6 +619,7 @@ upsertAvsCompany newAvsFirmInfo mbOldAvsFirmInfo = do , companyPrefersPostal = True , companyPostAddress = newAvsFirmInfo ^. _avsFirmPostAddress , companyEmail = newAvsFirmInfo ^? _avsFirmPrimaryEmail . _Just . from _CI + , companyPinPassword = True } cmp = foldl' upd dmy $ firmInfo2key : firmInfo2companyNo : firmInfo2company $logInfoS "AVS" $ "Insert new company: " <> tshow cmp @@ -646,6 +661,7 @@ upsertAvsCompany newAvsFirmInfo mbOldAvsFirmInfo = do , CheckUpdate CompanyPostAddress _avsFirmPostAddress , CheckUpdate CompanyEmail $ _avsFirmPrimaryEmail . _Just . from _CI . re _Just -- , CheckUpdate CompanyPrefersPostal _avsFirmPrefersPostal -- Guessing here is not useful, since postal preference is ignored anyway when there is only one option available + -- , CheckUpdate CompanyPinPassword -- same as for FirmPrefersPostal ] @@ -702,11 +718,11 @@ upsertCompanySuperior Entity{entityKey=cid, entityVal=Company{}} newAfi oldAfi u E.<&> E.justVal cid E.<&> E.val reasonSuperior ) - (\_old _new -> [] -- do not change exisitng supervision - -- [ UserSupervisorCompany E.=. new E.^. UserSupervisorCompany - -- , UserSupervisorReason E.=. new E.^. UserSupervisorReason - -- , UserSupervisorRerouteNotifications E.=. new E.^. UserSupervisorRerouteNotifications - -- ] + (\_old new -> + [ UserSupervisorCompany E.=. new E.^. UserSupervisorCompany + , UserSupervisorReason E.=. new E.^. UserSupervisorReason + , UserSupervisorRerouteNotifications E.=. new E.^. UserSupervisorRerouteNotifications + ] ) when (unchangedCompany && changedSuperior) $ do oldSupId <- getOldId @@ -714,8 +730,9 @@ upsertCompanySuperior Entity{entityKey=cid, entityVal=Company{}} newAfi oldAfi u (Nothing, Nothing) -> when (unchangedCompany && changedSuperior) $ do oldSupId <- getOldId - reportAdminProblem $ AdminProblemCompanySuperiorNotFound mbSupEmail cid oldSupId + reportAdminProblem $ AdminProblemCompanySuperiorNotFound usrId mbSupEmail cid oldSupId +-- | queue AVS synch for several UserIds, if a day is given, the last synch must be before the date to trigger an update queueAvsUpdateByUID :: (MonoFoldable mono, UserId ~ Element mono) => mono -> Maybe Day -> DB Int64 queueAvsUpdateByUID uids = queueAvsUpdateAux (E.table @User) (E.^. UserId) (\usr -> usr E.^. UserId `E.in_` E.vals uids) @@ -949,7 +966,9 @@ getDifferingLicences (AvsResponseGetLicences licences) = do vorORrollfeld = Set.map avsLicencePersonID vorORrollfeld' rollfeld = Set.map avsLicencePersonID rollfeld' - antijoinAvsLicences :: AvsLicence -> Set AvsPersonId -> DBRead (Set AvsPersonId,Set AvsPersonId) + antijoinAvsLicences :: AvsLicence -> Set AvsPersonId -> DBReadUq (Set AvsPersonId,Set AvsPersonId) + -- antijoinAvsLicences :: forall backend . (BackendCompatible SqlBackend backend, PersistQueryRead backend, PersistUniqueRead backend) + -- => AvsLicence -> Set AvsPersonId -> ReaderT backend (HandlerFor UniWorX) (Set AvsPersonId,Set AvsPersonId) antijoinAvsLicences lic avsLics = fmap unwrapIds $ E.select $ do ((_qauli :& _qualUser :& usrAvs) :& excl) <- diff --git a/src/Handler/Utils/AvsUpdate.hs b/src/Handler/Utils/AvsUpdate.hs index cf0ff1abe..76b7f4a91 100644 --- a/src/Handler/Utils/AvsUpdate.hs +++ b/src/Handler/Utils/AvsUpdate.hs @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2024 Steffen Jost +-- SPDX-FileCopyrightText: 2024-2025 Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later {-# OPTIONS_GHC -fno-warn-unused-top-binds -fno-warn-orphans #-} @@ -74,14 +74,14 @@ instance MkCheckUpdate CU_AvsPersonInfo_User where mkCheckUpdate CU_API_UserLdapPrimaryKey = CheckUpdateMay UserLdapPrimaryKey $ _avsInfoInternalPersonalNo . _Just . _avsInternalPersonalNo . re _Just -- mkCheckUpdate CU_API_UserDisplayEmail = CheckUpdateOpt UserDisplayEmail $ _avsInfoPersonEMail . _Just . from _CI -- Maybe im AvsInfo, aber nicht im User, daher Opt -data CU_AvsDataContcat_User +data CU_AvsDataContact_User = CU_ADC_UserPostAddress | CU_ADC_UserDisplayEmail deriving (Show, Eq) -instance MkCheckUpdate CU_AvsDataContcat_User where - type MCU_Rec CU_AvsDataContcat_User = User - type MCU_Raw CU_AvsDataContcat_User = AvsDataContact +instance MkCheckUpdate CU_AvsDataContact_User where + type MCU_Rec CU_AvsDataContact_User = User + type MCU_Raw CU_AvsDataContact_User = AvsDataContact mkCheckUpdate CU_ADC_UserPostAddress = CheckUpdateMay UserPostAddress _avsContactPrimaryPostAddress mkCheckUpdate CU_ADC_UserDisplayEmail = CheckUpdateOpt UserDisplayEmail $ _avsContactPrimaryEmail . _Just . from _CI @@ -100,7 +100,7 @@ instance MkCheckUpdate CU_AvsFirmInfo_User where -- NOTE: Ensure that the lenses between CU_UserAvs_User and CU_AvsPersonInfo_User/CU_AvsFirmInfo_User agree! -data CU_UserAvs_User +data CU_UserAvs_User -- only used in templates/profileData.hamlet for detection = CU_UA_UserPinPassword -- CU_UA_UserPostAddress -- use _avsContactPrimaryPostAddress instead | CU_UA_UserFirstName diff --git a/src/Handler/Utils/Communication.hs b/src/Handler/Utils/Communication.hs index 4990c21f2..5d2365721 100644 --- a/src/Handler/Utils/Communication.hs +++ b/src/Handler/Utils/Communication.hs @@ -27,6 +27,7 @@ import qualified Data.Set as Set import qualified Data.Conduit.Combinators as C +{-# ANN module ("HLint: ignore Functor law" :: String) #-} data RecipientGroup = RGCourseParticipants | RGCourseLecturers | RGCourseCorrectors | RGCourseTutors | RGCourseParticipantsInTutorial | RGCourseUnacceptedApplicants -- WARNING: no RenderMessage instance, but a pattern match in templates/widgets/communication/recipientLayout.hamlet that needs to be extended @@ -185,7 +186,7 @@ commR CommunicationRoute{..} = do recipientAForm = postProcess <$> massInputA MassInput{..} (fslI MsgCommRecipients & setTooltip MsgCommRecipientsTip) True (Just chosenRecipients') where miAdd pos@(BoundedPosition RecipientCustom, 0) dim@1 liveliness nudge submitView = guardOn (miAllowAdd pos dim liveliness) $ \csrf -> do - (addRes, addView) <- mpreq (multiUserField True Nothing) (fslpI MsgUtilEMail (mr MsgUtilEMail) & setTooltip MsgUtilMultiEmailFieldTip & addName (nudge "email")) Nothing + (addRes, addView) <- mpreq (multiUserField False Nothing) (fslpI MsgUtilEMail (mr MsgUtilEMail) & setTooltip MsgUtilMultiEmailFieldTip & addName (nudge "email")) Nothing let addRes' = addRes <&> \nEmails ((Map.elems &&& maybe 0 (succ . snd . fst) . Map.lookupMax) . Map.filterWithKey (\(BoundedPosition c, _) _ -> c == RecipientCustom) -> (oEmails, kStart)) -> FormSuccess . Map.fromList . zip (map (BoundedPosition RecipientCustom, ) [kStart..]) . Set.toList $ nEmails `Set.difference` Set.fromList oEmails return (addRes', $(widgetFile "widgets/communication/recipientAdd")) diff --git a/src/Handler/Utils/Company.hs b/src/Handler/Utils/Company.hs index 86f88ef03..f4053e2f3 100644 --- a/src/Handler/Utils/Company.hs +++ b/src/Handler/Utils/Company.hs @@ -1,7 +1,10 @@ --- SPDX-FileCopyrightText: 2022 Steffen Jost +-- SPDX-FileCopyrightText: 2022-2025 Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later +{-# LANGUAGE BlockArguments #-} -- do starts is own block +{-# OPTIONS_GHC -fno-warn-orphans #-} + module Handler.Utils.Company where @@ -21,6 +24,9 @@ import qualified Database.Esqueleto.PostgreSQL as E import Handler.Utils.Users import Handler.Utils.Widgets +-- KeyCompany is CompanyShorthand, i.e. CI Text +instance E.SqlString (Key Company) + -- Snippet to restrict to primary company only -- E.&&. E.notExists (do -- othr <- E.from $ E.table @UserCompany @@ -32,9 +38,21 @@ import Handler.Utils.Widgets company2msg :: CompanyId -> SomeMessage UniWorX company2msg = text2message . ciOriginal . unCompanyKey -wgtCompanies :: UserId -> DB (Maybe Widget) -wgtCompanies = \uid -> do - companies <- E.select $ do +-- for convenience in debugging +instance ToText (Maybe CompanyId) where + toText Nothing = toText ("-None-"::Text) + toText (Just fsh) = toText $ unCompanyKey fsh + +wgtCompanies :: Bool -> UserId -> DB (Maybe Widget) +wgtCompanies useShort = (wrapUL . fst <<$>>) . wgtCompanies' useShort + where + wrapUL wgt = [whamlet|
                  ^{wgt}|] + +-- | Given a UserId, create widget showing top-companies (with internal link) and associated companies (unlinked) +-- NOTE: The widget must be wrapped with
                    +wgtCompanies' :: Bool -> UserId -> DB (Maybe (Widget, [(CompanyShorthand,CompanyName,Bool,Int)])) +wgtCompanies' useShort uid = do + companies <- $(E.unValueN 4) <<$>> E.select do (usrComp :& comp) <- E.from $ E.table @UserCompany `E.innerJoin` E.table @Company `E.on` (\(usrComp :& comp) -> usrComp E.^. UserCompanyCompany E.==. comp E.^. CompanyId) E.where_ $ usrComp E.^. UserCompanyUser E.==. E.val uid @@ -44,20 +62,23 @@ wgtCompanies = \uid -> do resWgt = [whamlet| $forall c <- topCmp -

                    +

                  • ^{c} $forall c <- otherCmp -

                    +

                  • ^{c} |] - return $ toMaybe (notNull topCmp) resWgt + return $ toMaybe (notNull companies) (resWgt, companies) where procCmp _ [] = (0, [], []) - procCmp maxPri ((E.Value cmpSh, E.Value cmpName, E.Value cmpSpr, E.Value cmpPrio) : cs) = + procCmp maxPri ((cmpSh, cmpName, cmpSpr, cmpPrio) : cs) = let isTop = cmpPrio >= maxPri - cmpWgt = companyWidget isTop (cmpSh, cmpName, cmpSpr) + cmpWgt = companyWidget' useShort isTop (cmpSh, cmpName, cmpSpr) (accPri,accTop,accRem) = procCmp maxPri cs - in (max cmpPrio accPri, bool accTop (cmpWgt : accTop) isTop, bool (cmpWgt : accRem) accRem isTop) -- lazy evaluation after repmin example, don't factor out the bool! + in ( max cmpPrio accPri + , bool accTop (cmpWgt : accTop) isTop -- lazy evaluation after repmin example, don't factor out the bool! + , bool (cmpWgt : accRem) accRem isTop + ) type AnySuperReason = Either SupervisorReason (Maybe Text) @@ -88,7 +109,8 @@ addDefaultSupervisors reason cid employees = do [ UserSupervisorRerouteNotifications E.=. new E.^. UserSupervisorRerouteNotifications , UserSupervisorCompany E.=. E.justVal cid , UserSupervisorReason E.=. E.coalesce [new E.^. UserSupervisorReason, old E.^. UserSupervisorReason] -- keep existing reason, if no new one was given - ]) + ] + ) -- like `Handler.Utils.addDefaultSupervisors`, but selects all employees of given companies from database, optionally filtered by being under supervision of a given individual @@ -148,7 +170,7 @@ addDefaultSupervisorsAll reason mutualSupervision cids = do , UserSupervisorReason E.=. E.coalesce [old E.^. UserSupervisorReason, new E.^. UserSupervisorReason] -- keep any existing reason ] ) --- | removes user supervisorship on switch. WARNING: problems are not yet written to DB via reportProblem yet +-- | removes user supervisorship on switch. WARNING: problems are only returned, but not yet written to DB via reportProblem switchAvsUserCompany :: Bool -> Bool -> UserId -> CompanyId -> DB ([Update User], [AdminProblem]) switchAvsUserCompany usrPostEmailUpds keepOldCompanySupervs uid newCompanyId = do usrRec <- get404 uid @@ -163,13 +185,12 @@ switchAvsUserCompany usrPostEmailUpds keepOldCompanySupervs uid newCompanyId = d usrPrefPost = userPrefersPostal usrRec usrPrefPostUp = toMaybe (Just usrPrefPost == (mbOldComp ^? _Just . _companyPrefersPostal)) (UserPrefersPostal =. companyPrefersPostal newCompany) + usrPinPassUp = toMaybe (newCompany ^. _companyPinPassword . _not) (UserPinPassword =. Nothing) -- newCmpEmail :: UserEmail = fromMaybe "" $ companyEmail newCompany usrDisplayEmail :: UserEmail = userDisplayEmail usrRec avsEmail :: Maybe UserEmail = mbUsrAvs ^? _Just . _entityVal . _userAvsLastFirmInfo . _Just . _avsFirmPrimaryEmail . _Just . from _CI usrDisplayEmailUp = toMaybe (usrPostEmailUpds && avsEmail == Just usrDisplayEmail) (UserDisplayEmail =. "") -- delete DisplayEmail, if equal to AVS Firm Email - usrUpdate = catMaybes [usrPostUp, usrPrefPostUp, usrDisplayEmailUp] - -- [UserPostAddress =. Nothing, UserPrefersPostal =. companyPrefersPostal newCompany] -- unconditional - + usrUpdate = catMaybes [usrPostUp, usrPrefPostUp, usrPinPassUp, usrDisplayEmailUp] newUserComp = UserCompany uid newCompanyId False False 1 True Nothing -- default value for new company insertion, if no update can be done superReasonComDef = tshow SupervisorReasonCompanyDefault @@ -178,14 +199,16 @@ switchAvsUserCompany usrPostEmailUpds keepOldCompanySupervs uid newCompanyId = d case mbUsrComp of Nothing -> do -- create company user void $ insertUnique newUserComp - void $ addDefaultSupervisors Nothing newCompanyId $ singleton uid + newAPs <- addDefaultSupervisors' newCompanyId $ singleton uid + $logInfoS "Supervision" [st|switchAvsUserCompany for #{tshow uid} to #{unCompanyKey newCompanyId}. #{newAPs} default company supervisors upserted.|] return (usrUpdate, mempty) Just UserCompany{userCompanyCompany=oldCompanyId, userCompanyPriority=oldPrio, userCompanySupervisor=oldSuper, userCompanySupervisorReroute=oldSuperReroute, userCompanyReason=oldAssocReason} | newCompanyId == oldCompanyId -> return mempty -- nothing to do | otherwise -> do -- switch company when (isNothing oldAssocReason) $ deleteBy $ UniqueUserCompany uid oldCompanyId - void $ upsertBy (UniqueUserCompany uid newCompanyId) newUserComp{userCompanyPriority = succ oldPrio} - [UserCompanyPriority =. succ oldPrio, UserCompanySupervisor =. False, UserCompanySupervisorReroute =. False, UserCompanyUseCompanyAddress =. True, UserCompanyReason =. Nothing] + let newPrio = succ oldPrio + void $ upsertBy (UniqueUserCompany uid newCompanyId) newUserComp{userCompanyPriority = newPrio} + [UserCompanyPriority =. newPrio, UserCompanySupervisor =. False, UserCompanySupervisorReroute =. False, UserCompanyUseCompanyAddress =. True, UserCompanyReason =. Nothing] -- supervised by uid supervisees :: [(Entity UserSupervisor, E.Value Bool)] <- E.select $ do usrSup <- E.from $ E.table @UserSupervisor @@ -205,17 +228,19 @@ switchAvsUserCompany usrPostEmailUpds keepOldCompanySupervs uid newCompanyId = d return $ [ AdminProblemSupervisorLeftCompany subid oldCompanyId oldSuperReroute | (Entity{entityVal=UserSupervisor{userSupervisorUser=subid}}, E.Value True) <- supervisees ] -- supervisors of uid - let superDeftFltr = (UserSupervisorUser ==. uid) : (UserSupervisorReason ~=. superReasonComDef) - oldSubFltr = (UserSupervisorCompany ~=. oldCompanyId) <> superDeftFltr + let superDeftFltr = (UserSupervisorUser ==. uid) : (UserSupervisorReason ~=. superReasonComDef) -- default or no reason + oldSubFltr = (UserSupervisorCompany ~=. oldCompanyId) <> superDeftFltr -- old company or no company oldAPs <- if keepOldCompanySupervs then updateWhereCount oldSubFltr [UserSupervisorReason =. Nothing] else deleteWhereCount oldSubFltr - void $ addDefaultSupervisors Nothing newCompanyId $ singleton uid + nrDefSups <- addDefaultSupervisors' newCompanyId $ singleton uid -- CHECK HERE WITH LINES ABOVE newAPs <- count $ (UserSupervisorCompany ==. Just newCompanyId) : superDeftFltr let isNoLongerSupervised = not keepOldCompanySupervs && oldAPs > 0 && newAPs <= 0 problems = bcons oldSuper (AdminProblemSupervisorNewCompany uid oldCompanyId newCompanyId oldSuperReroute) $ bcons isNoLongerSupervised (AdminProblemNewlyUnsupervised uid (Just oldCompanyId) newCompanyId) newlyUnsupervised + delupd = bool "deleted" "updated" keepOldCompanySupervs :: Text + $logInfoS "Supervision" [st|switchAvsUserCompany for #{tshow uid} from #{unCompanyKey oldCompanyId} to #{unCompanyKey newCompanyId}. #{oldAPs} old APs #{delupd}. #{nrDefSups} default company supervisors upserted. #{newAPs} new company supervisors counted now.|] return (usrUpdate ,problems) defaultSupervisorReasonFilter :: [Filter UserSupervisor] @@ -233,7 +258,8 @@ deleteDefaultSupervisorsForUsers cids sprs usrs = $ bcons (notNull sprs) (UserSupervisorSupervisor <-. sprs) $ (UserSupervisorUser <-. toList usrs) : defaultSupervisorReasonFilter --- | retrieve maximum company user priority fo a user +-- | retrieve maximum company user priority for a user + getCompanyUserMaxPrio :: UserId -> DB Int getCompanyUserMaxPrio uid = do mbMaxPrio <- E.selectOne $ do @@ -241,3 +267,23 @@ getCompanyUserMaxPrio uid = do E.where_ $ usrCmp E.^. UserCompanyUser E.==. E.val uid return . E.max_ $ usrCmp E.^. UserCompanyPriority return $ maybe 1 (fromMaybe 1 . E.unValue) mbMaxPrio + +-- | retrieve maximum company user priority for a user within SQL query +-- Note: if there a multiple top-companies, only one is returned +selectCompanyUserPrime :: E.SqlExpr (Entity User) -> E.SqlExpr (E.Value (Maybe CompanyId)) +selectCompanyUserPrime usr = E.subSelect $ selectCompanyUserPrimeHelper $ usr E.^. UserId + +-- | like @selectCompanyUserPrime@, but directly usable, a simpler type to think about it `UserId -> DB (Maybe CompanyId)` +selectCompanyUserPrime' :: (MonadIO m, BackendCompatible SqlBackend backend, PersistQueryRead backend, PersistUniqueRead backend) + => UserId -> ReaderT backend m (Maybe CompanyId) +selectCompanyUserPrime' uid = fmap E.unValue <<$>> E.selectOne $ selectCompanyUserPrimeHelper $ E.val uid + +-- selectCompanyUserPrime'' :: UserId -> DB (Maybe CompanyId) +-- selectCompanyUserPrime'' uid = (userCompanyCompany . entityVal) <<$>> selectMaybe [UserCompanyUser ==. uid] [Desc UserCompanyPriority, Asc UserCompanyCompany] + +selectCompanyUserPrimeHelper :: E.SqlExpr (E.Value UserId) -> E.SqlQuery (E.SqlExpr (E.Value CompanyId)) +selectCompanyUserPrimeHelper uid = do + uc <- E.from $ E.table @UserCompany + E.where_ $ uc E.^. UserCompanyUser E.==. uid + E.orderBy [E.desc $ uc E.^. UserCompanyPriority, E.asc $ uc E.^. UserCompanyCompany] + return (uc E.^. UserCompanyCompany) \ No newline at end of file diff --git a/src/Handler/Utils/Course/Cache.hs b/src/Handler/Utils/Course/Cache.hs new file mode 100644 index 000000000..63e0a0d0c --- /dev/null +++ b/src/Handler/Utils/Course/Cache.hs @@ -0,0 +1,188 @@ +-- SPDX-FileCopyrightText: 2024 Steffen Jost +-- +-- SPDX-License-Identifier: AGPL-3.0-or-later + +module Handler.Utils.Course.Cache where + +import Import + +import Handler.Utils +-- import Handler.Utils.Occurrences +import Handler.Exam.Form (ExamOccurrenceForm(..)) + +import qualified Data.Set as Set +import qualified Data.Map as Map +-- import qualified Data.Aeson as Aeson + + +-- import Database.Persist.Sql (updateWhereCount) +import Database.Esqueleto.Experimental ((:&)(..)) +import qualified Database.Esqueleto.Experimental as E +import qualified Database.Esqueleto.Utils as E +-- import Database.Esqueleto.PostgreSQL.JSON ((@>.)) +-- import qualified Database.Esqueleto.PostgreSQL.JSON as E hiding ((?.)) + + + +-- partial JSON object to be used for filtering with "@>" +-- ensure that a GIN index for the jsonb column is created in Model.Migration.Definitions +-- occurrenceDayValue :: Day -> Value +-- occurrenceDayValue d = Aeson.object +-- [ "exceptions" Aeson..= +-- [ Aeson.object +-- [ "exception" Aeson..= ("occur"::Text) +-- , "day" Aeson..= d +-- ] ] ] + +{- More efficient DB-only version, but ignores regular schedules +getDayTutorials :: SchoolId -> Day -> DB [TutorialId] +getDayTutorials ssh d = E.unValue <<$>> E.select (do + (trm :& crs :& tut) <- E.from $ E.table @Term + `E.innerJoin` E.table @Course `E.on` (\(trm :& crs) -> crs E.^. CourseTerm E.==. trm E.^. TermId) + `E.innerJoin` E.table @Tutorial `E.on` (\(_ :& crs :& tut) -> crs E.^. CourseId E.==. tut E.^. TutorialCourse) + E.where_ $ E.between (E.val d) (trm E.^. TermStart, trm E.^. TermEnd) + E.&&. crs E.^. CourseSchool E.==. E.val ssh + E.&&. (E.just (tut E.^. TutorialTime) @>. E.jsonbVal (occurrenceDayValue d)) + return $ tut E.^. TutorialId + ) +-} + +-- | Datatype to be used as key for memcaching DayTask related stuff; note that newtype-CacheKeys are optimized away, so multiple constructors are advisable +data CourseCacheKeys + = CacheKeyTutorialOccurrences SchoolId (Day,Day) -- ^ Map TutorialId (TutorialName, [LessonTime]) + | CacheKeyExamOccurrences SchoolId (Day,Day) (Maybe CourseId) -- ^ Map ExamOccurrenceId (CourseId, ExamName, ExamOccurrence) + | CacheKeySuggsParticipantNote SchoolId TutorialId + | CacheKeySuggsAttendanceNote SchoolId TutorialId + | CacheKeyTutorialCheckResults SchoolId Day + deriving (Eq, Ord, Read, Show, Generic) + deriving anyclass (Hashable, Binary, NFData) + +-- getDayTutorials :: SchoolId -> (Day,Day) -> DB [TutorialId] +-- getDayTutorials ssh dlimit@(dstart, dend ) +-- | dstart > dend = return mempty +-- | otherwise = memcachedByClass MemcachedKeyClassTutorialOccurrences (Just . Right $ 12 * diffDay) (CacheKeyTutorialOccurrences ssh dlimit) $ do -- same key is ok, distinguished by return type +-- candidates <- E.select $ do +-- (trm :& crs :& tut) <- E.from $ E.table @Term +-- `E.innerJoin` E.table @Course `E.on` (\(trm :& crs) -> crs E.^. CourseTerm E.==. trm E.^. TermId) +-- `E.innerJoin` E.table @Tutorial `E.on` (\(_ :& crs :& tut) -> crs E.^. CourseId E.==. tut E.^. TutorialCourse) +-- E.where_ $ crs E.^. CourseSchool E.==. E.val ssh +-- E.&&. trm E.^. TermStart E.<=. E.val dend +-- E.&&. trm E.^. TermEnd E.>=. E.val dstart +-- return (trm, tut, E.just (tut E.^. TutorialTime) @>. E.jsonbVal (occurrenceDayValue dstart)) +-- -- logErrorS "DAILY" $ foldMap (\(Entity{entityVal=someTerm},Entity{entityVal=Tutorial{..}},_) -> tshow someTerm <> " *** " <> ciOriginal tutorialName <> ": " <> tshow (unJSONB tutorialTime)) candidates +-- return $ mapMaybe checkCandidate candidates +-- where +-- period = Set.fromAscList [dstart..dend] + +-- checkCandidate (_, Entity{entityKey=tutId}, E.unValue -> True) = Just tutId -- most common case +-- checkCandidate (Entity{entityVal=trm}, Entity{entityKey=tutId, entityVal=Tutorial{tutorialTime=JSONB occ}}, _) +-- | not $ Set.null $ Set.intersection period $ occurrencesCompute' trm occ +-- = Just tutId +-- | otherwise +-- = Nothing + +-- | like the previous version above, but also returns the lessons occurring within the given time frame +-- Due to caching, we only use the more informative version, unless experiments with the full DB show otherwise +getDayTutorials :: SchoolId -> (Day,Day) -> DB (Map TutorialId (TutorialName, [LessonTime])) +getDayTutorials ssh dlimit@(dstart, dend ) + | dstart > dend = return mempty + | otherwise = memcachedByClass MemcachedKeyClassTutorialOccurrences (Just . Right $ 12 * diffDay) (CacheKeyTutorialOccurrences ssh dlimit) $ do + candidates <- E.select $ do + (trm :& crs :& tut) <- E.from $ E.table @Term + `E.innerJoin` E.table @Course `E.on` (\(trm :& crs) -> crs E.^. CourseTerm E.==. trm E.^. TermId) + `E.innerJoin` E.table @Tutorial `E.on` (\(_ :& crs :& tut) -> crs E.^. CourseId E.==. tut E.^. TutorialCourse) + E.where_ $ crs E.^. CourseSchool E.==. E.val ssh + E.&&. trm E.^. TermStart E.<=. E.val dend + E.&&. trm E.^. TermEnd E.>=. E.val dstart + return (trm, tut) + -- logErrorS "DAILY" $ foldMap (\(Entity{entityVal=someTerm},Entity{entityVal=Tutorial{..}},_) -> tshow someTerm <> " *** " <> ciOriginal tutorialName <> ": " <> tshow (unJSONB tutorialTime)) candidates + return $ foldMap checkCandidate candidates + where + checkCandidate :: (Entity Term, Entity Tutorial) -> Map TutorialId (TutorialName, [LessonTime]) + checkCandidate (Entity{entityVal=trm}, Entity{entityKey=tutId, entityVal=Tutorial{tutorialTime=JSONB occ, tutorialName=tName}}) + | let lessons = Set.filter lessonFltr $ occurringLessons trm occ + , notNull lessons + = Map.singleton tutId (tName , Set.toAscList lessons) -- due to Set not having a Functor instance, we need mostly need lists anyway + | otherwise + = mempty + + lessonFltr :: LessonTime -> Bool + lessonFltr LessonTime{..} = dstart <= localDay lessonStart + && dend >= localDay lessonEnd + +-- -- retrieve all exam occurrences for a school for a term in a given time period; uses caching +-- getDayExamOccurrences :: SchoolId -> (Day,Day) -> DB (Map ExamOccurrenceId (CourseId, ExamName, ExamOccurrence)) +-- getDayExamOccurrences ssh dlimit@(dstart, dend ) +-- | dstart > dend = return mempty +-- | otherwise = memcachedByClass MemcachedKeyClassExamOccurrences (Just . Right $ 12 * diffDay) (CacheKeyExamOccurrences ssh dlimit) $ do +-- candidates <- E.select $ do +-- (trm :& crs :& exm :& occ) <- E.from $ E.table @Term +-- `E.innerJoin` E.table @Course `E.on` (\(trm :& crs) -> crs E.^. CourseTerm E.==. trm E.^. TermId) +-- `E.innerJoin` E.table @Exam `E.on` (\(_ :& crs :& exm) -> crs E.^. CourseId E.==. exm E.^. ExamCourse) +-- `E.innerJoin` E.table @ExamOccurrence `E.on` (\(_ :& _ :& exm :& occ) -> exm E.^. ExamId E.==. occ E.^. ExamOccurrenceExam) +-- E.where_ $ E.val ssh E.==. crs E.^. CourseSchool +-- E.&&. E.val dstart E.<=. trm E.^. TermEnd +-- E.&&. E.val dend E.>=. trm E.^. TermStart +-- E.&&. ( E.between (E.day $ occ E.^. ExamOccurrenceStart) (E.val dstart, E.val dend) +-- E.||. E.between (E.dayMaybe $ occ E.^. ExamOccurrenceEnd) (E.justVal dstart, E.justVal dend) +-- ) +-- return (exm, occ) +-- return $ foldMap mkOccMap candidates +-- where +-- mkOccMap :: (Entity Exam, Entity ExamOccurrence) -> Map ExamOccurrenceId (CourseId, ExamName, ExamOccurrence) +-- mkOccMap (entityVal -> exm, Entity{..}) = Map.singleton entityKey (exm ^. _examCourse, exm ^. _examName, entityVal) + +type ExamOccurrenceMap = Map ExamOccurrenceId (ExamOccurrence, CryptoUUIDExamOccurrence, (ExamId, ExamName)) +type ExamToOccurrencesMap = Map ExamId (Set CryptoUUIDExamOccurrence, Set ExamOccurrenceForm) + +-- | retrieve all exam occurrences for a school in a given time period, ignoring term times; uses caching +-- if a CourseId is specified, only exams from that course are returned +getDayExamOccurrences :: Bool -> SchoolId -> Maybe CourseId -> (Day,Day) -> DB ExamOccurrenceMap +getDayExamOccurrences onlyOpen ssh mbcid dlimit@(dstart, dend) + | dstart > dend = return mempty + | otherwise = memcachedByClass MemcachedKeyClassExamOccurrences (Just . Right $ 12 * diffDay) (CacheKeyExamOccurrences ssh dlimit mbcid) $ do + now <- liftIO getCurrentTime + candidates <- E.select $ do + (crs :& exm :& occ) <- E.from $ E.table @Course + `E.innerJoin` E.table @Exam `E.on` (\(crs :& exm) -> crs E.^. CourseId E.==. exm E.^. ExamCourse) + `E.innerJoin` E.table @ExamOccurrence `E.on` (\(_ :& exm :& occ) -> exm E.^. ExamId E.==. occ E.^. ExamOccurrenceExam) + E.where_ $ E.and $ catMaybes + [ toMaybe onlyOpen $ E.justVal now E.>=. exm E.^. ExamRegisterFrom -- fail on null + E.&&. E.val now E.<~. exm E.^. ExamRegisterTo -- success on null + , mbcid <&> ((E.==. (crs E.^. CourseId)) . E.val) + , Just $ crs E.^. CourseSchool E.==. E.val ssh + , Just $ E.withinPeriod dlimit (occ E.^. ExamOccurrenceStart) (occ E.^. ExamOccurrenceEnd) + ] + -- E.orderBy [E.asc $ exm E.^. ExamName] -- we return a map, so the order does not matter + return (occ, exm E.^. ExamId, exm E.^. ExamName) -- No Binary instance for Entity Exam, so we only extract what is needed for now + foldMapM mkOccMap candidates + where + mkOccMap :: (Entity ExamOccurrence, E.Value ExamId, E.Value ExamName) -> DB ExamOccurrenceMap + mkOccMap (Entity{..}, E.Value eId, E.Value eName) = encrypt entityKey <&> (\ceoId -> Map.singleton entityKey (entityVal, ceoId, (eId, eName))) + +mkExamOccurrenceOptions :: ExamOccurrenceMap -> OptionList ExamOccurrenceId +mkExamOccurrenceOptions = mkOptionListGrouped . map (over _2 $ sortBy (compare `on` optionDisplay)) . groupSort . map mkEOOption . Map.toList + where + mkEOOption :: (ExamOccurrenceId, (ExamOccurrence, CryptoUUIDExamOccurrence, (ExamId, ExamName))) -> (Text, [Option ExamOccurrenceId]) + mkEOOption (eid, (ExamOccurrence{examOccurrenceName}, ceoId, (_,eName))) = (ciOriginal eName, [Option{..}]) + where + optionDisplay = ciOriginal examOccurrenceName + optionExternalValue = toPathPiece ceoId + optionInternalValue = eid + +convertExamOccurrenceMap :: ExamOccurrenceMap -> ExamToOccurrencesMap +convertExamOccurrenceMap eom = Map.fromListWith (<>) $ map aux $ Map.toList eom + where + aux :: (ExamOccurrenceId, (ExamOccurrence, CryptoUUIDExamOccurrence, (ExamId, ExamName))) -> (ExamId, (Set CryptoUUIDExamOccurrence, Set ExamOccurrenceForm)) + aux (_, (ExamOccurrence{..}, cueoId, (eid,_))) = (eid, (Set.singleton cueoId, Set.singleton ExamOccurrenceForm + { eofId = Just cueoId + , eofName = Just examOccurrenceName + , eofExaminer = examOccurrenceExaminer + , eofRoom = examOccurrenceRoom + , eofRoomHidden = examOccurrenceRoomHidden + , eofCapacity = examOccurrenceCapacity + , eofStart = examOccurrenceStart + , eofEnd = examOccurrenceEnd + , eofDescription = examOccurrenceDescription + } + )) \ No newline at end of file diff --git a/src/Handler/Utils/DateTime.hs b/src/Handler/Utils/DateTime.hs index 58beaf373..e955044b0 100644 --- a/src/Handler/Utils/DateTime.hs +++ b/src/Handler/Utils/DateTime.hs @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2022-23 Gregor Kleen ,Sarah Vaupel ,Steffen Jost ,Steffen Jost +-- SPDX-FileCopyrightText: 2022-2023 Gregor Kleen ,Sarah Vaupel ,Steffen Jost ,Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later @@ -97,6 +97,10 @@ toTimeOfDay todHour todMin todSec d = localTimeToUTCTZ appTZ $ LocalTime d TimeO addHours :: Integral n => n -> UTCTime -> UTCTime addHours = addUTCTime . secondsToNominalDiffTime . fromIntegral . (* 3600) +-- Use _utctDay %~ addDays n instead! +-- addDaysSimple :: Integer -> UTCTime -> UTCTime +-- addDaysSimple n t = t { utctDay = addDays n (utctDay t) } + instance HasLocalTime UTCTime where toLocalTime = utcToLocalTime @@ -118,7 +122,7 @@ formatTimeW :: (HasLocalTime t, YesodAuthPersist UniWorX, AuthEntity UniWorX ~ U formatTimeW s t = toWidget =<< formatTime s t formatTimeMail :: (MonadMail m, HasLocalTime t) => SelDateTimeFormat -> t -> m Text -formatTimeMail sel t = fmap fromString $ Time.formatTime <$> (getTimeLocale' . view _Wrapped <$> askMailLanguages) <*> (unDateTimeFormat <$> askMailDateTimeFormat sel) <*> pure (toLocalTime t) +formatTimeMail sel t = fmap fromString $ (Time.formatTime . getTimeLocale' . view _Wrapped <$> askMailLanguages) <*> (unDateTimeFormat <$> askMailDateTimeFormat sel) <*> pure (toLocalTime t) getTimeLocale :: MonadHandler m => m TimeLocale getTimeLocale = getTimeLocale' <$> languages diff --git a/src/Handler/Utils/Delete.hs b/src/Handler/Utils/Delete.hs index dbd062bbb..2ebf4df4f 100644 --- a/src/Handler/Utils/Delete.hs +++ b/src/Handler/Utils/Delete.hs @@ -21,6 +21,7 @@ module Handler.Utils.Delete import Import import Handler.Utils.Form +import Handler.Utils.Memcached import qualified Data.Text as Text import qualified Data.Set as Set @@ -113,6 +114,7 @@ deleteR' DeleteRoute{..} = do True -> do runDBJobs $ do forM_ drRecords $ \k -> drDelete k $ delete k + memcachedInvalidateClass MemcachedKeyClassTutorialOccurrences addMessageI Success drSuccessMessage redirect drSuccess False -> diff --git a/src/Handler/Utils/Files.hs b/src/Handler/Utils/Files.hs index 07b777643..1644c91cf 100644 --- a/src/Handler/Utils/Files.hs +++ b/src/Handler/Utils/Files.hs @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2022 Gregor Kleen ,Sarah Vaupel +-- SPDX-FileCopyrightText: 2022-2024 Gregor Kleen ,Sarah Vaupel , Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later @@ -18,8 +18,6 @@ import Foundation.Type import Foundation.DB import Utils.Metrics -import Data.Monoid (First(..)) - import qualified Data.Conduit.Combinators as C import qualified Data.Conduit.List as C (unfoldM) @@ -32,7 +30,7 @@ import qualified Database.Esqueleto.Utils as E import System.FilePath (normalise, makeValid) import Data.List (dropWhileEnd) -import qualified Data.ByteString as ByteString +{-# ANN module ("HLint: ignore Redundant bracket" :: String) #-} data SourceFilesException @@ -44,60 +42,19 @@ data SourceFilesException makePrisms ''SourceFilesException -fileChunkARC :: ( MonadHandler m - , HandlerSite m ~ UniWorX - ) - => Maybe Int - -> (FileContentChunkReference, (Int, Int)) - -> m (Maybe (ByteString, Maybe FileChunkStorage)) +fileChunk :: ( MonadHandler m ) + => m (Maybe (ByteString, Maybe FileChunkStorage)) -> m (Maybe ByteString) -fileChunkARC altSize k@(ref, (s, l)) getChunkDB' = do - prewarm <- getsYesod appFileSourcePrewarm - let getChunkDB = case prewarm of - Nothing -> do - chunk' <- getChunkDB' - for chunk' $ \(chunk, mStorage) -> chunk <$ do - $logDebugS "fileChunkARC" "No prewarm" - for_ mStorage $ \storage -> - let w = length chunk - in liftIO $ observeSourcedChunk storage w - Just lh -> do - chunkRes <- lookupLRUHandle lh k - case chunkRes of - Just (chunk, w) -> Just chunk <$ do - $logDebugS "fileChunkARC" "Prewarm hit" - liftIO $ observeSourcedChunk StoragePrewarm w - Nothing -> do - chunk' <- getChunkDB' - for chunk' $ \(chunk, mStorage) -> chunk <$ do - $logDebugS "fileChunkARC" "Prewarm miss" - for_ mStorage $ \storage -> - let w = length chunk - in liftIO $ observeSourcedChunk storage w - - arc <- getsYesod appFileSourceARC - case arc of - Nothing -> getChunkDB - Just ah -> do - cachedARC' ah k $ \case - Nothing -> do - chunk' <- case assertM (> l) altSize of - -- This optimization works for the somewhat common case that cdc chunks are smaller than db chunks and start of the requested range is aligned with a db chunk boundary - Just altSize' - -> fmap getFirst . execWriterT . cachedARC' ah (ref, (s, altSize')) $ \x -> x <$ case x of - Nothing -> tellM $ First <$> getChunkDB - Just (v, _) -> tell . First . Just $ ByteString.take l v - Nothing -> getChunkDB - for chunk' $ \chunk -> do - let w = length chunk - $logDebugS "fileChunkARC" "ARC miss" - return (chunk, w) - Just x@(_, w) -> do - $logDebugS "fileChunkARC" "ARC hit" - liftIO $ Just x <$ observeSourcedChunk StorageARC w +fileChunk getChunkDB' = do + -- NOTE: crude surgery happened here to remove ARC caching; useless artifacts may have remained + chunk' <- getChunkDB' + for chunk' $ \(chunk, mStorage) -> chunk <$ do + $logDebugS "fileChunkARC" "No prewarm" + for_ mStorage $ \storage -> + let w = length chunk + in liftIO $ observeSourcedChunk storage w - sourceFileDB :: forall m. (MonadCatch m, MonadHandler m, HandlerSite m ~ UniWorX, MonadUnliftIO m) => FileContentReference -> ConduitT () ByteString (SqlPersistT m) () @@ -119,12 +76,12 @@ sourceFileChunks cont chunkHash = transPipe (withReaderT $ projectBackend @SqlRe Nothing -> return Nothing Just start -> do let getChunkDB = cont (start, dbChunksize) . runMaybeT $ - let getChunkDB' = MaybeT . fmap (fmap $ (, StorageDB) . E.unValue) . E.selectMaybe . E.from $ \fileContentChunk -> do + let getChunkDB' = MaybeT . fmap (fmap $ (, StorageDB) . E.unValue) . E.selectOne . E.from $ \fileContentChunk -> do E.where_ $ fileContentChunk E.^. FileContentChunkHash E.==. E.val chunkHash return $ E.substring (fileContentChunk E.^. FileContentChunkContent) (E.val start) (E.val dbChunksize) getChunkMinio = fmap (, StorageMinio) . catchIfMaybeT (is _SourceFilesContentUnavailable) . runConduit $ sourceMinio (Left chunkHash) (Just $ ByteRangeFromTo (fromIntegral $ pred start) (fromIntegral . pred $ pred start + dbChunksize)) .| C.fold in getChunkDB' <|> getChunkMinio - chunk <- fileChunkARC Nothing (chunkHash, (start, dbChunksize)) getChunkDB + chunk <- fileChunk getChunkDB case chunk of Just c | olength c <= 0 -> return Nothing Just c -> do @@ -191,7 +148,7 @@ sourceFile' = sourceFile . view (_FileReference . _1) instance (YesodMail UniWorX, YesodPersistBackend UniWorX ~ SqlBackend) => ToMailPart UniWorX FileReference where toMailPart = toMailPart <=< liftHandler . runDBRead . withReaderT projectBackend . toPureFile . sourceFile' - + respondFileConditional :: (MonadThrow m, MonadHandler m, HandlerSite m ~ UniWorX, YesodPersistBackend UniWorX ~ SqlBackend, YesodPersistRunner UniWorX) => Maybe UTCTime -> MimeType @@ -253,10 +210,10 @@ respondFileConditional representationLastModified cType FileReference{..} = do forM_ relevantChunks $ \(chunkHash, offset, cLength) -> let retrieveChunk = \case Just (start, cLength') | cLength' > 0 -> do - let getChunkDB = fmap (fmap $ (, Just StorageDB) . E.unValue) . E.selectMaybe . E.from $ \fileContentChunk -> do + let getChunkDB = fmap (fmap $ (, Just StorageDB) . E.unValue) . E.selectOne . E.from $ \fileContentChunk -> do E.where_ $ fileContentChunk E.^. FileContentChunkHash E.==. E.val chunkHash return $ E.substring (fileContentChunk E.^. FileContentChunkContent) (E.val start) (E.val $ min cLength' dbChunksize) - chunk <- fileChunkARC (Just $ fromIntegral dbChunksize) (chunkHash, (fromIntegral start, fromIntegral $ min cLength' dbChunksize)) getChunkDB + chunk <- fileChunk getChunkDB case chunk of Nothing -> throwM SourceFilesContentUnavailable Just c -> do @@ -270,7 +227,7 @@ respondFileConditional representationLastModified cType FileReference{..} = do , ByteContentRangeSpecification (Just $ ByteRangeResponseSpecification byteFrom byteTo) (Just iLength) ) | otherwise -> throwM SourceFilesContentUnavailable - + | otherwise -> return $ sendResponseStatus noContent204 () where @@ -281,7 +238,7 @@ respondFileConditional representationLastModified cType FileReference{..} = do , requestedActionAlreadySucceeded = Nothing } -byteRangeSpecificationToMinio :: Word64 -> ByteRangeSpecification -> (ByteRange, ByteRangeResponseSpecification) +byteRangeSpecificationToMinio :: Word64 -> ByteRangeSpecification -> (ByteRange, ByteRangeResponseSpecification) byteRangeSpecificationToMinio iLength byteRange = (byteRange', respRange) where byteRange' = case byteRange of @@ -293,7 +250,7 @@ byteRangeSpecificationToMinio iLength byteRange = (byteRange', respRange) ByteRangeSpecification f (Just t) -> ByteRangeResponseSpecification (min (pred iLength) f) (min (pred iLength) t) ByteRangeSuffixSpecification s -> ByteRangeResponseSpecification (iLength - min (pred iLength) s) (pred iLength) - + acceptFile :: (MonadResource m, MonadResource m') => FileInfo -> m (File m') acceptFile fInfo = do let fileTitle = "." unpack (fileName fInfo) diff --git a/src/Handler/Utils/Form.hs b/src/Handler/Utils/Form.hs index 24ceb7b92..e5ec7d61b 100644 --- a/src/Handler/Utils/Form.hs +++ b/src/Handler/Utils/Form.hs @@ -1,8 +1,9 @@ --- SPDX-FileCopyrightText: 2022 Felix Hamann ,Gregor Kleen ,Sarah Vaupel ,Sarah Vaupel ,Steffen Jost ,Winnie Ros +-- SPDX-FileCopyrightText: 2022-2025 Felix Hamann ,Gregor Kleen ,Sarah Vaupel ,Sarah Vaupel ,Steffen Jost ,Winnie Ros ,Steffen Jost -- -- SPDX-License-Identifier: AGPL-3.0-or-later {-# OPTIONS_GHC -fno-warn-redundant-constraints #-} +{-# LANGUAGE TypeApplications #-} module Handler.Utils.Form ( module Handler.Utils.Form @@ -16,16 +17,12 @@ import Utils.Form import Utils.Files import Handler.Utils.Form.Types - import Handler.Utils.Pandoc - import Handler.Utils.DateTime - import Handler.Utils.I18n - import Handler.Utils.Files - import Handler.Utils.Exam +import Handler.Utils.Memcached import Utils.Term @@ -44,6 +41,8 @@ import qualified Data.Conduit.List as C (mapMaybe, mapMaybeM) import qualified Database.Esqueleto.Legacy as E import qualified Database.Esqueleto.Utils as E import qualified Database.Esqueleto.Internal.Internal as E (SqlSelect) +import qualified Database.Esqueleto.Experimental as Ex -- needs TypeApplications Lang-Pragma +import Database.Persist.Sql.Raw.QQ import qualified Data.Set as Set import qualified Data.Sequence as Seq @@ -141,6 +140,16 @@ instance Button UniWorX ButtonConfirm where --confirmButton :: (Button (HandlerSite m) ButtonConfirm, MonadHandler m) => AForm m () --confirmButton = combinedButtonFieldF_ (Proxy @ButtonConfirm) "" +data ButtonPerform = BtnPerform + deriving (Enum, Eq, Ord, Bounded, Read, Show, Generic) +instance Universe ButtonPerform +instance Finite ButtonPerform + +nullaryPathPiece ''ButtonPerform $ camelToPathPiece' 1 + +embedRenderMessage ''UniWorX ''ButtonPerform id +instance Button UniWorX ButtonPerform where + btnClasses BtnPerform = [BCIsButton, BCPrimary] data ButtonRegister = BtnRegister | BtnDeregister @@ -183,7 +192,7 @@ instance Button UniWorX ButtonSubmitDelete where nullaryPathPiece ''ButtonSubmitDelete $ camelToPathPiece' 1 . dropSuffix "'" --- | Looks like a button, but is just a link (e.g. for create course, etc.) +-- | Looks like a button, but is just a link (e.g. for create course, etc.), aka btnLink or linkBtn linkButton :: Widget -- ^ Widget to display if unauthorized -> Widget -- ^ Button label -> [ButtonClass UniWorX] @@ -672,8 +681,8 @@ uploadModeForm fs prev = multiActionA actions fs (classifyUploadMode <$> prev) actions = Map.fromList [ ( UploadModeNone, pure NoUpload) , ( UploadModeAny - , UploadAny - <$> (fromMaybe False <$> aopt checkBoxField (fslI MsgUploadModeUnpackZips & setTooltip MsgUploadModeUnpackZipsTip) (Just $ prev ^? _Just . _uploadUnpackZips)) + , (UploadAny . fromMaybe False + <$> aopt checkBoxField (fslI MsgUploadModeUnpackZips & setTooltip MsgUploadModeUnpackZipsTip) (Just $ prev ^? _Just . _uploadUnpackZips)) <*> aopt extensionRestrictionField (fslI MsgUploadModeExtensionRestriction & setTooltip MsgUploadModeExtensionRestrictionTip) ((prev ^? _Just . _uploadExtensionRestriction) <|> fmap Just defaultExtensionRestriction) <*> apopt checkBoxField (fslI MsgUploadAnyEmptyOk & setTooltip MsgUploadAnyEmptyOkTip) (preview (_Just . _uploadEmptyOk) prev <|> Just False) ) @@ -795,8 +804,8 @@ examBonusRuleForm prev = multiActionA actions (fslI MsgUtilExamBonusRule) $ clas actions :: Map ExamBonusRule' (AForm Handler ExamBonusRule) actions = Map.fromList [ ( ExamBonusManual' - , ExamBonusManual - <$> (fromMaybe False <$> aopt checkBoxField (fslI MsgExamBonusOnlyPassed) (Just <$> preview _bonusOnlyPassed =<< prev)) + , ExamBonusManual . + fromMaybe False <$> aopt checkBoxField (fslI MsgExamBonusOnlyPassed) (Just <$> preview _bonusOnlyPassed =<< prev) ) , ( ExamBonusPoints' , ExamBonusPoints @@ -1418,6 +1427,35 @@ utcTimeField = checkMMap (return . localTimeToUTC') utcToLocalTime localTimeFiel LTUNone{} -> Left MsgIllDefinedUTCTime LTUAmbiguous{} -> Left MsgAmbiguousUTCTime +-- | Field for entering calendarDiffDays, result (0, 0) is accepted here +calendarDiffDaysField :: (MonadHandler m, HandlerSite m ~ UniWorX) => Field m CalendarDiffDays +calendarDiffDaysField = Field + { fieldParse = parseDD + , fieldEnctype = UrlEncoded + , fieldView = \theId name attrs val isReq -> do + let (vmon, vday) = showDD val + [whamlet| + $newline never + + _{MsgSomeMonths} # + + _{MsgSomeDays} + |] + } + where + showDD :: Either Text CalendarDiffDays -> (Text,Text) + showDD (Left t) = (mempty, t) -- show error message only once, on day field + showDD (Right CalendarDiffDays{..}) = (tshow cdMonths, tshow cdDays) + + parseDD [tmon, tday] _ + | Just nmon <- readMay tmon + , Just nday <- readMay tday + = return $ Right $ Just $ CalendarDiffDays { cdMonths=nmon, cdDays=nday} + parseDD ["",""] _ = return $ Right Nothing + parseDD [""] _ = return $ Right Nothing + parseDD [] _ = return $ Right Nothing + parseDD pl _ = return $ Left $ SomeMessage $ "Parsing CalendarDiffDays failed: " <> tshow pl -- TODO: better error message + langField :: Bool -- ^ Only allow values from `appLanguages` -> Field Handler Lang @@ -1596,6 +1634,28 @@ optionsPersistCryptoId filts ords toDisplay = do ents <- runDB $ selectList filts ords optionsCryptoIdF ents (return . entityKey) (return . toDisplay . entityVal) +mkOptionText :: E.Value Text -> Option Text +mkOptionText (E.unValue -> t) = Option{ optionDisplay = t, optionInternalValue = t, optionExternalValue = toPathPiece t } + +mkOptionListText :: [E.Value Text] -> OptionList Text +mkOptionListText = mkOptionList . fmap mkOptionText + +data OptionListCacheable a = OptionListCacheable [Option a] (Map Text a) +deriving instance (Show a) => Show (OptionListCacheable a) +deriving instance Generic (OptionListCacheable Text) +deriving instance Binary (OptionListCacheable Text) +deriving instance Generic (OptionListCacheable Textarea) +deriving instance Binary (OptionListCacheable Textarea) + +mkOptionListCacheable :: [Option a] -> OptionListCacheable a +mkOptionListCacheable ol = OptionListCacheable ol $ Map.fromList $ map (optionExternalValue &&& optionInternalValue) ol + +mkOptionListFromCacheable :: OptionListCacheable a -> OptionList a +mkOptionListFromCacheable (OptionListCacheable ol om) = OptionList + { olOptions = ol + , olReadExternal = flip Map.lookup om + } + mkOptionsE :: forall a r b msg. ( RenderMessage UniWorX msg , E.SqlSelect a r @@ -1607,7 +1667,7 @@ mkOptionsE :: forall a r b msg. -> YesodDB UniWorX (OptionList b) mkOptionsE query toExternal toDisplay toInternal = do mr <- getMessageRender - let toOption x = Option <$> (mr <$> toDisplay x) <*> toInternal x <*> toExternal x + let toOption x = (Option . mr <$> toDisplay x) <*> toInternal x <*> toExternal x fmap (mkOptionList . toList) . runConduit $ E.selectSource query .| C.mapM toOption .| C.foldMap Seq.singleton @@ -1720,6 +1780,8 @@ multiUserInvitationField mode _{MsgMultiUserFieldInvitationExplanation} |] + +-- | Field for entering multiple users by email, matriculation or personnel number. Unknown valid emails are also accepted, e.g. for sending invitations multiUserField :: forall m. ( MonadHandler m , HandlerSite m ~ UniWorX @@ -1727,90 +1789,21 @@ multiUserField :: forall m. => Bool -- ^ Only resolve suggested users? -> Maybe (E.SqlQuery (E.SqlExpr (Entity User))) -- ^ Suggested users -> Field m (Set (Either UserEmail UserId)) -multiUserField onlySuggested suggestions = Field{..} +multiUserField = userFieldAux procEmails wrapUid mergeRes where - lookupExpr - | onlySuggested = suggestions - | otherwise = Just $ E.from return + procEmails :: (UserId -> WidgetFor UniWorX Text) -> Set (Either UserEmail UserId) -> WidgetFor UniWorX Text + procEmails f vs = Text.intercalate ", " <$> forM (Set.toList vs) (procEmail f) - fieldEnctype = UrlEncoded - fieldView theId name attrs val isReq = do - val' <- case val of - Left t -> return t - Right vs -> Text.intercalate ", " . map CI.original <$> do - let (emails, uids) = partitionEithers $ Set.toList vs - rEmails <- case lookupExpr of - Nothing -> return [] - Just lookupExpr' -> fmap concat . forM uids $ \uid -> do - dbRes <- liftHandler . runDB . E.select $ do - user <- lookupExpr' - E.where_ $ user E.^. UserId E.==. E.val uid - return $ user E.^. UserEmail - case dbRes of - [E.Value email] -> return [email] - _other -> return [] - return $ emails ++ rEmails + procEmail _ (Left email) = return $ CI.original email + procEmail f (Right uid ) = f uid - datalistId <- maybe (return $ error "Not to be used") (const newIdent) suggestions + wrapUid (Right uid) = return $ Just $ Right uid + wrapUid (Left email) = return $ Just $ Left email - [whamlet| - $newline never - - |] - - whenIsJust suggestions $ \suggestions' -> do - suggestedEmails <- fmap (Map.assocs . Map.fromListWith min . map (over _2 E.unValue . over _1 E.unValue)) . liftHandler . runDB . E.select $ do - user <- suggestions' - return ( E.case_ - [ E.when_ (unique UserDisplayEmail user) - E.then_ (user E.^. UserDisplayEmail) - , E.when_ (unique UserEmail user) - E.then_ (user E.^. UserEmail) - ] - ( E.else_ $ user E.^. UserIdent) - , user E.^. UserDisplayName - ) - [whamlet| - $newline never - - $forall (email, dName) <- suggestedEmails -