Compare commits

...
Author SHA1 Message Date
Johann Hoffmann aabec8d6fd adapt Mobilitaet_model.php formatZusatz for the new int/ext naming convention. 2026-09-23 16:04:29 +02:00
Johann Hoffmann 0d82753833 Merge remote-tracking branch 'origin/master' into feature-60873/GesamtnoteneingabeCis4
# Conflicts:
#	system/phrasesupdate.php
2026-09-23 12:05:11 +02:00
Johann Hoffmann a9b9cd59b0 noten api cleanup to enforce naming consistency; client really just reads server decisions to avoid duplicity as much as possible; avoided constant config fallbacks since high configurability is kind of the point of the last changes; fixed a few small bugs with freigabe frist config checking, saveLvNote, Antritt Dategap rules on edit, moved certain ui configs also to server; splitup of Benotungstool component; test suite additions; updated readme_local.txt and readme_noten.txt; WIP: decide which config setup we actually like to use ;) 2026-09-23 11:45:01 +02:00
Johann Hoffmann c1939d0e23 hint after a passed Antritt; split Cypress setup into global, suite and local layers; if configured a pass closes the Antritt chain: the next exam cell shows "Positiv abgeschlossen" with a tooltip, and an add attempt shows pruefungNachBestandenerNote; 8 new specs with 55 tests: kettenregeln, terminregeln, scope, rollenmatrix, antrittrollen, nebenlaeufigkeit, UI ablehnung and navigation; WIP documenting testsuite caveats in readme files; WIP streamlining layers and removing redundant code/fallbacks; 2026-09-21 16:59:31 +02:00
Johann Hoffmann 73c63c791d seeder consolidation into branch naming convention; consolidated helper functions; fixed maxAntrittCount & brauchtNeueLVNote helpers in notenRules outside the cypress suite; more careful usage of db connection pools; cypress code cleanup & lint; WIP seperating branch specific configs/env setup from global cypress settings & ongoing test refinement; 2026-09-16 13:56:11 +02:00
Johann Hoffmann 7c9bcb6fd2 Merge remote-tracking branch 'origin/master' into feature-60873/GesamtnoteneingabeCis4
# Conflicts:
#	cis/private/lehre/notenliste.xls.php
#	public/js/api/factory/studiensemester.js
#	system/phrasesupdate.php
2026-09-15 14:44:45 +02:00
Johann Hoffmann 4399857f4a WIP 2026-09-15 10:59:09 +02:00
Johann Hoffmann 3212800643 started extracting every meaningful/realistic decision in the grading workflow into configs; groups: exam date guard logic, grades/pruefungchain interactions, deadlines, role configuration & permission configurability, freigabe, import & display; lots of code bugfixes & general cleanup effort; WIP testing and finding bugs i introduced with this new & large configuration scheme; 2026-09-11 15:19:39 +02:00
Johann Hoffmann 6d2cbf4896 adapt import to also parse note.anmerkung (entschuldigt - en, nicht beurteilt - nb, etc) if configured; new config if the benotungstool is allowed to create kommissionelle prüfungen or if that is a job for assistenz in stv; coded a kommPruef as the last pruefungsattempt and not a special case so it follows the same ordering rules in the table; changed headers from "Antritte" to "Prüfungstermine" to keep the wording aligned with the domain objects definitions; tooltip explaining the current antritt while hovering over the badge (1,2,3-K) (maybe make the badges contents configurable?); changed the notenOption sort to resemble stv: order 1-5 by value and afterwards alphabetically; actually removed redundant configs for defining which noten count for antritte -> only go by bezeichnung and fetch the matching primary keys from each db as they can have mixed pk's; vorschlag übernehmen now upserts the first prüfungsattempt, aswell as the notenfreigabe(if no other pruefungen exist) which should fix the possibility of creating a pruefungseries that starts with termin2, vorschlag also carries a datepicker writing the date of that first prüfung but not the benotungsdatum -> that needs to stay the entry date since the freigabemechanismus hinges on it; added a flag handling the password inputs browser prompt to not appear on other inputs; WIP coming up with even more configurability in order to cover uncertain workflow this tool is supposed to represent; 2026-09-10 13:04:11 +02:00
Johann Hoffmann 97be497fda refactored the core pruefungslogic to revolve around PruefungsverlaufLib.php instead of tbl_pruefungstyp which was highly inflexible for any adjustments. could be reused in Stv/Pruefungen aswell since there invalid inserts are very much possible; typen are still being used but are irrelevant for sequencing the attempts correctly; incorporated kommissionelle Pruefung into Gesamtnoteneingabe as the last possible antritt a student makes; updated cypress suites accordingly and added some ui tests aswell; documented usage in a readme_benotungstool.txt for the suite; 2026-08-11 17:35:06 +02:00
Johann Hoffmann 3c9f46941c Merge remote-tracking branch 'origin/master' into feature-60873/GesamtnoteneingabeCis4 2026-08-10 07:48:11 +02:00
Johann Hoffmann c9c92f82d3 WIP refactor 2026-08-06 10:33:46 +02:00
Johann Hoffmann 56fcb23275 WIP 2026-08-04 17:40:07 +02:00
Johann Hoffmann dba199b531 Merge remote-tracking branch 'origin/master' into feature-60873/GesamtnoteneingabeCis4 2026-08-04 15:31:19 +02:00
Johann Hoffmann 193dd166c3 finalizing current state of benotungstool api testsuite before touching implementation again; access suite and pruefungstermin suite have a few known failing tests, rest should run fine on a freshly resetted database! reset with 'php system/setup_testinstance.php --setup' for wrapped db setup routine; 2026-08-04 14:05:17 +02:00
Johann Hoffmann 431e0bb341 extended noten testsuite to cover more cases; added seedPruefung task to achieve difficult/impossible states for noten Api alone like kommPruef inserts or some entschuldigt/termin2 setups; WIP getting to test lkt_ueberschreibbar on zeugnisnote (needs updated seeders); WIP testing some logic/rules that only live in js frontend; 2026-07-31 13:32:47 +02:00
Johann Hoffmann beb54c1bc5 Add Cypress API test suite for the Gesamtnoteneingabe. Tests the grade-entry rules against /api/frontend/v1/Noten, where they are enforced
server-side.

Generic and reusable by the next suite: setup_testinstance.php (--setup/--verify,
delegating the rebuild to paolos db_setup.sh), tasks/db.js, tasks/sshTunnel.js,
tools/dbCheck.js and cypress.config.js carry no suite-specific knowledge. A suite
adds tools/checks/<name>.js, suites/<name>.js and its own task file.

Fixture state resets over a direct Postgres connection through an SSH tunnel the
runner opens itself.

Tests: 37 passing, 5 pending, 1 failing. Pending are opt-in (Freigabe sends
real mail; the Frist config is off). The failing spec is left red on purpose: it
documents that getLvGesamtNoten filters freigabedatum < NOW(), so the read path
hides un-freigegebene notes while the write path uses the unfiltered getter. Not really certain if that is a feature or a bug;

Working on more actual testsuites now that the setup is running as intended;
2026-07-30 17:18:51 +02:00
Johann Hoffmann 4e6a2d5218 Merge branch 'master' into feature-60873/GesamtnoteneingabeCis4
# Conflicts:
#	application/config/routes.php
#	application/controllers/api/frontend/v1/Lehre.php
#	application/views/CisRouterView/CisRouterView.php
#	cis/private/lehre/notenliste.xls.php
#	public/js/api/factory/lehre.js
#	public/js/apps/Cis/Cis.js
#	public/js/components/Cis/Abgabetool/AbgabetoolAssistenz.js
#	public/js/helpers/StringHelpers.js
#	system/phrasesupdate.php
2026-07-27 15:41:29 +02:00
Johann Hoffmann 273f00a9c4 added accept/cancel prompt to Notenvorschlag übernehmen spelling out the current benotungsdatum to the user; also added a list over all changedNoten which are about to be freigegeben in the Notenfreigabe Modal before entering a password; fixed/added some phrasen; 2026-07-24 13:52:05 +02:00
Johann Hoffmann 32de4aac6b assistenz recht & features gesamtnoteneingabe; load stg array for assistenzen to choose any lva from; added configurable datelimit for each semester with defaults as per upcoming prüfungsordnung; 2026-07-23 16:59:40 +02:00
Johann Hoffmann 8bf18b0d37 prüfung/notenimport enabled per config + adapt notenliste.xls.php highlighting as per config; check if higher antritt prüfungen exist when editing existing ones (changing grades is not allowed, dates inbetween the other 2 antritt termine allowed); prüfungstermine with entschuldigt note are being preserved since the whole series can have 1max; frozen columns selection dropdown + 4 columns freezable (selector, uid, vorname, nachname) -> setting saved in local storage; better mirroring of new pruefung studenten dropdown with actual table sort + update the label there to be uid + name aswell; fixed the note_vorschlag select input cell; table sort doesnt clear selection; show the NotenlisteLinks.js in import modal and filter it based on LE selection; 2026-06-26 12:52:18 +02:00
Johann Hoffmann 79c0f31ddc lv_id & sem_kurzbz are now optional route params; defer loading of LehreinheitenModule.js; fetch orgform for lva dropdown; added selected|filtered|total count to table; dropdowns have responsive labels now; left aligned columns with text; AuthInfo Api for uid; tableLayout without WidthGrow, fixed rowHeight at 30 for now; selectAll + filter interaction fixed; load studiensemester similar to abgabetool; clearFilter fix on LE deselect; modal formatting & loading toggle; 2026-06-23 18:59:51 +02:00
Johann Hoffmann 49d61342c2 lv_id & sem_kurzbz are now optional route params; defer loading of LehreinheitenModule.js; fetch orgform for lva dropdown; added selected|filtered|total count to table; dropdowns have responsive labels now; left aligned columns with text; AuthInfo Api for uid; tableLayout without WidthGrow, fixed rowHeight at 30 for now; selectAll + filter interaction fixed; load studiensemester similar to abgabetool; clearFilter fix on LE deselect; modal formatting & loading toggle; 2026-06-23 18:59:36 +02:00
Johann Hoffmann be0943f1ba Merge remote-tracking branch 'origin/master' into feature-60873/GesamtnoteneingabeCis4 2026-06-22 18:12:29 +02:00
Harald Bamberger c56064d189 js import add missing file extension 2026-05-06 18:36:05 +02:00
Harald Bamberger f6747713a1 Merge branch 'master' into feature-60873/GesamtnoteneingabeCis4 2026-04-20 11:13:14 +02:00
Johann Hoffmann 5a6d20f817 fixed scrollOffset discrepancy issue by UNDEFINING rowHeight and thus let tabulator detect the actual size of a row itself and not trust a sligthly wrong rowHeight value; define centered formatter in js/tabulator/formatter/centered and use it on unformatted columns for consistency; 2026-03-05 15:47:19 +01:00
Johann Hoffmann ce5da22180 import error msg phrase; config for noten which dount count towards prüfungsantritt & load & use that; 2026-03-03 16:47:17 +01:00
Johann Hoffmann 05b2c3c42b persistence of order/width/visibility of notenTable predefined columns before applying dynamic pruefung columns in setupData and other table col manipulating routines; fix antrittCalculation reactivity and resulting selection eligibility and the reactivity of that also; added custom sorter for pruefung cols; some minor UX tweaks; 2026-03-03 12:05:05 +01:00
Johann Hoffmann 2dd732b924 Merge branch 'master' into feature-60873/GesamtnoteneingabeCis4
# Conflicts:
#	application/views/CisRouterView/CisRouterView.php
2026-02-27 10:38:24 +01:00
Johann Hoffmann 110f73e622 WIP notentool table persistence with dyn cols 2026-02-17 14:09:57 +01:00
Johann Hoffmann bb0d118284 Pruefung Termin1/original Note column fixed logic, variable column creation reserved for termin2/termin3 mixtures; remove redundant termintypen from student row in table if backend sent a succesful update response since their cant be multiple termin2/termin3; block notenvorschlag übernehmen once a pruefung exists; 2026-02-13 12:25:55 +01:00
Johann Hoffmann 701ccadff3 approveGrades phrase with counter; computed maxAntritte by config settings; block illegal pruefung creation in frontend (backend was already working); 2026-02-12 13:56:56 +01:00
Johann Hoffmann 34555504df Notenfreigabe email template insert in dbupdate3.4; notenvorschlag übernehmen cellHandler adjustment; WIP testing pruefungen & pruefungs config 2026-02-11 10:20:53 +01:00
Johann Hoffmann d1f5220925 notenschluessel availability check; WIP lvgesamtnote model method that actually fetches the notenvorschlag without join over zeugnisnote; 2026-02-10 12:57:38 +01:00
Johann Hoffmann decd514b22 WIP improving notenimport with punktefeature 2026-02-09 09:50:22 +01:00
Johann Hoffmann 6cf7093293 testing/implementing more config flags; dont select on certain cols; certain cols only available with certain flags; 2026-02-05 16:49:26 +01:00
Johann Hoffmann 17f11fa871 Merge remote-tracking branch 'origin/master' into feature-60873/GesamtnoteneingabeCis4
# Conflicts:
#	application/controllers/api/frontend/v1/Lehre.php
#	application/models/education/Lehrveranstaltung_model.php
#	application/models/education/Note_model.php
#	application/views/CisRouterView/CisRouterView.php
#	public/js/api/factory/studiensemester.js
#	public/js/components/Bootstrap/Offcanvas.js
#	public/js/components/Overlay/FhcOverlay.js
2026-02-04 15:40:34 +01:00
Johann Hoffmann 96812868a4 WIP 2026-02-04 10:31:20 +01:00
Johann Hoffmann 40c79158f7 punkte feature basically finished; WIP testing & import rewrite 2026-02-03 17:38:33 +01:00
Johann Hoffmann 054663ee00 WIP punkte 2026-02-03 11:34:59 +01:00
Johann Hoffmann 81eee814e9 yellow dropdown styling only on editable tabulator colums for note_vorschlag; fetch note for punkte for notenvorschlag and pruefungsnote if certain config is set; added debounce helper file/function; WIP persisting punkte in backend 2026-02-02 17:07:16 +01:00
Johann Hoffmann 390a3c0d5a Notenschluessel Model + WIP making sense of legacy config flags 2026-01-29 14:50:37 +01:00
Johann Hoffmann 695dd655c0 WIP implementing getNotenvorschlagStudent, currently only works for whole lva/sem 2026-01-26 14:47:21 +01:00
Johann Hoffmann 957da460a6 shorter passwort freigabe text; loadCisConfig for Benotungstool via api; anw% in notentable via event; WIP incorporating CIS config into actual noten logic; 2025-12-18 15:24:08 +01:00
Johann Hoffmann 2cee36d7b5 try/catch around moodle event & proper error message 2025-12-17 14:29:05 +01:00
Johann Hoffmann 43925e3088 custom sticky css; offcanvas mobility legende; mobiltiy zusatz in seperate column; added getMobilityZusatzForUids & formatZusatz similar to digital anw mobility zusaetze; 2025-11-25 17:23:49 +01:00
Johann Hoffmann 1c236cce02 Merge branch 'master' into feature-60873/GesamtnoteneingabeCis4
# Conflicts:
#	application/config/routes.php
#	application/models/crm/Prestudent_model.php
#	application/models/education/Lehreinheit_model.php
#	application/models/education/Lehrveranstaltung_model.php
#	public/js/apps/Dashboard/Fhc.js
#	system/phrasesupdate.php
2025-11-25 10:50:26 +01:00
Johann Hoffmann 4956a517ca loading overlay, notenfreigabe available phrase with counter; placeholder phrasen & date format; WIP handling moodle API errors well 2025-11-25 10:31:52 +01:00
Johann Hoffmann a6167583a3 hinweistexte import/freigabe; distinct css for editable table cols notenvorschlag & freigabe; trigger 'getEntschuldigungsStatusForStudentOnDate' event when saving a pruefungstermin -> if akzeptierte entschuldigung is found for student on pruefungsdate it is automatically set to entschuldigt; fix event unmount lifecycle; 2025-10-07 16:26:58 +02:00
Johann Hoffmann 1e68eb0b90 berechtigungsprüfung 'lehre/benotungstool:rw' in Noten Controller; API Method documentation; removed addMeta statements; removed Tabulator Event logging; 2025-08-21 13:35:38 +02:00
Johann Hoffmann 332efd4106 selection fixes; phrases; preserve scroll after redraw when pressing action buttons on far right; take nav offset into account for width calculation; 2025-08-20 16:25:21 +02:00
Johann Hoffmann f303191c54 alternative email per cis global config 2025-08-19 16:33:12 +02:00
Johann Hoffmann d6c7f16ceb Merge remote-tracking branch 'origin/master' into feature-60873/GesamtnoteneingabeCis4 2025-08-19 13:43:23 +02:00
Johann Hoffmann f1912fe739 custom selection handling due to bugged tab5 rowSelect, should work as intended now, WIP illegal emails 2025-08-19 13:32:18 +02:00
Johann Hoffmann 2f7fe05d21 mobility legende; TopCalc Row (sum, negative, prueflinge); fix row selection issues; 2025-08-18 11:25:38 +02:00
Johann Hoffmann ee4b61f549 recommit branch; event naming changes; 2025-08-18 09:13:14 +02:00
Johann Hoffmann 511a4256bc moved LE loading and infoString setup into LehreinheitenModule, which can be bound to an instance of Primevue3 Dropdown via v-bind. WIP modularizing other common selections like LVA & Semester Kurzbz; 2025-08-08 13:35:01 +02:00
Johann Hoffmann 3c9db86df2 Merge remote-tracking branch 'origin/master' into feature-60873/GesamtnoteneingabeCis4
# Conflicts:
#	application/config/routes.php
#	application/controllers/api/frontend/v1/Lehre.php
#	application/models/education/Lehrveranstaltung_model.php
#	application/views/CisRouterView/CisRouterView.php
#	public/js/apps/Dashboard/Fhc.js
#	system/phrasesupdate.php
2025-08-07 15:17:10 +02:00
Johann Hoffmann 367204a1ee removed legacy classes (except mobility) and moved crud functionality to LePruefungModel, LVgesamtnoteModel & LehrveranstaltungModel; 2025-08-07 14:54:41 +02:00
Johann Hoffmann bbe55a75ea noten/pruefungen import; import validation for nr of antritte and date/antritt chronological order; 2025-08-04 14:27:33 +02:00
Johann Hoffmann e58bf3a8cf WIP noten/pruefung import 2025-08-01 09:48:46 +02:00
Johann Hoffmann 1f2f866c61 positiv/negativ/unbenotet filter; reload lva/le correctly on sem/lva dropdown selection change; 2025-07-30 17:10:43 +02:00
Johann Hoffmann 6ccbc95697 createPruefung entry with "noch nicht eingetragen" note as default for a selection of students at given date; filterHeaders in noten cols; antrittCount col; pruefungsformatter with antritt highlighting; 2025-07-29 17:32:07 +02:00
Johann Hoffmann 52d9e0a195 pruefungen columns pro datum nicht pro termintyp; row Selection & modal newPruefungForSelectedStudents mit linked multiselect dropdown; WIP antritte berechnen 2025-07-25 12:50:51 +02:00
Johann Hoffmann 6a3982347b teilnoten/punkte berechnen für notenvorschläge; vorschläge übernehmen; noten freigabe mit passwort; prüfungen generisch anzeigen nach datum gruppiert; prüfungen anlegen/bearbeiten auf mapping termin1/2/3 möglich. 2025-07-24 17:02:57 +02:00
Johann Hoffmann fe7feeb74e Merge remote-tracking branch 'origin/master' into feature-60873/GesamtnoteneingabeCis4 2025-07-15 09:51:11 +02:00
Johann Hoffmann 658fe79ad7 load Teilnoten via moodle Event trigger; WIP further noten/punkte logic; 2025-05-12 16:45:33 +02:00
Johann Hoffmann 5bbf05ac8a WIP Gesamtnoteneingabe Notenberechnung endpoint 2025-05-12 12:50:56 +02:00
91 changed files with 19194 additions and 71 deletions
+216
View File
@@ -0,0 +1,216 @@
<?php
if (!defined('BASEPATH')) exit('No direct script access allowed');
// The configuration of the Benotungstool. The tool reads two sources:
// this file $this->config->item('KEY')
// global.config.inc.php define() flags of the old tool: CIS_GESAMTNOTE_PUNKTE, CIS_GESAMTNOTE_GEWICHTUNG,
// CIS_GESAMTNOTE_FREIGABEMAIL_NOTE, CIS_GESAMTNOTE_PRUEFUNG_MOODLE_LE_NOTE and
// CIS_GESAMTNOTE_PRUEFUNG_TERMIN2/TERMIN3/KOMMPRUEF (see CIS_GESAMTNOTE_MAX_ANTRITTE)
//
// The prefixes in this file:
// NOTEN_, NOTE_ name a Note by its Bezeichnung. The server finds its key in lehre.tbl_note.
// PRUEFUNG_ name a Pruefungstyp from lehre.tbl_pruefungstyp.
// CIS_GESAMTNOTE_ the switches and values of the tool.
//
// Every key must be present. The code has no default values.
// --- Noten ---------------------------------------------------------------------------------------
// Noten that use no Antritt.
$config['NOTEN_OHNE_ANTRITT_BEZEICHNUNGEN'] = ['Noch nicht eingetragen', 'entschuldigt'];
// Anrechnung Noten: an earlier result counts for this LV. As a Zeugnisnote they block every Pruefung.
$config['NOTEN_ANRECHNUNG_BEZEICHNUNGEN'] = ['angerechnet', 'intern angerechnet'];
// How often a Note may occur in the Pruefungen of one student.
$config['NOTEN_OCCURRENCE_LIMIT_MAP'] = ['entschuldigt' => 1];
// The Note 'entschuldigt'. An addon can report a Pruefung date as entschuldigt.
$config['NOTE_ENTSCHULDIGT_BEZEICHNUNG'] = 'entschuldigt';
// The Note of a Pruefung without a result.
$config['NOTE_NICHT_EINGETRAGEN_BEZEICHNUNG'] = 'Noch nicht eingetragen';
// Noten with no better one. They always close the Antritt chain.
$config['NOTEN_ABSCHLIESSEND_BEZEICHNUNGEN'] = ['Sehr Gut', 'Bestanden', 'Approbiert', 'Erfolgreich absolviert'];
// The order of the Noten, best first. tbl_note.notenwert is NULL everywhere, so the order cannot come
// from the database. A Note outside this list is not comparable.
$config['NOTEN_RANGFOLGE_BEZEICHNUNGEN'] = ['Sehr Gut', 'Gut', 'Befriedigend', 'Genügend', 'Nicht Genügend'];
// The order of the Noten in the lists: 'skala' = 1-5 first, then alphabetical; 'bezeichnung' = alphabetical.
$config['NOTEN_SORTIERUNG'] = 'skala';
// --- Antritte -------------------------------------------------------------------------------------
// The maximum number of Antritte, Antritt 1 and the kommissionelle Pruefung included.
// null = 1 + CIS_GESAMTNOTE_PRUEFUNG_TERMIN2 + CIS_GESAMTNOTE_PRUEFUNG_TERMIN3 + CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF.
// The Pruefungsordnung permits three Antritte: TERMIN2 and KOMMPRUEF give 3, TERMIN3 adds one more.
$config['CIS_GESAMTNOTE_MAX_ANTRITTE'] = null;
// The Antritt from which the Pruefung is kommissionell:
// 'letzter' the last Antritt (CIS_GESAMTNOTE_MAX_ANTRITTE)
// 0 never
// 3 from Antritt 3 on
// A chain of one Antritt is never kommissionell.
$config['CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT'] = 'letzter';
// The Benotungstool may create the kommissionelle Pruefung. Some installations enter it in the StV.
// false: the tool shows an existing kommissionelle Pruefung, but it creates none. The chain then ends
// one Antritt earlier for this tool.
$config['CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF'] = true;
// Allow another Antritt after a positive Note. It uses an Antritt like any repeat.
$config['CIS_GESAMTNOTE_NOTENVERBESSERUNG'] = false;
// A worse repeat keeps the better LV-Note. The Pruefung keeps its own Note.
$config['CIS_GESAMTNOTE_VERBESSERUNG_BESSERE_GEWINNT'] = false;
// Create Antritt 1 as its own Pruefung when a Note becomes the LV-Note: the proposal, the import, the
// Freigabe, and the Notenuebernahme in the StV.
$config['CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME'] = true;
// --- Pruefungstypen ---------------------------------------------------------------------------------
// The tool derives the position of a Pruefung from its date. The type marks a kommissionelle Pruefung,
// sorts old Pruefungen without a date, separates Antritt 1 from a repeat (verlauf.hasRepeat), and feeds
// the old reports of the StV.
// The Pruefungstypen of a kommissionelle Pruefung.
$config['PRUEFUNG_KOMMISSIONELL_TYPEN'] = ['kommPruef', 'zusKommPruef'];
// The Pruefungstyp that the tool writes for a kommissionelle Pruefung.
$config['PRUEFUNG_TYP_KOMMISSIONELL'] = 'kommPruef';
// Pruefungstypen that never use an Antritt. A zusKommPruef repeats a kommissionelle Pruefung with a
// procedural fault. The StV enters it.
$config['PRUEFUNG_TYPEN_OHNE_ANTRITT'] = ['zusKommPruef'];
// The Pruefungstyp that the tool writes for each Antritt. A type that tbl_pruefungstyp does not have
// is skipped (Termin3 is absent by default).
$config['PRUEFUNG_TYP_JE_ANTRITT'] = [1 => 'Termin1', 2 => 'Termin2', 3 => 'Termin3'];
// --- Pruefung dates -------------------------------------------------------------------------------
// Allow a new Pruefung on the day of an existing one.
$config['CIS_GESAMTNOTE_PRUEFUNG_GLEICHER_TAG'] = false;
// A later Pruefung locks the Note of an earlier one. Its date stays editable.
$config['CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETERER_PRUEFUNG'] = true;
// Allow a Benotungsdatum in the future: the day of Antritt 1 when the LV-Note is written. A Pruefung
// may always lie in the future: without a Note it waits for its result.
$config['CIS_GESAMTNOTE_DATUM_ZUKUNFT'] = false;
// The minimum number of days between two Antritte. A Pruefung without an Antritt does not count.
// null = off
$config['CIS_GESAMTNOTE_ANTRITT_MIN_ABSTAND_TAGE'] = null;
// The maximum number of days between two Antritte. null = off
$config['CIS_GESAMTNOTE_ANTRITT_MAX_ABSTAND_TAGE'] = null;
// --- Frist ----------------------------------------------------------------------------------------
// The Frist (Pruefungsordnung §1) is a day in the year of the Studiensemester:
// Sommersemester SSyyyy -> the same year
// Wintersemester WSyyyy -> the next year
$config['NOTENEINTRAGUNGSFRIST_SS'] = ['month' => 11, 'day' => 15];
$config['NOTENEINTRAGUNGSFRIST_WS'] = ['month' => 5, 'day' => 15];
// No entry after the Frist.
$config['CIS_GESAMTNOTE_FRIST_EINGABE'] = true;
// No Pruefung date after the Frist.
// Example: a Pruefung must take place before the Frist, but the Note may come later -> EINGABE false.
$config['CIS_GESAMTNOTE_FRIST_PRUEFUNGSDATUM'] = true;
// Permissions that may enter after the Frist. The Pruefung date has no exception.
// Example: ['admin', 'lehre/benotungstool_assistenz']
$config['CIS_GESAMTNOTE_FRIST_AUSNAHME'] = [];
// --- Roles and Freigabe ---------------------------------------------------------------------------
// The actions of each permission. The keys are also the permissions that open the tool.
// lvnote write the LV-Note (the proposal)
// pruefung create or edit a Pruefung
// kommpruef create the kommissionelle Pruefung (also needs CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF)
// freigabe the Freigabe
// import the two imports
// A user with several permissions gets all their actions.
$config['CIS_GESAMTNOTE_ROLLENMATRIX'] = [
'lehre/benotungstool' => ['lvnote', 'pruefung', 'kommpruef', 'freigabe', 'import'],
'lehre/benotungstool_assistenz' => ['lvnote', 'pruefung', 'kommpruef', 'freigabe', 'import']
];
// A Lektor sees only the LVs that the Lektor teaches.
$config['CIS_GESAMTNOTE_LEKTOR_NUR_EIGENE_LV'] = true;
// The Freigabe asks for the password of the user.
$config['CIS_GESAMTNOTE_FREIGABE_PASSWORT'] = true;
// The Freigabe sends a mail.
$config['CIS_GESAMTNOTE_FREIGABEMAIL'] = true;
// The recipients of the mail: 'studiengang' = the addresses of the Studiengang, 'aufrufer' = the user.
// An entry with an '@' is a fixed address.
$config['CIS_GESAMTNOTE_FREIGABEMAIL_EMPFAENGER'] = ['studiengang', 'aufrufer'];
// The Sancho template of the mail. The text is in the database.
$config['CIS_GESAMTNOTE_FREIGABEMAIL_VORLAGE'] = 'Notenfreigabe';
// A freigegeben LV-Note is final. No path can change it.
$config['CIS_GESAMTNOTE_FREIGABE_FINAL'] = false;
// A new or changed Pruefung cancels the Freigabe of the LV-Note: it sets the benotungsdatum.
$config['CIS_GESAMTNOTE_PRUEFUNG_HEBT_FREIGABE_AUF'] = true;
// --- LV-Note and proposal -------------------------------------------------------------------------
// An LV-Note must be a Note of the Lehre (tbl_note.lehre).
$config['CIS_GESAMTNOTE_LVNOTE_NUR_LEHRENOTEN'] = true;
// The proposal stays open after a repeat. false: after a repeat the Lektor changes the LV-Note
// through the Pruefung.
$config['CIS_GESAMTNOTE_VORSCHLAG_NACH_WIEDERHOLUNG'] = false;
// The rounding of the Teilnoten average. The smaller number is the better Note:
// 'kaufmaennisch' 2.5 -> 3
// 'besser' 2.5 -> 2
// 'schlechter' 2.1 -> 3
$config['CIS_GESAMTNOTE_VORSCHLAG_RUNDUNG'] = 'kaufmaennisch';
// The decimals of the Punkte average before the Notenschluessel applies.
$config['CIS_GESAMTNOTE_VORSCHLAG_PUNKTE_STELLEN'] = 2;
// --- Import and display ---------------------------------------------------------------------------
// The two imports. With both on, the tool shows two buttons.
$config['CIS_GESAMTNOTE_PRUEFUNGSIMPORT'] = true; // one Pruefung per row: Kennung, Datum, Note
$config['CIS_GESAMTNOTE_NOTENIMPORT'] = false; // one LV-Note per row: Kennung, Note
// The Note column of an import accepts the key of lehre.tbl_note. true also accepts the short form
// from lehre.tbl_note.anmerkung ('nb', 'ea', 'en') of the Excel Notenliste.
$config['CIS_GESAMTNOTE_IMPORT_NOTENKUERZEL'] = false;
// The column order of an import. Allowed: 'kennung', 'datum', 'note'.
$config['CIS_GESAMTNOTE_IMPORT_SPALTEN_NOTEN'] = ['kennung', 'note'];
$config['CIS_GESAMTNOTE_IMPORT_SPALTEN_PRUEFUNG'] = ['kennung', 'datum', 'note'];
// The date format of an import: 'dd.MM.yyyy' or 'yyyy-MM-dd'.
$config['CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT'] = 'dd.MM.yyyy';
// An import stops at the first rejected row. false: it reports the row and writes the others.
$config['CIS_GESAMTNOTE_IMPORT_ABBRUCH'] = false;
// The Pruefung columns of the table. The user can switch the layout; the browser keeps the choice.
// 'antritt' one column per Antritt, the date is in the cell
// 'datum' one column per Pruefung date
$config['CIS_GESAMTNOTE_PRUEFUNGSSPALTEN'] = 'antritt';
// The badge in a Pruefung cell; {n} is the Antritt number.
$config['CIS_GESAMTNOTE_ANTRITT_ZEICHEN'] = [
'kommissionell' => '{n}-K',
'kommissionell_ohne_antritt' => 'K',
'antritt' => '{n}',
'ohne_antritt' => '–'
];
+1
View File
@@ -64,6 +64,7 @@ $route['api/v1/system/[S|s]prache/(:any)'] = 'api/v1/system/sprache2/$1';
$route['Cis/LvPlan/.*'] = 'Cis/LvPlan/index/$1';
$route['Cis/MyLvPlan/.*'] = 'Cis/MyLvPlan/index/$1';
$route['Cis/MyLv/.*'] = 'Cis/MyLv/index/$1';
$route['Cis/Benotungstool/.*'] = 'Cis/Benotungstool/index/$1';
$route['Cis/OtherLvPlan/.*'] = 'Cis/OtherLvPlan/index/$1';
//Route for LV Plan Stg/Semester/Verband/Gruppe
$route['Cis/StgOrgLvPlan/.*'] = 'Cis/StgOrgLvPlan/index/$1';
@@ -0,0 +1,35 @@
<?php
if (! defined('BASEPATH')) exit('No direct script access allowed');
// the constructor needs the permissions before CodeIgniter can load a helper
require_once APPPATH . 'helpers/hlp_benotungstool_helper.php';
/**
* The page of the Benotungstool. The same permissions open it as the Noten API.
*/
class Benotungstool extends Auth_Controller
{
public function __construct()
{
parent::__construct([
'index' => benotungstoolPermissions()
]);
}
// -----------------------------------------------------------------------------------------------------------------
// Public methods
/**
* @return void
*/
public function index()
{
$viewData = array(
'uid'=>getAuthUID(),
);
$this->load->view('CisRouterView/CisRouterView.php', ['viewData' => $viewData, 'route' => 'Benotungstool']);
}
}
@@ -18,18 +18,9 @@
if (! defined('BASEPATH')) exit('No direct script access allowed');
//require_once('../../../include/studiengang.class.php');
//require_once('../../../include/student.class.php');
//require_once('../../../include/datum.class.php');
//require_once('../../../include/mail.class.php');
//require_once('../../../include/benutzerberechtigung.class.php');
//require_once('../../../include/phrasen.class.php');
//require_once('../../../include/projektarbeit.class.php');
//require_once('../../../include/projektbetreuer.class.php');
class Lehre extends FHCAPI_Controller
{
/**
* Object initialization
*/
@@ -41,37 +32,53 @@ class Lehre extends FHCAPI_Controller
'Pruefungen' => self::PERM_LOGGED,
'semesterAverageGrade' => self::PERM_LOGGED,
]);
$this->load->library('PhrasesLib');
$this->loadPhrases(
array(
'global',
'ui',
'abgabetool'
)
);
$this->load->helper('hlp_sancho_helper');
require_once(FHCPATH . 'include/studiengang.class.php');
require_once(FHCPATH . 'include/student.class.php');
require_once(FHCPATH . 'include/projektarbeit.class.php');
require_once(FHCPATH . 'include/projektbetreuer.class.php');
}
//------------------------------------------------------------------------------------------------------------------
// Public methods
/**
/**
* constructs the emails of the groups from a lehrveranstaltung
*/
public function lvStudentenMail()
public function lvStudentenMail()
{
$lehreinheit_id = $this->input->get("lehreinheit_id",TRUE);
// return early if the required parameter is missing
if(!isset($lehreinheit_id))
{
$this->terminateWithError('Missing required parameter', self::ERROR_TYPE_GENERAL);
}
$lehreinheit_id = $this->input->get("lehreinheit_id",TRUE);
$this->load->model('education/Lehreinheit_model', 'LehreinheitModel');
$studentenMails = $this->LehreinheitModel->getStudentenMail($lehreinheit_id);
// return early if the required parameter is missing
if(!isset($lehreinheit_id))
{
$this->terminateWithError('Missing required parameter', self::ERROR_TYPE_GENERAL);
}
$studentenMails = $this->getDataOrTerminateWithError($studentenMails);
$this->load->model('education/Lehreinheit_model', 'LehreinheitModel');
$studentenMails = $this->LehreinheitModel->getStudentenMail($lehreinheit_id);
$studentenMails = $this->getDataOrTerminateWithError($studentenMails);
//convert array of objects into array of strings
$studentenMails = array_map(function($element){
return $element->mail;
}, $studentenMails);
$this->terminateWithSuccess($studentenMails);
$this->terminateWithSuccess($studentenMails);
}
public function LV($studiensemester_kurzbz, $lehrveranstaltung_id)
@@ -81,13 +88,13 @@ class Lehre extends FHCAPI_Controller
$result = $this->LehrveranstaltungModel->getLvsByStudentWithGrades(getAuthUID(), $studiensemester_kurzbz, getUserLanguage(), $lehrveranstaltung_id);
$result = current($this->getDataOrTerminateWithError($result));
$this->terminateWithSuccess($result);
}
/**
* fetches all Pruefungen of a student for a specific lehrveranstaltung
* if the student passed the Pruefung on the first attempt, no information about the Pruefungen is stored in the database
* if the student passed the Pruefung on the first attempt, no information about the Pruefungen is stored in the database
* @param mixed $lehrveranstaltung_id
* @return void
*/
@@ -145,5 +152,5 @@ class Lehre extends FHCAPI_Controller
$this->terminateWithSuccess(['average_grade' => $averageGrade, 'weighted_average_grade' => $weightedAverageGrade]);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -41,6 +41,9 @@ class Grades extends FHCAPI_Controller
// Load Libraries
$this->load->library('VariableLib', ['uid' => getAuthUID()]);
$this->load->library('PruefungsverlaufLib', null, 'VerlaufLib');
$this->load->config('noten');
// Load Phrases
$this->loadPhrases([
@@ -355,23 +358,24 @@ class Grades extends FHCAPI_Controller
$data['lehrveranstaltung_id'] = $lehrveranstaltung_id;
$data['student_uid'] = $student_uid;
$data['studiensemester_kurzbz'] = $studiensemester_kurzbz;
$this->ZeugnisnoteModel->insert($data);
if (defined('FAS_PRUEFUNG_BEI_NOTENEINGABE_ANLEGEN')
&& FAS_PRUEFUNG_BEI_NOTENEINGABE_ANLEGEN) {
$result = $this->addTestsForGrade(
$studiensemester_kurzbz,
$student_uid,
$lehrveranstaltung_id,
$teacherGrade->note,
$teacherGrade->punkte
);
$this->getDataOrTerminateWithError($result);
}
$this->ZeugnisnoteModel->insert($data);
}
// Der erste Antritt wird bereits bei der Übernahme als eigene Prüfung angelegt, damit der
// Prüfungsverlauf von Anfang an vollständig ist und das Benotungstool ihn nicht erst beim
// Anlegen einer Wiederholung nachtragen muss. Läuft bewusst in BEIDEN Zweigen (auch beim
// Überschreiben einer bestehenden Zeugnisnote) und ist idempotent.
if ($this->config->item('CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME')) {
$result = $this->addErstantrittForGrade(
$studiensemester_kurzbz,
$student_uid,
$lehrveranstaltung_id,
$teacherGrade
);
$this->getDataOrTerminateWithError($result);
}
$this->terminateWithSuccess(true);
}
@@ -491,20 +495,32 @@ class Grades extends FHCAPI_Controller
}
/**
* Helper function that adds tests for a student
* (Entries in lehre.tbl_pruefung)
* Legt bei der Notenübernahme den ersten Prüfungsantritt als eigenen Datensatz an
* (Eintrag in lehre.tbl_pruefung).
*
* @param string $studiensemester_kurzbz
* @param string $student_uid
* @param integer $lehrveranstaltung_id
* @param integer $note
* @param numeric $punkte
* Idempotent: existiert für den Studenten in dieser LV bereits ein Termin, passiert nichts -
* die Übernahme darf beliebig oft laufen, ohne den Verlauf zu verfälschen.
*
* Datiert auf das Freigabedatum der LV-Note: das ist der Zeitpunkt, zu dem die
* Leistungsfeststellung verbindlich wurde. Das Benotungsdatum ändert sich bei jeder Korrektur,
* der Übernahmezeitpunkt ist reine Verwaltungsarbeit - beide taugen nicht als Termindatum.
*
* @param string $studiensemester_kurzbz
* @param string $student_uid
* @param integer $lehrveranstaltung_id
* @param stdClass $teacherGrade die übernommene LV-Gesamtnote
*
* @return stdClass
*/
protected function addTestsForGrade($studiensemester_kurzbz, $student_uid, $lehrveranstaltung_id, $note, $punkte)
protected function addErstantrittForGrade($studiensemester_kurzbz, $student_uid, $lehrveranstaltung_id, $teacherGrade)
{
$this->load->model('education/Lehrveranstaltung_model', 'LehrveranstaltungModel');
$this->load->model('education/LePruefung_model', 'LePruefungModel');
// bereits ein Termin vorhanden -> der Verlauf beginnt schon, nichts nachzutragen
$vorhanden = $this->VerlaufLib->getPruefungen($student_uid, $lehrveranstaltung_id, $studiensemester_kurzbz);
if (count($vorhanden) > 0)
return success(null);
// Get Lehreinheit
$result = $this->LehrveranstaltungModel->getLeByStudent($student_uid, $studiensemester_kurzbz, $lehrveranstaltung_id);
@@ -515,19 +531,24 @@ class Grades extends FHCAPI_Controller
return error($this->p->t("stv", "grades_error_lehreinheit_id"));
$le = current(getData($result));
$datum = $teacherGrade->freigabedatum ?: ($teacherGrade->benotungsdatum ?: date('Y-m-d'));
$now = date('Y-m-d H:i:s');
// Prepare
$this->load->model('education/LePruefung_model', 'LePruefungModel');
$data = [
"student_uid" => $student_uid,
"lehreinheit_id" => $le->lehreinheit_id,
"datum" => date('Y-m-d'),
"pruefungstyp_kurzbz" => "Termin1",
"note" => $note
"mitarbeiter_uid" => $teacherGrade->mitarbeiter_uid,
"datum" => $datum,
"pruefungstyp_kurzbz" => $this->VerlaufLib->legacyTypFuerAntritt(1),
"note" => $teacherGrade->note,
"insertamum" => $now,
"insertvon" => getAuthUID()
];
if (defined('CIS_GESAMTNOTE_PUNKTE') && CIS_GESAMTNOTE_PUNKTE)
$data["punkte"] = $punkte;
$data["punkte"] = $teacherGrade->punkte;
// Get Anwesenheit
$this->load->model('education/Anwesenheit_model', 'AnwesenheitModel');
$result = $this->AnwesenheitModel->loadAnwesenheitStudiensemester($studiensemester_kurzbz, $student_uid, $lehrveranstaltung_id);
@@ -535,7 +556,8 @@ class Grades extends FHCAPI_Controller
return $result;
$anwesenheit = getData($result);
if ($anwesenheit && (float)current($anwesenheit)->prozent < FAS_ANWESENHEIT_ROT) {
if (defined('FAS_PRUEFUNG_BEI_NOTENEINGABE_ANLEGEN') && FAS_PRUEFUNG_BEI_NOTENEINGABE_ANLEGEN
&& $anwesenheit && (float)current($anwesenheit)->prozent < FAS_ANWESENHEIT_ROT) {
// Get Anwesenheitsbefreiung
$this->load->model('person/Benutzerfunktion_model', 'BenutzerfunktionModel');
$result = $this->BenutzerfunktionModel->getBenutzerFunktionByUidInStdsem($student_uid, $studiensemester_kurzbz, 'awbefreit');
@@ -545,7 +567,10 @@ class Grades extends FHCAPI_Controller
$anwesenheitsbefreit = hasData($result);
// Wenn nicht Anwesenheitsbefreit und Anwesenheit unter einem bestimmten Prozentsatz fällt dann wird ein Pruefungsantritt abgezogen
// Bei zu geringer Anwesenheit ohne Befreiung wird ein zusätzlicher Termin
// 'Nicht beurteilt' vor dem eigentlichen Antritt festgehalten. ACHTUNG: diese Note
// zählt nicht mehr als Antritt (NOTEN_OHNE_ANTRITT_BEZEICHNUNGEN) - der frühere
// "Antritt abziehen"-Effekt entfällt damit, der Eintrag ist reine Dokumentation.
if (!$anwesenheitsbefreit) {
$data2 = $data;
$data2["note"] = 7;
@@ -556,8 +581,6 @@ class Grades extends FHCAPI_Controller
if (isError($result))
return $result;
$data["pruefungstyp_kurzbz"] = "Termin2";
}
}
@@ -0,0 +1,21 @@
<?php
if (! defined('BASEPATH')) exit('No direct script access allowed');
/**
* The permissions that open the Benotungstool: the keys of CIS_GESAMTNOTE_ROLLENMATRIX. The page and
* the Noten API read this one list. A constructor needs it before CodeIgniter can load a configuration,
* so this reads the file itself.
*
* @return array one 'permission:rw' entry per key
*/
function benotungstoolPermissions()
{
$config = array();
include APPPATH . 'config/noten.php';
$permissions = array();
foreach (array_keys($config['CIS_GESAMTNOTE_ROLLENMATRIX']) as $permission) $permissions[] = $permission . ':rw';
return $permissions;
}
@@ -0,0 +1,765 @@
<?php
/**
* Copyright (C) 2026 fhcomplete.org
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
if (! defined('BASEPATH')) exit('No direct script access allowed');
/**
* The Verlauf of one student in one LV: the order of the Pruefungen, the Antritte and the limits.
*
* This class holds the rules of the Antritt chain. The controller holds access, Frist and Freigabe.
* The client only reads the Verlauf.
*
* The StV calls getPruefungen() and legacyTypFuerAntritt(). Keep their names and signatures.
*/
class PruefungsverlaufLib
{
/** Antritt 1. Without a Pruefung the LV-Note itself is Antritt 1. */
const ROLE_ERSTANTRITT = 'erstantritt';
/** Each Antritt after the first one. */
const ROLE_PRUEFUNG = 'pruefung';
/** The Antritt before a commission (CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT). */
const ROLE_KOMMISSIONELL = 'kommissionell';
/** upsertErstantritt(): an existing Antritt 1 keeps its date. */
const KEEP_DATUM = false;
/** upsertErstantritt(): an existing Antritt 1 takes the new date. */
const SET_DATUM = true;
private $_ci;
private $_noteCache = [];
private $_specialNotes = null;
private $_types = null;
public function __construct()
{
$this->_ci =& get_instance();
$this->_ci->load->model('education/LePruefung_model', 'LePruefungModel');
$this->_ci->load->model('education/Note_model', 'NoteModel');
$this->_ci->load->model('education/Pruefungstyp_model', 'PruefungstypModel');
$this->_ci->load->config('noten');
}
/**
* Builds the Verlauf. Each Pruefung gets position, is_antritt, antritt_nr, kommissionell and
* note_locked. The rules and the client read this one object.
*
* @param array $pruefungen the Pruefungen of one student in one LV
* @param mixed|null $lvNote Antritt 1 while no Pruefung is an Antritt
* @param mixed|null $zeugnisnote an Anrechnung blocks every Pruefung
* @return stdClass
*/
public function buildVerlauf($pruefungen, $lvNote, $zeugnisnote)
{
$pruefungen = $this->sortPruefungen($pruefungen);
// a later Pruefung locks the Note of an earlier one; its date stays editable
$lockedByLater = $this->configBool('CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETERER_PRUEFUNG');
$antrittCount = 0;
$terminal = false;
$entries = [];
foreach ($pruefungen as $i => $pruefung) {
$isAntritt = $this->isAntritt($pruefung->note, $pruefung->pruefungstyp_kurzbz);
if ($isAntritt) $antrittCount++;
// A kommissionelle Pruefung closes the chain, also if its Note uses no Antritt.
$kommissionell = $this->isKommissionell($pruefung->pruefungstyp_kurzbz);
if ($kommissionell) $terminal = true;
$entry = clone $pruefung;
$entry->position = $i + 1;
$entry->is_antritt = $isAntritt;
$entry->antritt_nr = $isAntritt ? $antrittCount : null;
$entry->kommissionell = $kommissionell;
$entry->note_locked = $lockedByLater && $i < count($pruefungen) - 1;
$entries[] = $entry;
}
// without a Pruefung that is an Antritt, the LV-Note itself is Antritt 1
$implicit = ($antrittCount === 0 && $lvNote !== null && $this->isAntritt($lvNote));
if ($implicit) $antrittCount = 1;
// the deciding Note: the last Antritt, else the LV-Note
$currentNote = null;
foreach ($entries as $entry) {
if ($entry->is_antritt) $currentNote = $entry->note;
}
if ($currentNote === null && $implicit) $currentNote = $lvNote;
// a closing Note always ends the chain, any other positive Note only without Notenverbesserung
$bestanden = $this->isClosingNote($currentNote)
|| (!$this->allowsImprovement() && $this->isPositiveNote($currentNote));
if ($bestanden) $terminal = true;
$verlauf = new stdClass();
$verlauf->pruefungen = $entries;
$verlauf->antrittCount = $antrittCount;
$verlauf->maxAntritte = $this->getMaxAntritte();
$verlauf->terminal = $terminal;
// closed by a pass, not by the limit
$verlauf->bestanden = $bestanden;
// the row stays visible, but nobody can enter anything
$verlauf->angerechnet = $this->isAnrechnungNote($zeugnisnote);
$verlauf->implicitErstantritt = $implicit;
$verlauf->canAdd = !$terminal && !$verlauf->angerechnet && $antrittCount < $verlauf->maxAntritte;
// a second Pruefung, or one Pruefung that is not Antritt 1 (next to an implicit Antritt 1).
// This is the only rule that reads the legacy Pruefungstyp back.
$verlauf->hasRepeat = count($entries) > 1
|| (count($entries) === 1 && $entries[0]->pruefungstyp_kurzbz !== $this->legacyTypFuerAntritt(1));
// The role follows from the position (§17 Abs 1), so the user interface offers no type.
if (count($entries) === 0 && !$implicit) {
$verlauf->nextRole = self::ROLE_ERSTANTRITT;
} else {
// a Pruefung exists: the next one is a repeat, also if no Pruefung is an Antritt yet
$verlauf->nextRole = $this->roleForAntritt(max(2, $antrittCount + 1));
}
// the next Antritt is kommissionell, and this tool may not create it
$verlauf->kommPruefLocked = $verlauf->canAdd
&& $verlauf->nextRole === self::ROLE_KOMMISSIONELL
&& !$this->canCreateKommPruef();
if ($verlauf->kommPruefLocked) $verlauf->canAdd = false;
$verlauf->earliestNewDay = $this->earliestNewDay($entries);
$verlauf->notenAtLimit = $this->notenAtLimit($entries);
return $verlauf;
}
/**
* @param mixed|null $lvNote see buildVerlauf
* @param mixed|null $zeugnisnote see buildVerlauf
* @return stdClass
*/
public function getVerlauf($student_uid, $lv_id, $sem_kurzbz, $lvNote, $zeugnisnote)
{
return $this->buildVerlauf($this->getPruefungen($student_uid, $lv_id, $sem_kurzbz), $lvNote, $zeugnisnote);
}
/** All Pruefungen of one student in one LV, in the order of the Verlauf. The StV calls it. @return array */
public function getPruefungen($student_uid, $lv_id, $sem_kurzbz)
{
$result = $this->_ci->LePruefungModel->getPruefungenByStudent($student_uid, $lv_id, $sem_kurzbz);
if (isError($result) || !hasData($result)) return [];
return $this->sortPruefungen(getData($result));
}
/**
* Checks a NEW Antritt: A the limit, B the order of the dates, C the occurrence limit, D the gap.
*
* @param stdClass $verlauf from buildVerlauf
* @return array|null [phraseKey, params] or null
*/
public function validateAdd($verlauf, $note, $datum)
{
if ($verlauf->angerechnet) return ['c4angerechnetKeinePruefung', []];
if ($verlauf->kommPruefLocked) return ['kommPruefNichtErlaubt', []];
// A: Antritt 1 only writes the LV-Note down, so it needs no free Antritt
if ($verlauf->nextRole !== self::ROLE_ERSTANTRITT && !$verlauf->canAdd) {
if ($verlauf->bestanden) return ['pruefungNachBestandenerNote', []];
return ['maxAntritteReached', [$verlauf->maxAntritte]];
}
// one Pruefung without a result per Verlauf; more of them break the order of the Antritte
if ($this->isOpen($note) && $this->hasOpenPruefung($verlauf->pruefungen)) return ['pruefungOhneErgebnis', []];
// B: no Pruefung before an existing one
$newDay = substr((string) $datum, 0, 10);
if ($verlauf->earliestNewDay !== null && $newDay < $verlauf->earliestNewDay) return ['pruefungDatumBeforeExisting', []];
// C: for example one 'entschuldigt' only
if ($this->exceedsNoteLimit($verlauf->pruefungen, $note)) return ['noteOccuranceLimitReached', []];
// D
return $this->checkAntrittGap($verlauf, $newDay);
}
/**
* Checks a CHANGED Pruefung. The date must stay between its neighbours, and a later Pruefung locks
* the Note.
*
* @param stdClass $verlauf from buildVerlauf
* @return array|null [phraseKey, params] or null
*/
public function validateEdit($verlauf, $pruefung_id, $note, $datum)
{
if ($verlauf->angerechnet) return ['c4angerechnetKeinePruefung', []];
// only a Pruefung of this student in this LV; the StV owns the types without an Antritt
$current = $this->findPruefung($verlauf->pruefungen, $pruefung_id);
if ($current === null || in_array($current->pruefungstyp_kurzbz, $this->getTypesWithoutAntritt())) {
return ['pruefungNichtBearbeitbar', []];
}
if ($this->isOpen($note) && $this->hasOpenPruefung($verlauf->pruefungen, $pruefung_id)) return ['pruefungOhneErgebnis', []];
if ($current->note_locked && $note != $current->note) return ['pruefungNoteLocked', []];
// the date must stay strictly between the dates of the neighbours
$currentDay = substr((string) $current->datum, 0, 10);
$newDay = substr((string) $datum, 0, 10);
$lower = null;
$upper = null;
foreach ($verlauf->pruefungen as $pruefung) {
$day = substr((string) $pruefung->datum, 0, 10);
if ($pruefung->pruefung_id == $current->pruefung_id || $day === '') continue;
if ($day < $currentDay && ($lower === null || $day > $lower)) $lower = $day;
if ($day > $currentDay && ($upper === null || $day < $upper)) $upper = $day;
}
if (($lower !== null && $newDay <= $lower) || ($upper !== null && $newDay >= $upper)) {
return ['pruefungDatumOutOfRange', []];
}
if ($this->exceedsNoteLimit($verlauf->pruefungen, $note, $current->pruefung_id)) {
return ['noteOccuranceLimitReached', []];
}
// only a new date: an old gap outside the rule must not block a Note correction
if ($newDay === $currentDay) return null;
return $this->checkAntrittGap($verlauf, $newDay, $current->pruefung_id);
}
/**
* The LV-Note after one Pruefung write: the Note of the last Antritt. Without an Antritt the
* implicit Antritt 1 stays, else nothing is entered. The LV-Note is never 'entschuldigt'.
*
* With CIS_GESAMTNOTE_VERBESSERUNG_BESSERE_GEWINNT a worse new Antritt keeps the old LV-Note.
*
* @param stdClass $verlauf from buildVerlauf, before this write
* @param mixed $pruefung_id set = the changed Pruefung, empty = a new Pruefung after all others
* @return array [note, punkte]
*/
public function deriveLvNote($verlauf, $pruefung_id, $note, $punkte, $lvNote, $lvPunkte)
{
$isNew = $pruefung_id === null || $pruefung_id === '';
$result = null;
foreach ($verlauf->pruefungen as $pruefung) {
$row = (!$isNew && $pruefung->pruefung_id == $pruefung_id) ? [$note, $punkte] : [$pruefung->note, $pruefung->punkte];
if ($this->isAntritt($row[0], $pruefung->pruefungstyp_kurzbz)) $result = $row;
}
if ($isNew && $this->isAntritt($note)) $result = [$note, $punkte];
if ($result === null) {
if ($verlauf->implicitErstantritt) return [$lvNote, $lvPunkte];
return [$this->getNoteNichtEingetragen(), null];
}
if ($result[0] == $note
&& $this->configBool('CIS_GESAMTNOTE_VERBESSERUNG_BESSERE_GEWINNT')
&& $this->isWorse($note, $lvNote)) {
return [$lvNote, $lvPunkte];
}
return $result;
}
/**
* Writes Antritt 1 as its own Pruefung. An LV-Note without a Pruefung IS Antritt 1. While that row
* is missing, the next Pruefung becomes Antritt 2 and the Pruefungstypen of the chain shift.
*
* Writes nothing once a repeat exists, and nothing into a Pruefung with a result of its own.
*
* @param bool $datumMode self::SET_DATUM or self::KEEP_DATUM for an existing Antritt 1
* @return stdClass|null the Pruefung, or null if nothing was written
*/
public function upsertErstantritt($student_uid, $lv_id, $sem_kurzbz, $note, $punkte, $datum, $datumMode, $mitarbeiter_uid, $lehreinheit_id)
{
$pruefungen = $this->getPruefungen($student_uid, $lv_id, $sem_kurzbz);
// hasRepeat reads only the Pruefungen
if ($this->buildVerlauf($pruefungen, null, null)->hasRepeat) return null;
$now = date('Y-m-d H:i:s');
$day = substr((string) $datum, 0, 10);
if (count($pruefungen) === 1) {
if (!$this->canOverwriteErstantritt($pruefungen[0])) return null;
$data = [
'note' => $note,
'punkte' => $punkte,
'updateamum' => $now,
'updatevon' => getAuthUID()
];
if ($datumMode === self::SET_DATUM) $data['datum'] = $day;
$this->_ci->LePruefungModel->update($pruefungen[0]->pruefung_id, $data);
return $this->loadPruefung($pruefungen[0]->pruefung_id);
}
if ($lehreinheit_id === null) return null;
$result = $this->_ci->LePruefungModel->insert([
'lehreinheit_id' => $lehreinheit_id,
'student_uid' => $student_uid,
'mitarbeiter_uid' => $mitarbeiter_uid,
'note' => $note,
'punkte' => $punkte,
'pruefungstyp_kurzbz' => $this->legacyTypFuerAntritt(1),
'datum' => $day,
'anmerkung' => '',
'insertamum' => $now,
'insertvon' => getAuthUID(),
'updateamum' => null,
'updatevon' => null,
'ext_id' => null
]);
return $result ? $this->loadPruefung($result->retval) : null;
}
/**
* The Pruefungstyp that the tool writes for an Antritt. The StV still reads that column and calls
* this method. A type that tbl_pruefungstyp does not have breaks the foreign key, so step down.
*
* @return string
*/
public function legacyTypFuerAntritt($antrittNr)
{
$antrittNr = max(1, (int) $antrittNr);
$types = $this->getTypes();
$kommissionell = $this->_ci->config->item('PRUEFUNG_TYP_KOMMISSIONELL');
if ($this->roleForAntritt($antrittNr) === self::ROLE_KOMMISSIONELL && isset($types[$kommissionell])) {
return $kommissionell;
}
$typePerAntritt = $this->configArray('PRUEFUNG_TYP_JE_ANTRITT');
for ($nr = $antrittNr; $nr >= 1; $nr--) {
if (isset($typePerAntritt[$nr]) && isset($types[$typePerAntritt[$nr]])) return $typePerAntritt[$nr];
}
return $typePerAntritt[1];
}
/** The Pruefungstyp of a new repeat; never below Antritt 2. @return string */
public function legacyTypeForRepeat($verlauf)
{
return $this->legacyTypFuerAntritt(max(2, $verlauf->antrittCount + 1));
}
/** Antritte, Antritt 1 and the kommissionelle Pruefung included. @return int */
public function getMaxAntritte()
{
$max = $this->_ci->config->item('CIS_GESAMTNOTE_MAX_ANTRITTE');
if ($max !== null) return (int) $max;
// the flags of the old tool: Antritt 1 plus one Antritt per flag
return 1 + (int) CIS_GESAMTNOTE_PRUEFUNG_TERMIN2 + (int) CIS_GESAMTNOTE_PRUEFUNG_TERMIN3 + (int) CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF;
}
/** The Antritt from which a Pruefung is kommissionell, or null. @return int|null */
public function getKommissionellFromAntritt()
{
$value = $this->_ci->config->item('CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT');
if ($value === 'letzter') {
$max = $this->getMaxAntritte();
// a chain of one has nothing to close
return $max > 1 ? $max : null;
}
return (int) $value >= 1 ? (int) $value : null;
}
/** May this tool create the kommissionelle Pruefung? It always shows an existing one. @return bool */
public function canCreateKommPruef()
{
return $this->configBool('CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF');
}
/** May a student take another Antritt after a positive Note? @return bool */
public function allowsImprovement()
{
return $this->configBool('CIS_GESAMTNOTE_NOTENVERBESSERUNG');
}
/** @return array the Pruefungstypen that never use an Antritt */
public function getTypesWithoutAntritt()
{
return $this->configArray('PRUEFUNG_TYPEN_OHNE_ANTRITT');
}
/**
* The special Noten. The configuration names them, lehre.tbl_note gives their keys: a key means
* something else in every installation. An unknown name is dropped.
*
* @return array
*/
public function getSpecialNotes()
{
if ($this->_specialNotes !== null) return $this->_specialNotes;
$entschuldigt = $this->getNoteByBezeichnung($this->_ci->config->item('NOTE_ENTSCHULDIGT_BEZEICHNUNG'));
$ohneAntritt = $this->resolveNoten('NOTEN_OHNE_ANTRITT_BEZEICHNUNGEN');
if ($entschuldigt !== null && !in_array($entschuldigt, $ohneAntritt)) $ohneAntritt[] = $entschuldigt;
// the configuration names a Note, the rules work with its key
$limitMap = [];
foreach ($this->configArray('NOTEN_OCCURRENCE_LIMIT_MAP') as $bezeichnung => $limit) {
$note = $this->getNoteByBezeichnung($bezeichnung);
if ($note !== null) $limitMap[$note] = $limit;
}
$this->_specialNotes = [
'entschuldigt' => $entschuldigt,
'ohneAntritt' => array_values($ohneAntritt),
'anrechnung' => $this->resolveNoten('NOTEN_ANRECHNUNG_BEZEICHNUNGEN'),
'abschliessend' => $this->resolveNoten('NOTEN_ABSCHLIESSEND_BEZEICHNUNGEN'),
'nichtEingetragen' => $this->getNoteByBezeichnung($this->_ci->config->item('NOTE_NICHT_EINGETRAGEN_BEZEICHNUNG')),
// best first
'rangfolge' => $this->resolveNoten('NOTEN_RANGFOLGE_BEZEICHNUNGEN'),
'limitMap' => $limitMap
];
return $this->_specialNotes;
}
/** The Note of a Pruefung without a result ('Noch nicht eingetragen'). @return mixed|null */
public function getNoteNichtEingetragen()
{
return $this->getSpecialNotes()['nichtEingetragen'];
}
/** 'angerechnet' or 'intern angerechnet'. Ask it about the Zeugnisnote. @return bool */
public function isAnrechnungNote($note)
{
if ($note === null || $note === '') return false;
return in_array($note, $this->getSpecialNotes()['anrechnung']);
}
/** Would one more $note cross NOTEN_OCCURRENCE_LIMIT_MAP? $pruefung_id skips the changed row. @return bool */
public function exceedsNoteLimit($pruefungen, $note, $pruefung_id = null)
{
$limit = null;
foreach ($this->getSpecialNotes()['limitMap'] as $limitNote => $value) {
if ($limitNote == $note) $limit = $value;
}
if ($limit === null) return false;
$count = 0;
foreach ($pruefungen as $pruefung) {
if ($pruefung_id !== null && $pruefung->pruefung_id == $pruefung_id) continue;
if ($pruefung->note == $note) $count++;
}
return $count + 1 > $limit;
}
// --- private --------------------------------------------------------------------------------
/** The role of one Antritt: 1 = erstantritt, later = pruefung, from the commission on = kommissionell. @return string */
private function roleForAntritt($antrittNr)
{
$nr = max(1, (int) $antrittNr);
$fromAntritt = $this->getKommissionellFromAntritt();
if ($fromAntritt !== null && $nr >= $fromAntritt) return self::ROLE_KOMMISSIONELL;
return $nr === 1 ? self::ROLE_ERSTANTRITT : self::ROLE_PRUEFUNG;
}
/**
* Does this Note use an Antritt? The Note decides; the type only through PRUEFUNG_TYPEN_OHNE_ANTRITT.
*
* @return bool
*/
private function isAntritt($note, $type = null)
{
if ($type !== null && in_array($type, $this->getTypesWithoutAntritt())) return false;
if ($note === null || $note === '') return false;
if (in_array($note, $this->getSpecialNotes()['ohneAntritt'])) return false;
// an Anrechnung is no assessment
if ($this->isAnrechnungNote($note)) return false;
$noteRow = $this->getNote($note);
return $noteRow ? (bool) $noteRow->lehre : false;
}
/** Is this Pruefungstyp kommissionell? @return bool */
private function isKommissionell($type)
{
return in_array($type, $this->configArray('PRUEFUNG_KOMMISSIONELL_TYPEN'));
}
/** Is the new Note worse? A Note outside NOTEN_RANGFOLGE_BEZEICHNUNGEN is never worse. @return bool */
private function isWorse($newNote, $oldNote)
{
$rangfolge = $this->getSpecialNotes()['rangfolge'];
$newRank = array_search($newNote, $rangfolge);
$oldRank = array_search($oldNote, $rangfolge);
if ($newRank === false || $oldRank === false) return false;
return $newRank > $oldRank;
}
/** The order of the Verlauf: datum, then tbl_pruefungstyp.sort (old rows without a date), then pruefung_id. */
private function sortPruefungen($pruefungen)
{
$pruefungen = array_values($pruefungen);
$types = $this->getTypes();
usort($pruefungen, function ($a, $b) use ($types) {
$dayA = substr((string) $a->datum, 0, 10);
$dayB = substr((string) $b->datum, 0, 10);
if ($dayA !== $dayB) {
if ($dayA === '') return -1;
if ($dayB === '') return 1;
return ($dayA < $dayB) ? -1 : 1;
}
$sortA = isset($types[$a->pruefungstyp_kurzbz]) ? (int) $types[$a->pruefungstyp_kurzbz]->sort : 0;
$sortB = isset($types[$b->pruefungstyp_kurzbz]) ? (int) $types[$b->pruefungstyp_kurzbz]->sort : 0;
if ($sortA !== $sortB) return ($sortA < $sortB) ? -1 : 1;
return ((int) $a->pruefung_id < (int) $b->pruefung_id) ? -1 : 1;
});
return $pruefungen;
}
/**
* May the LV-Note go into this Pruefung? Antritt 1 and the LV-Note are the same result, and an
* empty Pruefung waits for its result. A Pruefung with a result of its own ('entschuldigt',
* 'Nicht beurteilt') keeps it: the LV-Note then stays the implicit Antritt 1.
*
* @return bool
*/
private function canOverwriteErstantritt($pruefung)
{
if (in_array($pruefung->pruefungstyp_kurzbz, $this->getTypesWithoutAntritt())) return false;
if ($this->isAntritt($pruefung->note, $pruefung->pruefungstyp_kurzbz)) return true;
return $this->isOpen($pruefung->note);
}
/**
* The first day for a new Pruefung: it comes after all others. CIS_GESAMTNOTE_PRUEFUNG_GLEICHER_TAG
* allows the day of the last one.
*
* @return string|null Y-m-d, or null without a dated Pruefung
*/
private function earliestNewDay($entries)
{
$lastDay = null;
foreach ($entries as $entry) {
$day = substr((string) $entry->datum, 0, 10);
if ($day !== '' && ($lastDay === null || $day > $lastDay)) $lastDay = $day;
}
if ($lastDay === null || $this->configBool('CIS_GESAMTNOTE_PRUEFUNG_GLEICHER_TAG')) return $lastDay;
return date('Y-m-d', strtotime($lastDay . ' +1 day'));
}
/** The Noten that NOTEN_OCCURRENCE_LIMIT_MAP allows no more time in this Verlauf. @return array Note => limit */
private function notenAtLimit($entries)
{
$atLimit = [];
foreach ($this->getSpecialNotes()['limitMap'] as $limitNote => $limit) {
$count = 0;
foreach ($entries as $entry) {
if ($entry->note == $limitNote) $count++;
}
if ($count >= $limit) $atLimit[$limitNote] = $limit;
}
return $atLimit;
}
/**
* The gaps between a Pruefung on $newDay and the Antritte next to it: the last one on or before
* the day, and the first one after it. A new Pruefung has no Antritt after it. A Pruefung without
* an Antritt does not count: an entschuldigt date must not push the next Antritt away.
*
* @param mixed $pruefung_id the changed Pruefung, which is no neighbour of itself; null for a new one
* @return array|null [phraseKey, params] or null
*/
private function checkAntrittGap($verlauf, $newDay, $pruefung_id = null)
{
$min = $this->_ci->config->item('CIS_GESAMTNOTE_ANTRITT_MIN_ABSTAND_TAGE');
$max = $this->_ci->config->item('CIS_GESAMTNOTE_ANTRITT_MAX_ABSTAND_TAGE');
if (($min === null && $max === null) || $newDay === '') return null;
$before = null;
$after = null;
foreach ($verlauf->pruefungen as $pruefung) {
$day = substr((string) $pruefung->datum, 0, 10);
if (!$pruefung->is_antritt || $day === '' || ($pruefung_id !== null && $pruefung->pruefung_id == $pruefung_id)) continue;
if ($day <= $newDay && ($before === null || $day > $before)) $before = $day;
if ($day > $newDay && ($after === null || $day < $after)) $after = $day;
}
foreach ([[$before, $newDay], [$newDay, $after]] as list($from, $to)) {
$gap = ($from !== null && $to !== null) ? $this->daysBetween($from, $to) : null;
if ($gap === null) continue;
if ($min !== null && $gap < (int) $min) return ['pruefungAbstandZuKurz', [(int) $min]];
if ($max !== null && $gap > (int) $max) return ['pruefungAbstandZuLang', [(int) $max]];
}
return null;
}
/** @return array the list under $key, or an empty list */
private function configArray($key)
{
$value = $this->_ci->config->item($key);
return is_array($value) ? $value : [];
}
/** @return bool */
private function configBool($key)
{
return (bool) $this->_ci->config->item($key);
}
/** Whole days from $from to $to (Y-m-d). @return int|null */
private function daysBetween($from, $to)
{
$a = DateTime::createFromFormat('Y-m-d|', $from);
$b = DateTime::createFromFormat('Y-m-d|', $to);
if ($a === false || $b === false) return null;
$diff = $a->diff($b);
return $diff->invert ? -((int) $diff->days) : (int) $diff->days;
}
/** @return stdClass|null */
private function findPruefung($pruefungen, $pruefung_id)
{
foreach ($pruefungen as $pruefung) {
if ($pruefung->pruefung_id == $pruefung_id) return $pruefung;
}
return null;
}
/** @return stdClass|null the tbl_note row */
private function getNote($note)
{
if (!array_key_exists($note, $this->_noteCache)) {
$result = $this->_ci->NoteModel->load($note);
$this->_noteCache[$note] = (!isError($result) && hasData($result)) ? getData($result)[0] : null;
}
return $this->_noteCache[$note];
}
/** @return mixed|null the key of the Note, or null if no Note has this Bezeichnung */
private function getNoteByBezeichnung($bezeichnung)
{
if (!is_string($bezeichnung) || trim($bezeichnung) === '') return null;
$result = $this->_ci->NoteModel->loadWhere(['bezeichnung' => $bezeichnung]);
return (!isError($result) && hasData($result)) ? getData($result)[0]->note : null;
}
/** lehre.tbl_pruefungstyp by pruefungstyp_kurzbz. @return array */
private function getTypes()
{
if ($this->_types !== null) return $this->_types;
$this->_types = [];
$result = $this->_ci->PruefungstypModel->load();
if (!isError($result) && hasData($result)) {
foreach (getData($result) as $type) $this->_types[$type->pruefungstyp_kurzbz] = $type;
}
return $this->_types;
}
/** Does a Pruefung other than $pruefung_id still wait for its result? @return bool */
private function hasOpenPruefung($pruefungen, $pruefung_id = null)
{
foreach ($pruefungen as $pruefung) {
if ($pruefung->pruefung_id != $pruefung_id && $this->isOpen($pruefung->note)) return true;
}
return false;
}
/** A Note with no better one ends the chain for good. @return bool */
private function isClosingNote($note)
{
if ($note === null || $note === '') return false;
return in_array($note, $this->getSpecialNotes()['abschliessend']);
}
/** 'Noch nicht eingetragen': the Pruefung waits for its result. @return bool */
private function isOpen($note)
{
$open = $this->getNoteNichtEingetragen();
return $open !== null && $note !== null && $note !== '' && $note == $open;
}
/**
* Is this Note a pass? tbl_note.positiv is also true for 'entschuldigt' and 'Teilgenommen', so ask
* it only about a Note that uses an Antritt.
*
* @return bool
*/
private function isPositiveNote($note)
{
if ($note === null || $note === '') return false;
$noteRow = $this->getNote($note);
return $noteRow ? (bool) $noteRow->positiv : false;
}
/** @return stdClass|null */
private function loadPruefung($pruefung_id)
{
$result = $this->_ci->LePruefungModel->load($pruefung_id);
return (!isError($result) && hasData($result)) ? getData($result)[0] : null;
}
/** The keys of the Noten that one configuration key names. @return array */
private function resolveNoten($key)
{
$noten = [];
foreach ($this->configArray($key) as $bezeichnung) {
$note = $this->getNoteByBezeichnung($bezeichnung);
if ($note !== null && !in_array($note, $noten)) $noten[] = $note;
}
return $noten;
}
}
@@ -11,4 +11,73 @@ class Mobilitaet_model extends DB_Model
$this->dbTable = 'bis.tbl_mobilitaet';
$this->pk = 'mobilitaet_id';
}
public function getMobilityZusatzForUids($uids) {
$qry = "SELECT distinct on(nachname, vorname, public.tbl_benutzer.person_id) uid,
tbl_mitarbeiter.mitarbeiter_uid,
tbl_note.lkt_ueberschreibbar, tbl_note.anmerkung,
tbl_mobilitaet.mobilitaetstyp_kurzbz,
(CASE WHEN bis.tbl_mobilitaet.studiensemester_kurzbz = vw_student_lehrveranstaltung.studiensemester_kurzbz THEN 1 ELSE 0 END) as doubledegree,
public.tbl_prestudent.gsstudientyp_kurzbz as ddtype,
(SELECT status_kurzbz FROM public.tbl_prestudentstatus
WHERE prestudent_id=tbl_student.prestudent_id
ORDER BY datum DESC, insertamum DESC, ext_id DESC LIMIT 1) as studienstatus
FROM
campus.vw_student_lehrveranstaltung
JOIN public.tbl_benutzer USING(uid)
JOIN public.tbl_person USING(person_id)
LEFT JOIN public.tbl_student ON(uid=student_uid)
LEFT JOIN public.tbl_mitarbeiter ON(uid=mitarbeiter_uid)
LEFT JOIN public.tbl_studentlehrverband USING(student_uid,studiensemester_kurzbz)
LEFT JOIN lehre.tbl_zeugnisnote on(vw_student_lehrveranstaltung.lehrveranstaltung_id=tbl_zeugnisnote.lehrveranstaltung_id
AND tbl_zeugnisnote.student_uid=tbl_student.student_uid
AND tbl_zeugnisnote.studiensemester_kurzbz=tbl_studentlehrverband.studiensemester_kurzbz)
LEFT JOIN lehre.tbl_note USING (note)
LEFT JOIN bis.tbl_bisio ON(uid=tbl_bisio.student_uid)
LEFT JOIN public.tbl_studiengang ON(tbl_student.studiengang_kz=tbl_studiengang.studiengang_kz)
LEFT JOIN bis.tbl_mobilitaet USING(prestudent_id)
LEFT JOIN public.tbl_prestudent USING(prestudent_id)
WHERE uid IN ?";
return $this->execReadOnlyQuery($qry, [$uids]);
}
public function formatZusatz($entry, $erhalter_kz) {
$zusatz = '';
if (isset($entry->studienstatus) && $entry->studienstatus === 'Incoming') {
$zusatz = '(i)';
}
if (isset($entry->lkt_ueberschreibbar) && $entry->lkt_ueberschreibbar === false) {
$zusatz .= ' (' . ($entry->anmerkung ?? '') . ')';
}
if (isset($entry->mitarbeiter_uid) && $entry->mitarbeiter_uid !== null) {
$zusatz .= ' (ma)';
}
if (isset($entry->stg_kz_student) && $entry->stg_kz_student == $erhalter_kz) {
$zusatz .= ' (a.o.)';
}
if (
isset($entry->mobilitaetstyp_kurzbz) && $entry->mobilitaetstyp_kurzbz &&
isset($entry->doubledegree) && $entry->doubledegree === 1
) {
$zusatz .= ' (d.d.';
$ddtype = $entry->ddtype ?? null;
if ($ddtype == 'Intern') {
$zusatz .= 'int.)';
} elseif ($ddtype == 'Extern') {
$zusatz .= 'ext.)';
} else {
$zusatz .= ')';
}
}
return $zusatz;
}
}
@@ -52,4 +52,47 @@ class LePruefung_model extends DB_Model
'student_uid' => $student_uid
]);
}
/**
* All Pruefungen of one LV in one Studiensemester.
*
* Note and Pruefungstyp are LEFT JOINs: a Pruefung with a type that tbl_pruefungstyp does not know
* stays in the Verlauf. Else the rules count one Antritt too few.
*
* No ORDER BY: PruefungsverlaufLib sorts the rows into the order of the Verlauf.
*/
public function getPruefungenByLvStudiensemester($lv_id, $sem_kurzbz) {
$qry = "SELECT tbl_pruefung.*, tbl_lehrveranstaltung.bezeichnung as lehrveranstaltung_bezeichnung, tbl_lehrveranstaltung.lehrveranstaltung_id,
tbl_note.bezeichnung as note_bezeichnung, tbl_pruefungstyp.beschreibung as typ_beschreibung, tbl_lehreinheit.studiensemester_kurzbz as studiensemester_kurzbz
FROM lehre.tbl_pruefung
JOIN lehre.tbl_lehreinheit ON (tbl_pruefung.lehreinheit_id = tbl_lehreinheit.lehreinheit_id)
JOIN lehre.tbl_lehrveranstaltung ON (tbl_lehreinheit.lehrveranstaltung_id = tbl_lehrveranstaltung.lehrveranstaltung_id)
LEFT JOIN lehre.tbl_note ON (tbl_pruefung.note = tbl_note.note)
LEFT JOIN lehre.tbl_pruefungstyp ON (tbl_pruefung.pruefungstyp_kurzbz = tbl_pruefungstyp.pruefungstyp_kurzbz)
WHERE tbl_lehrveranstaltung.lehrveranstaltung_id = ?
AND tbl_lehreinheit.studiensemester_kurzbz = ?";
return $this->execReadOnlyQuery($qry, array($lv_id, $sem_kurzbz));
}
/**
* All Pruefungen of one student in one LV.
*
* Note and Pruefungstyp are LEFT JOINs. PruefungsverlaufLib derives the Antritt numbers from this
* list, and a missing row shifts the whole chain.
*/
public function getPruefungenByStudent($student_uid, $lv_id, $sem_kurzbz) {
$qry = "SELECT tbl_pruefung.*, tbl_lehrveranstaltung.bezeichnung as lehrveranstaltung_bezeichnung, tbl_lehrveranstaltung.lehrveranstaltung_id,
tbl_note.bezeichnung as note_bezeichnung, tbl_pruefungstyp.beschreibung as typ_beschreibung, tbl_lehreinheit.studiensemester_kurzbz as studiensemester_kurzbz
FROM lehre.tbl_pruefung
JOIN lehre.tbl_lehreinheit ON (tbl_pruefung.lehreinheit_id = tbl_lehreinheit.lehreinheit_id)
JOIN lehre.tbl_lehrveranstaltung ON (tbl_lehreinheit.lehrveranstaltung_id = tbl_lehrveranstaltung.lehrveranstaltung_id)
LEFT JOIN lehre.tbl_note ON (tbl_pruefung.note = tbl_note.note)
LEFT JOIN lehre.tbl_pruefungstyp ON (tbl_pruefung.pruefungstyp_kurzbz = tbl_pruefungstyp.pruefungstyp_kurzbz)
WHERE tbl_pruefung.student_uid = ?
AND tbl_lehrveranstaltung.lehrveranstaltung_id = ?
AND tbl_lehreinheit.studiensemester_kurzbz = ?";
return $this->execReadOnlyQuery($qry, array($student_uid, $lv_id, $sem_kurzbz));
}
}
@@ -741,4 +741,28 @@ EOSQL;
)";
}
public function getLehreinheitenForLv($lv_id, $sem_kurzbz)
{
// every Lehreinheit of the LV; the caller checks the access
$query = "SELECT DISTINCT tbl_lehreinheit.lehreinheit_id, tbl_lehreinheit.lehrveranstaltung_id, tbl_lehreinheit.lehrform_kurzbz,
tbl_gruppe.direktinskription,
tbl_lehreinheitgruppe.semester,
tbl_lehreinheitgruppe.verband,
tbl_lehreinheitgruppe.gruppe,
tbl_lehreinheitgruppe.gruppe_kurzbz,
tbl_lehrveranstaltung.kurzbz,
tbl_studiengang.kurzbzlang,
(SELECT COUNT(DISTINCT datum) FROM campus.vw_stundenplan WHERE lehreinheit_id = lehre.tbl_lehreinheit.lehreinheit_id) as termincount,
(SELECT COUNT(*) FROM campus.vw_student_lehrveranstaltung WHERE lehreinheit_id = lehre.tbl_lehreinheit.lehreinheit_id) as studentcount
FROM lehre.tbl_lehreinheit
JOIN lehre.tbl_lehreinheitgruppe USING(lehreinheit_id)
JOIN lehre.tbl_lehrveranstaltung USING(lehrveranstaltung_id)
JOIN public.tbl_studiengang ON (tbl_lehreinheitgruppe.studiengang_kz = tbl_studiengang.studiengang_kz)
LEFT JOIN public.tbl_gruppe ON (tbl_gruppe.gruppe_kurzbz = tbl_lehreinheitgruppe.gruppe_kurzbz)
WHERE lehrveranstaltung_id = ? AND tbl_lehreinheit.studiensemester_kurzbz = ?
ORDER BY tbl_lehreinheitgruppe.gruppe_kurzbz";
return $this->execReadOnlyQuery($query, [$lv_id, $sem_kurzbz]);
}
}
@@ -317,7 +317,7 @@ class Lehrveranstaltung_model extends DB_Model
tbl_bisio.bisio_id, tbl_bisio.von, tbl_bisio.bis, tbl_student.studiengang_kz AS stg_kz_student,
tbl_zeugnisnote.note, tbl_mitarbeiter.mitarbeiter_uid, tbl_person.matr_nr, tbl_benutzer.uid,
UPPER(tbl_studiengang.typ::varchar(1) || tbl_studiengang.kurzbz) as kuerzel, tbl_studiengang.orgform_kurzbz, vw_student_lehrveranstaltung.semester, vw_student_lehrveranstaltung.studiensemester_kurzbz, vw_student_lehrveranstaltung.bezeichnung,
tbl_student.prestudent_id
tbl_student.prestudent_id, campus.vw_student_lehrveranstaltung.lehreinheit_id
FROM
campus.vw_student_lehrveranstaltung
JOIN public.tbl_benutzer USING(uid)
@@ -685,10 +685,22 @@ class Lehrveranstaltung_model extends DB_Model
]);
$this->dbTable = $tmp;
return $result;
}
/**
* All Lehreinheiten of a student in a Lehrveranstaltung, lowest id first.
*/
public function getLeIdsByStudent($student_uid, $studiensemester_kurzbz, $lehrveranstaltung_id)
{
$qry = "SELECT DISTINCT lehreinheit_id FROM campus.vw_student_lehrveranstaltung
WHERE uid = ? AND lehrveranstaltung_id = ? AND studiensemester_kurzbz = ?
ORDER BY lehreinheit_id";
return $this->execReadOnlyQuery($qry, array($student_uid, $lehrveranstaltung_id, $studiensemester_kurzbz));
}
/**
* Sucht nach LV Templates und gibt Id und Label ("bezeichnung [kurzbz]") aus
* Diese funktion ist für autocomplete gedacht
@@ -1379,4 +1391,73 @@ class Lehrveranstaltung_model extends DB_Model
return $this->execQuery($qry, $params);
}
public function getLvForLektorInSemester($sem_kurzbz, $uid) {
$qry = "SELECT DISTINCT (tbl_lehrveranstaltung.lehrveranstaltung_id),
UPPER(tbl_studiengang.typ::varchar(1) || tbl_studiengang.kurzbz) as stg_kurzbz,
tbl_studiengang.studiengang_kz,
tbl_lehrveranstaltung.semester as lv_semester,
tbl_lehrveranstaltung.bezeichnung as lv_bezeichnung,
(SELECT kurzbz FROM public.tbl_mitarbeiter
WHERE mitarbeiter_uid=tbl_lehreinheitmitarbeiter.mitarbeiter_uid) as lektor,
lehre.tbl_lehrveranstaltung.orgform_kurzbz as orgform
FROM
lehre.tbl_lehreinheit JOIN lehre.tbl_lehreinheitmitarbeiter USING(lehreinheit_id)
JOIN lehre.tbl_lehrveranstaltung USING(lehrveranstaltung_id)
JOIN public.tbl_studiengang USING(studiengang_kz)
JOIN lehre.tbl_lehrveranstaltung as lehrfach ON(tbl_lehreinheit.lehrfach_id=lehrfach.lehrveranstaltung_id)
WHERE
tbl_lehreinheit.studiensemester_kurzbz = ?
AND mitarbeiter_uid = ?
ORDER BY stg_kurzbz,lv_semester,lv_bezeichnung";
return $this->execReadOnlyQuery($qry, array($sem_kurzbz, $uid));
}
/**
* All Lehrveranstaltungen of a Studiengang offered in a Studiensemester.
*/
public function getLvForStudiengangInSemester($sem_kurzbz, $studiengang_kz) {
$qry = "SELECT DISTINCT (tbl_lehrveranstaltung.lehrveranstaltung_id),
UPPER(tbl_studiengang.typ::varchar(1) || tbl_studiengang.kurzbz) as stg_kurzbz,
tbl_studiengang.studiengang_kz,
tbl_lehrveranstaltung.semester as lv_semester,
tbl_lehrveranstaltung.bezeichnung as lv_bezeichnung,
tbl_lehrveranstaltung.orgform_kurzbz as orgform
FROM
lehre.tbl_lehrveranstaltung JOIN public.tbl_studiengang USING(studiengang_kz)
WHERE
tbl_lehrveranstaltung.studiengang_kz = ?
AND tbl_lehrveranstaltung.aktiv = TRUE
AND EXISTS (
SELECT 1 FROM lehre.tbl_lehreinheit le
WHERE le.lehrveranstaltung_id = tbl_lehrveranstaltung.lehrveranstaltung_id
AND le.studiensemester_kurzbz = ?
)
ORDER BY stg_kurzbz,lv_semester,lv_bezeichnung";
return $this->execReadOnlyQuery($qry, array($studiengang_kz, $sem_kurzbz));
}
/**
* Number of Lehreinheit assignments the given mitarbeiter has for a Lehrveranstaltung (i.e. whether
* they teach it), optionally restricted to a Studiensemester. Returns a single row with a `teaches`
* count - check `teaches > 0`. Used to scope teacher access in the Benotungstool to their own LVs.
*/
public function getLektorIsTeachingLva($lva_id, $ma_uid, $studiensemester_kurzbz = null)
{
$params = array($lva_id, $ma_uid);
$query = "SELECT COUNT(*) AS teaches
FROM lehre.tbl_lehreinheitmitarbeiter
JOIN lehre.tbl_lehreinheit USING (lehreinheit_id)
JOIN lehre.tbl_lehrveranstaltung USING (lehrveranstaltung_id)
WHERE lehrveranstaltung_id = ? AND mitarbeiter_uid = ?";
if ($studiensemester_kurzbz !== null && $studiensemester_kurzbz !== '') {
$query .= " AND tbl_lehreinheit.studiensemester_kurzbz = ?";
$params[] = $studiensemester_kurzbz;
}
return $this->execReadOnlyQuery($query, $params);
}
}
@@ -50,4 +50,27 @@ class Lvgesamtnote_model extends DB_Model
return $this->loadWhere($where);
}
/**
* The LV-Note of one student, without the Freigabe filter of getLvGesamtNoten().
*/
public function getByStudent($lehrveranstaltung_id, $student_uid, $studiensemester_kurzbz)
{
return $this->loadWhere(array(
'student_uid' => $student_uid,
'lehrveranstaltung_id' => $lehrveranstaltung_id,
'studiensemester_kurzbz' => $studiensemester_kurzbz
));
}
/**
* All LV-Noten of one LV in one Studiensemester, without the Freigabe filter.
*/
public function getByLvStudiensemester($lehrveranstaltung_id, $studiensemester_kurzbz)
{
return $this->loadWhere(array(
'lehrveranstaltung_id' => $lehrveranstaltung_id,
'studiensemester_kurzbz' => $studiensemester_kurzbz
));
}
}
+22 -3
View File
@@ -11,12 +11,31 @@ class Note_model extends DB_Model
$this->dbTable = 'lehre.tbl_note';
$this->pk = 'note';
}
public function getAllActive() {
/**
* Sorted like the Notenliste of the StV: the scale 1-5 by its value first, every other Note
* alphabetically after it.
*
* @param string|null $sortOrder 'skala' or 'bezeichnung'. The caller holds the configuration.
* Without a value the model reads NOTEN_SORTIERUNG itself, for the
* callers outside the Benotungstool.
*/
public function getAllActive($sortOrder = null) {
$qry ="SELECT *
FROM lehre.tbl_note
WHERE aktiv = true";
if($sortOrder === null) {
$this->config->load('noten', FALSE, TRUE);
$sortOrder = $this->config->item('NOTEN_SORTIERUNG');
}
$qry .= ($sortOrder === 'bezeichnung')
? " ORDER BY bezeichnung"
: " ORDER BY CASE WHEN note BETWEEN 1 AND 5 THEN 0 ELSE 1 END,
CASE WHEN note BETWEEN 1 AND 5 THEN note END,
bezeichnung";
return $this->execReadOnlyQuery($qry);
}
}
}
@@ -13,7 +13,7 @@ class Notenschluesselaufteilung_model extends DB_Model
}
/**
* Liefert die Note zu Punkten einer Lehrveranstaltung
* Gives the Note for the Punkte of an LV.
*
* @param number $points
* @param integer $lehrveranstaltung_id
@@ -23,9 +23,18 @@ class Notenschluesselaufteilung_model extends DB_Model
*/
public function getNote($points, $lehrveranstaltung_id, $studiensemester_kurzbz)
{
// Without valid Punkte no Note follows. Without this guard the query builder makes a bad
// statement from "punkte <=" => null ('punkte' < 'IS' 'NULL'), and the request fails with
// HTTP 500 instead of an answer that says "no Note".
if (!is_numeric($points))
return success(null);
$this->load->model('education/Notenschluesselzuordnung_model', 'NotenschluesselzuordnungModel');
$notenschluessel_kurzbz = $this->NotenschluesselzuordnungModel->getKurzbzForLv($lehrveranstaltung_id, $studiensemester_kurzbz);
if($notenschluessel_kurzbz == null)
return success(null);
$this->addSelect("note");
$this->addOrder("punkte", "DESC");
$this->addLimit(1);
@@ -306,4 +306,5 @@ class Pruefung_model extends DB_Model
return $this->loadWhereCommitteeExamsFailed();
}
}
@@ -59,6 +59,37 @@ class Studienplan_model extends DB_Model
'tbl_studienplan_lehrveranstaltung.semester' => $semester
));
}
public function getStudienplanByLvaSemKurzbz($lehrveranstaltung_id, $studiensemester_kurzbz) {
$qry= "
SELECT
DISTINCT tbl_studienplan.*
FROM
lehre.tbl_studienplan
JOIN lehre.tbl_studienplan_lehrveranstaltung
USING(studienplan_id)
WHERE
tbl_studienplan_lehrveranstaltung.lehrveranstaltung_id IN (
SELECT
lv.lehrveranstaltung_id
FROM
lehre.tbl_lehrveranstaltung AS lv
LEFT JOIN lehre.tbl_lehrveranstaltung AS t ON t.lehrveranstaltung_id=lv.lehrveranstaltung_template_id
WHERE
lv.lehrtyp_kurzbz<>'tpl'
AND (lv.lehrveranstaltung_id= ? OR (lv.lehrveranstaltung_template_id= ? AND t.lehrtyp_kurzbz='tpl'))
)
AND EXISTS (
SELECT 1
FROM
lehre.tbl_studienplan_semester
WHERE studienplan_id=tbl_studienplan.studienplan_id
AND studiensemester_kurzbz= ?
AND semester = tbl_studienplan_lehrveranstaltung.semester)
ORDER BY bezeichnung";
return $this->execReadOnlyQuery($qry, array($lehrveranstaltung_id, $lehrveranstaltung_id, $studiensemester_kurzbz));
}
public function getStudienplanLehrveranstaltungForPrestudent($studienplan_id, $semester, $prestudent_id)
{
@@ -23,7 +23,8 @@ $includesArray = array(
'public/css/components/FormUnderline.css',
'public/css/components/abgabetool/abgabe.css',
'public/css/Cis4/Cms.css',
'public/css/Cis4/Studium.css'
'public/css/Cis4/Studium.css',
'public/css/Cis4/Benotungstool.css'
),
'customJSs' => array(
'vendor/npm-asset/primevue/accordion/accordion.min.js',
@@ -37,11 +38,15 @@ $includesArray = array(
'vendor/npm-asset/primevue/timeline/timeline.min.js',
'vendor/npm-asset/primevue/inplace/inplace.min.js',
'vendor/npm-asset/primevue/message/message.min.js',
'vendor/npm-asset/primevue/divider/divider.min.js',
'vendor/npm-asset/primevue/password/password.js',
'vendor/npm-asset/primevue/multiselect/multiselect.js',
'vendor/npm-asset/primevue/tieredmenu/tieredmenu.js',
'vendor/moment/luxonjs/luxon.min.js'
),
'customJSModules' => array(
'public/js/apps/Cis/Cis.js',
'vendor/olifolkerd/tabulator5/src/js/modules/ColumnCalcs/ColumnCalcs.js'
),
);
@@ -1824,7 +1824,7 @@ $htmlstring .= "
</table>
";
if ($anzahlChanged > 0)
if (isset($anzahlChanged) && $anzahlChanged > 0)
echo '<div id="notenWarning" class="warning">' . $p->t('benotungstool/geaenderteNotenVorhanden') . '</div>';
echo $htmlstring;
+36 -1
View File
@@ -159,6 +159,41 @@ if (!$berechtigung->isBerechtigt('admin')
// let's merge
$format_title->setAlign('merge');
// Importkonfiguration aus dem CI-Config (application/config/noten.php) lesen, damit nur die
// Spalten des jeweils aktiven Importtyps hervorgehoben (grau) werden.
$CIS_GESAMTNOTE_NOTENIMPORT = false;
$CIS_GESAMTNOTE_PRUEFUNGSIMPORT = true;
$notenConfigFile = __DIR__.'/../../../application/config/noten.php';
if(is_file($notenConfigFile))
{
if(!defined('BASEPATH')) define('BASEPATH', true);
$config = array();
include($notenConfigFile);
if(isset($config['CIS_GESAMTNOTE_NOTENIMPORT'])) $CIS_GESAMTNOTE_NOTENIMPORT = $config['CIS_GESAMTNOTE_NOTENIMPORT'];
if(isset($config['CIS_GESAMTNOTE_PRUEFUNGSIMPORT'])) $CIS_GESAMTNOTE_PRUEFUNGSIMPORT = $config['CIS_GESAMTNOTE_PRUEFUNGSIMPORT'];
unset($config);
}
// plain (non-highlighted) variants used when an import type is disabled
$format_plain =& $workbook->addFormat();
$format_plain->setAlign('left');
$format_plain->setNumFormat(49);
$format_plainright =& $workbook->addFormat();
$format_plainright->setAlign('right');
$format_plainright->setNumFormat(49);
// highlight only the cells belonging to an enabled import flow:
// - Notenimport -> Personenkennzeichen + Note (main block)
// - Prüfungsimport -> Personenkennzeichen + Datum + Note (Termin2/Termin3 blocks)
$fmtNoteImportPkz = $CIS_GESAMTNOTE_NOTENIMPORT ? $format_highlight : $format_plain;
$fmtNoteImportNote = $CIS_GESAMTNOTE_NOTENIMPORT ? $format_highlightright : $format_plainright;
$fmtPruefImportPkz = $CIS_GESAMTNOTE_PRUEFUNGSIMPORT ? $format_highlight : $format_plain;
$fmtPruefImportDate = $CIS_GESAMTNOTE_PRUEFUNGSIMPORT ? $format_highlightright_date : $format_plainright;
$fmtPruefImportNote = $CIS_GESAMTNOTE_PRUEFUNGSIMPORT ? $format_highlightright : $format_plainright;
$lvobj = new lehrveranstaltung($lvid);
$worksheet->write(0,0,$p->t('anwesenheitsliste/notenliste')." ".($sprache=='English'?$lvobj->bezeichnung_english:$lvobj->bezeichnung),$format_bold);
$stg_obj = new studiengang($stg);
@@ -351,7 +386,7 @@ if (!$berechtigung->isBerechtigt('admin')
{
$worksheet->write($lines,3,$elem->vorname);
}
$worksheet->writeString($lines,4,$elem->semester.$elem->verband.$elem->gruppe);
$worksheet->write($lines,4,$elem->semester.$elem->verband.$elem->gruppe);
$worksheet->write($lines,5,trim($elem->matrikelnr),$format_highlight);
$worksheet->write($lines,6, $note, $format_highlightright);
+37
View File
@@ -0,0 +1,37 @@
const { defineConfig } = require("cypress");
require("dotenv").config({ path: "tests/cypress/.env" });
module.exports = defineConfig({
// Cypress 15 deprecates Cypress.env() inside cy.request options, which the API helpers use.
allowCypressEnv: true,
// The suite lives under tests/cypress, so every default folder has to be pointed there.
fixturesFolder: "tests/cypress/fixtures",
screenshotsFolder: "tests/cypress/screenshots",
videosFolder: "tests/cypress/videos",
downloadsFolder: "tests/cypress/downloads",
e2e: {
baseUrl: process.env.BASE_URL,
specPattern: "tests/cypress/e2e/**/*.cy.{js,jsx,ts,tsx}",
supportFile: "tests/cypress/support/e2e.js",
defaultCommandTimeout: 20000,
pageLoadTimeout: 20000,
retries: { runMode: 0, openMode: 0 },
setupNodeEvents(on, config) {
// No defaults anywhere: an empty .env fails the run instead of hitting someone else's instance.
const missing = ["BASE_URL", "USER_NAME", "USER_PASSWORD"].filter((key) => !process.env[key]);
if (missing.length) throw new Error(`Missing in tests/cypress/.env: ${missing.join(", ")}`);
// each suite registers its own tasks and Cypress.env() values
return require("./tests/cypress/suites/noten").setupNodeEvents(on, config);
},
},
env: {
adminusername: process.env.USER_NAME,
adminpassword: process.env.USER_PASSWORD,
},
});
+16
View File
@@ -0,0 +1,16 @@
{
"scripts": {
"noten:check": "node tests/cypress/tools/dbCheck.js noten",
"noten:api": "cypress run --spec \"tests/cypress/e2e/specs/api/**/*.cy.js\"",
"noten:ui": "cypress run --browser chrome --spec \"tests/cypress/e2e/specs/ui/**/*.cy.js\""
},
"//": [
"pg: the suite resets and seeds the test database directly (tasks/db.js, tools/dbCheck.js)",
"dotenv: cypress.config.js and the suite read URL, accounts and DB access from the .env files"
],
"dependencies": {
"cypress": "^15.21.1",
"dotenv": "^17.4.2",
"pg": "^8.13.1"
}
}
+178
View File
@@ -0,0 +1,178 @@
/* Pinned columns (tableLayout.js). The four columns that can be pinned always have .sticky-col.
A `sticky-on-<field>` class on #notentable pins one of them, and --sl-<field> is its left offset,
so that the pinned columns stand next to each other. */
#notentable .tabulator-header .tabulator-col.sticky-col,
#notentable .tabulator-tableholder .tabulator-row .tabulator-cell.sticky-col {
background-color: #fff; /* the cells below do not show through */
}
/* also on row hover */
#notentable .tabulator-row:hover .tabulator-cell.sticky-col {
background-color: #ccc;
}
/* the container pins a column */
#notentable.sticky-on-selectCol .sticky-col[tabulator-field="selectCol"],
#notentable.sticky-on-uid .sticky-col[tabulator-field="uid"],
#notentable.sticky-on-vorname .sticky-col[tabulator-field="vorname"],
#notentable.sticky-on-nachname .sticky-col[tabulator-field="nachname"] {
position: sticky;
z-index: 10; /* above the Pruefung columns */
border-right: 1px solid #ddd;
}
#notentable.sticky-on-selectCol .sticky-col[tabulator-field="selectCol"] { left: var(--sl-selectCol, 0); }
#notentable.sticky-on-uid .sticky-col[tabulator-field="uid"] { left: var(--sl-uid, 0); }
#notentable.sticky-on-vorname .sticky-col[tabulator-field="vorname"] { left: var(--sl-vorname, 0); }
#notentable.sticky-on-nachname .sticky-col[tabulator-field="nachname"] { left: var(--sl-nachname, 0); }
/* the content of a cell, from the formatters in Benotungstool.js */
#notentable .cell-start,
#notentable .cell-center {
display: flex;
align-items: center;
height: 100%;
}
#notentable .cell-start { justify-content: start; }
#notentable .cell-center { justify-content: center; }
/* a Zeugnisnote that differs from the LV-Note */
#notentable .zeugnisnote-differs {
color: red;
border: 1px solid red;
}
/* a proposal that the user cannot change */
#notentable .proposal-locked {
color: gray;
font-style: italic;
background-color: #f0f0f0;
pointer-events: none;
opacity: 0.6;
user-select: none;
cursor: not-allowed;
}
#notentable .teilnote-negativ { color: red; }
#notentable .freigegeben-icon { color: green; }
#notentable .mail-icon { color: #00649C; }
/* the editable proposal columns */
#notentable .tabulator-tableholder .tabulator-editable[tabulator-field="proposed_punkte"] {
position: relative;
background-color: rgba(255, 255, 157, 0.73);
}
#notentable .tabulator-tableholder .tabulator-editable[tabulator-field="proposed_note"] {
position: relative;
background-color: rgba(255, 255, 157, 0.73);
cursor: pointer;
}
#notentable .tabulator-tableholder .tabulator-editable[tabulator-field="proposed_note"]::after {
content: "▾";
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%) rotate(0deg);
transform-origin: center;
color: rgba(120, 120, 60, 0.85);
font-size: 0.9rem;
line-height: 1;
pointer-events: none;
transition: transform 0.15s ease;
}
/* the arrow points up while the list is open */
#notentable .tabulator-tableholder .tabulator-cell.tabulator-editing[tabulator-field="proposed_note"]::after {
transform: translateY(-50%) rotate(180deg);
}
/* A Pruefung cell: fixed tracks for Note | date | action, so that they align in all rows. */
.pruefung-cell {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) 6.5rem; /* Note | action */
align-items: center;
column-gap: 0.5rem;
height: 100%;
padding-left: 44px;
padding-right: 6px;
text-align: left;
}
.pruefung-cell.with-datum {
grid-template-columns: minmax(0, 1fr) 4.75rem 6.5rem; /* Note | datum | action */
}
.pruefung-cell.without-action {
grid-template-columns: minmax(0, 1fr); /* Note */
}
.pruefung-cell.without-action.with-datum {
grid-template-columns: minmax(0, 1fr) 4.75rem; /* Note | datum */
}
.pruefung-cell .pruefung-note {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* the reason instead of a button, for example after a pass */
.pruefung-cell .pruefung-hint {
grid-column: 1 / -1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.8rem;
font-style: italic;
opacity: 0.7;
cursor: help;
}
.pruefung-cell .pruefung-datum {
font-size: 0.75rem;
white-space: nowrap;
text-align: right;
opacity: 0.7;
}
/* always the last track, also in an empty cell with only the add button */
.pruefung-cell .pruefung-action {
grid-column: -2 / -1;
display: flex;
justify-content: flex-end;
}
.pruefung-cell .pruefung-action .btn {
white-space: nowrap;
}
.pruefung-badge::before {
content: attr(data-antritt);
position: absolute;
left: 6px;
top: 50%;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 5px;
border-radius: 10px;
color: #fff;
font-size: 0.75rem;
font-weight: bold;
white-space: nowrap;
}
.pruefung-badge.antritt-1::before { background: #0072B2; }
.pruefung-badge.antritt-2::before { background: #E69F00; }
.pruefung-badge.antritt-3::before { background: #CC79A7; }
.pruefung-badge.antritt-k::before { background: #D55E00; }
.pruefung-badge.antritt-none::before { background: #767676; }
+125
View File
@@ -0,0 +1,125 @@
/**
* Copyright (C) 2025 fhcomplete.org
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
export default {
getCisConfig() {
return {
method: 'get',
url: '/api/frontend/v1/Noten/getCisConfig'
};
},
getNoten() {
return {
method: 'get',
url: '/api/frontend/v1/Noten/getNoten'
};
},
getBenotungstoolContext(sem_kurzbz, lv_id = null) {
return {
method: 'get',
url: '/api/frontend/v1/Noten/getBenotungstoolContext',
params: { sem_kurzbz, lv_id }
};
},
getLvForStudiengang(studiengang_kz, sem_kurzbz) {
return {
method: 'get',
url: '/api/frontend/v1/Noten/getLvForStudiengang',
params: { studiengang_kz, sem_kurzbz }
};
},
getLehreinheitenForLv(lv_id, sem_kurzbz) {
return {
method: 'get',
url: '/api/frontend/v1/Noten/getLehreinheitenForLv',
params: { lv_id, sem_kurzbz }
};
},
getLektorenForLehreinheit(lv_id, sem_kurzbz, lehreinheit_id) {
return {
method: 'get',
url: '/api/frontend/v1/Noten/getLektorenForLehreinheit',
params: { lv_id, sem_kurzbz, lehreinheit_id }
};
},
// -> { students, domain }
getStudentenNoten(lv_id, sem_kurzbz) {
return {
method: 'get',
url: '/api/frontend/v1/Noten/getStudentenNoten',
params: { lv_id, sem_kurzbz }
};
},
getNoteByPunkte(lv_id, sem_kurzbz, punkte) {
return {
method: 'post',
url: '/api/frontend/v1/Noten/getNoteByPunkte',
params: { lv_id, sem_kurzbz, punkte }
};
},
// Each write answers { <uid>: { lvgesamtnote, verlauf, pruefung } }. A rejected row of a bulk
// write is { <uid>: { error: { code, message } } }.
// datum: the day of Antritt 1 (YYYY-MM-DD); without it the server takes today
saveLvNote(lv_id, sem_kurzbz, student_uid, note, punkte, datum) {
return {
method: 'post',
url: '/api/frontend/v1/Noten/saveLvNote',
params: { lv_id, sem_kurzbz, student_uid, note, punkte, datum }
};
},
// lv_noten: [{ uid, note, punkte }]
importLvNoten(lv_id, sem_kurzbz, lv_noten) {
return {
method: 'post',
url: '/api/frontend/v1/Noten/importLvNoten',
params: { lv_id, sem_kurzbz, lv_noten }
};
},
// pruefung: { pruefung_id (null = a new Pruefung), lehreinheit_id, datum, note, punkte, mitarbeiter_uid }
savePruefung(lv_id, sem_kurzbz, student_uid, pruefung) {
return {
method: 'post',
url: '/api/frontend/v1/Noten/savePruefung',
params: { lv_id, sem_kurzbz, student_uid, ...pruefung }
};
},
// students: [{ uid, lehreinheit_id }]; pruefung: { datum, note, punkte, mitarbeiter_uid }
createPruefungen(lv_id, sem_kurzbz, students, pruefung) {
return {
method: 'post',
url: '/api/frontend/v1/Noten/createPruefungen',
params: { lv_id, sem_kurzbz, students, ...pruefung }
};
},
// pruefungen: [{ uid, lehreinheit_id, datum, note, punkte }]
importPruefungen(lv_id, sem_kurzbz, pruefungen) {
return {
method: 'post',
url: '/api/frontend/v1/Noten/importPruefungen',
params: { lv_id, sem_kurzbz, pruefungen }
};
},
saveFreigabe(lv_id, sem_kurzbz, password, uids) {
return {
method: 'post',
url: '/api/frontend/v1/Noten/saveFreigabe',
params: { lv_id, sem_kurzbz, password, uids }
};
}
};
+9 -1
View File
@@ -17,6 +17,7 @@ import AbgabetoolMitarbeiter from "../../components/Cis/Abgabetool/AbgabetoolMit
import AbgabetoolAssistenz from "../../components/Cis/Abgabetool/AbgabetoolAssistenz.js";
import DeadlineOverview from "../../components/Cis/Abgabetool/DeadlineOverview.js";
import Studium from "../../components/Cis/Studium/Studium.js";
import Benotungstool from "../../components/Cis/Benotungstool/Benotungstool.js";
import StgOrgLvPlan from "../../components/Cis/LvPlan/StgOrg.js";
import OtherLvPlan from "../../components/Cis/LvPlan/OtherLvPlan.js";
@@ -77,6 +78,13 @@ const router = VueRouter.createRouter({
component: Raumsuche,
props: true
},
{
// semester first: a URL without a course must not read the semester as lv_id
path: `/Cis/Benotungstool/:sem_kurzbz?/:lv_id?`,
name: 'Benotungstool',
component: Benotungstool,
props: true
},
// Redirect old links to new format
{
path: "/CisVue/Cms/getRoomInformation/:ort_kurzbz",
@@ -152,7 +160,7 @@ const router = VueRouter.createRouter({
path: `/Cis/MyLv/:studiensemester?`,
name: 'MyLv',
component: MylvStudent,
props: true,
props: true
},
{
path: `/Cis/MyLv/Info/:studien_semester/:lehrveranstaltung_id`,
+1 -1
View File
@@ -148,4 +148,4 @@ export default {
</div>
</div>
`
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,100 @@
import BsModal from '../../Bootstrap/Modal.js';
/**
* The Freigabe dialog: the changed LV-Noten and the password. It emits the password; the parent
* writes the Freigabe.
*
* open() -> 'save-freigabe' (password)
*/
export default {
name: "BenotungstoolFreigabeDialog",
components: {
BsModal,
Password: primevue.password
},
props: {
// the rows with an LV-Note that changed since the last Freigabe
lvNoten: { type: Array, default: () => [] },
notenOptions: { type: Array, default: () => [] }
},
emits: ['save-freigabe'],
data() {
return {
visible: false,
password: ''
};
},
computed: {
/** The Freigabe writes the LV-Note, not the Zeugnisnote. So the dialog shows both. */
summary() {
return this.lvNoten.map(s => ({
uid: s.uid,
name: `${s.vorname} ${s.nachname}`,
zeugnisnote: this.bezeichnungOf(s.zeugnisnote) ?? s.zeugnisnote ?? '—',
lvNote: this.bezeichnungOf(s.lv_note) ?? s.lv_note ?? '—',
changed: (s.zeugnisnote ?? '') != (s.lv_note ?? '')
}));
}
},
methods: {
open() {
this.$refs.modal.show();
},
submit() {
const password = this.password;
// the password leaves the memory with the dialog
this.password = '';
this.$refs.modal.hide();
this.$emit('save-freigabe', password);
},
bezeichnungOf(note) {
return this.notenOptions.find(n => n.note == note)?.bezeichnung;
}
},
template: `
<bs-modal data-cy="modal-freigabe" ref="modal" class="bootstrap-prompt" dialogClass="modal-lg" bodyClass="px-4 py-4"
@hideBsModal="visible = false"
@showBsModal="visible = true">
<template v-slot:title>{{ $p.t('benotungstool/noteneingabeSpeichern') }}</template>
<template v-slot:default>
<div v-html="$capitalize($p.t('benotungstool/notenfreigabeHinweistextv4'))"></div>
<div v-if="summary.length" class="mt-3">
<div class="fw-bold mb-1">{{ $p.t('benotungstool/c4freigabeSummaryHeading', [summary.length]) }}</div>
<div class="border rounded" style="max-height: 250px; overflow-y: auto;">
<table class="table table-sm table-hover align-middle mb-0">
<thead>
<tr>
<th class="bg-body sticky-top">{{ $capitalize($p.t('benotungstool/c4student')) }}</th>
<th class="bg-body sticky-top">{{ $capitalize($p.t('benotungstool/c4freigabeSummaryCurrent')) }}</th>
<th class="bg-body sticky-top"></th>
<th class="bg-body sticky-top">{{ $capitalize($p.t('benotungstool/c4freigabeSummaryReleased')) }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in summary" :key="row.uid" :data-cy="'freigabe-row-' + row.uid">
<td>{{ row.name }} <span class="text-muted">({{ row.uid }})</span></td>
<td>{{ row.zeugnisnote }}</td>
<td class="text-center"><i class="fa fa-arrow-right"></i></td>
<td data-cy="freigabe-row-lvnote" :class="{ 'fw-bold text-success': row.changed }">{{ row.lvNote }}</td>
</tr>
</tbody>
</table>
</div>
<div class="small text-muted mt-1">{{ $p.t('benotungstool/c4freigabeSummaryLegend') }}</div>
</div>
<div v-else class="mt-3 text-center text-muted" data-cy="freigabe-summary-empty">{{ $p.t('benotungstool/c4freigabeSummaryEmpty') }}</div>
<!-- The password field exists only while the dialog is open. Else the password manager
offers its entries on every other text field, for example the date of the proposal. -->
<div v-if="visible" class="mt-3 d-flex justify-content-center" data-cy="freigabe-password">
<Password v-model="password" :feedback="false" showIcon="fa fa-eye" :toggleMask="true" :promptLabel="$p.t('benotungstool/passwort')"></Password>
</div>
</template>
<template v-slot:footer>
<button type="button" data-cy="freigabe-submit" class="btn btn-primary" :disabled="!summary.length" @click="submit">{{ $p.t('benotungstool/noteneingabeBestätigen') }}</button>
</template>
</bs-modal>
`
};
@@ -0,0 +1,204 @@
import BsModal from '../../Bootstrap/Modal.js';
import NotenlisteLinks from "./NotenlisteLinks.js";
import {dmyToIso, isoToDmy} from "../../../helpers/DateHelpers.js";
/**
* The two import dialogs. The user pastes lines from Excel: one row per line, the cells separated by a
* tab. The dialog finds the student and checks the format of each cell. It warns for a bad line and
* emits the other rows. The parent checks the Antritt chain and sends the rows.
*
* Pruefung import Kennung, Datum, Note -> 'import-pruefungen' [{ uid, lehreinheit_id, datum, note, punkte }]
* Noten import Kennung, Note -> 'import-lv-noten' [{ uid, note, punkte }]
*
* CIS_GESAMTNOTE_IMPORT_SPALTEN_* gives the column order. In the Punkte mode the Note cell holds Punkte.
*/
export default {
name: "BenotungstoolImport",
components: {
BsModal,
NotenlisteLinks,
Textarea: primevue.textarea
},
props: {
students: { type: Array, default: () => [] },
config: { type: Object, default: null },
notenOptions: { type: Array, default: () => [] },
lehrveranstaltung: { type: Object, default: null },
semKurzbz: { type: String, default: null },
lehreinheiten: { type: Array, default: () => [] },
selectedLehreinheit: { type: Object, default: null }
},
emits: ['import-pruefungen', 'import-lv-noten'],
data() {
return {
pruefungenText: '',
lvNotenText: ''
};
},
computed: {
pruefungenHint() {
return this.$capitalize(this.$p.t('benotungstool/notenimportHinweistextv6')) + this.kuerzelHint;
},
lvNotenHint() {
return this.$capitalize(this.$p.t('benotungstool/notenimportHinweistextv5')) + this.kuerzelHint;
},
/** One more bullet while the short form of tbl_note.anmerkung is allowed. */
kuerzelHint() {
if (!this.config?.CIS_GESAMTNOTE_IMPORT_NOTENKUERZEL) return '';
return '• ' + this.$capitalize(this.$p.t('benotungstool/c4importNotenkuerzel')) + '<br>';
}
},
methods: {
openPruefungen() {
this.$refs.modalPruefungen.show();
},
openLvNoten() {
this.$refs.modalLvNoten.show();
},
submitPruefungen() {
const rows = this.parseLines(this.pruefungenText, 3, 'c4importRowNotDateFormat', this.parsePruefungRow);
this.$refs.modalPruefungen.hide();
this.$emit('import-pruefungen', rows);
},
submitLvNoten() {
const rows = this.parseLines(this.lvNotenText, 2, 'c4importRowNotNoteFormat', this.parseLvNoteRow);
this.$refs.modalLvNoten.hide();
this.$emit('import-lv-noten', rows);
},
/** Splits the text into rows. A line with the wrong number of cells names only its line number. */
parseLines(text, cellCount, formatPhrase, parseRow) {
const rows = [];
text.split('\n').forEach((line, index) => {
if (line.trim() === '') return;
const lineNr = index + 1;
const cells = line.split('\t');
if (cells.length !== cellCount) {
this.$fhcAlert.alertWarning(this.$p.t('benotungstool/' + formatPhrase, [lineNr]));
return;
}
const row = parseRow(cells, lineNr);
if (row) rows.push(row);
});
return rows;
},
parsePruefungRow(cells, lineNr) {
const kennung = cells[this.columnIndex('pruefung', 'kennung')];
const student = this.findStudent(kennung);
if (!student) return this.warnLine('c4importNoStudentFoundForIdInRow', kennung, lineNr);
const datum = this.parseDatum(cells[this.columnIndex('pruefung', 'datum')]);
if (!datum) return this.warnLine('c4importInvalidDateFoundForIdInRow', kennung, lineNr);
const value = this.parseNoteCell(cells[this.columnIndex('pruefung', 'note')]);
if (!value) return this.warnLine('c4importNoGradeFoundForIdInRow', kennung, lineNr);
return { uid: student.uid, lehreinheit_id: student.lehreinheit_id, datum, ...value };
},
parseLvNoteRow(cells, lineNr) {
const kennung = cells[this.columnIndex('noten', 'kennung')];
const student = this.findStudent(kennung);
if (!student) return this.warnLine('c4importNoStudentFoundForIdInRow', kennung, lineNr);
const value = this.parseNoteCell(cells[this.columnIndex('noten', 'note')]);
if (!value) return this.warnLine('c4importNoGradeFoundForIdInRow', kennung, lineNr);
return { uid: student.uid, ...value };
},
/** @param {'pruefung'|'noten'} kind */
columnIndex(kind, name) {
const key = kind === 'pruefung' ? 'CIS_GESAMTNOTE_IMPORT_SPALTEN_PRUEFUNG' : 'CIS_GESAMTNOTE_IMPORT_SPALTEN_NOTEN';
return this.config[key].indexOf(name);
},
/** A Kennung is a uid (it starts with a letter) or a Matrikelnummer (it starts with a digit). */
findStudent(kennung) {
const value = String(kennung ?? '').trim();
if (/^[0-9]/.test(value)) return this.students.find(s => s.matrikelnr?.trim() === value);
if (/^[a-zA-Z]/.test(value)) return this.students.find(s => s.uid?.trim() === value);
return null;
},
/** @returns {string|null} 'yyyy-MM-dd', or null if the value does not match CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT */
parseDatum(value) {
const text = String(value ?? '').trim();
if (this.config.CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT === 'yyyy-MM-dd') {
// the round trip rejects a day that does not exist, for example 2026-02-31
return dmyToIso(isoToDmy(text)) === text ? text : null;
}
return dmyToIso(text);
},
/**
* The Note cell: in the Punkte mode a number (a decimal comma counts as a point), else a Note of
* the Lehre, by its key or, with CIS_GESAMTNOTE_IMPORT_NOTENKUERZEL, by its short form.
*
* @returns {{note, punkte}|null}
*/
parseNoteCell(value) {
const text = String(value ?? '').trim();
if (this.config.CIS_GESAMTNOTE_PUNKTE) return { note: null, punkte: Number.parseFloat(text.replace(',', '.')) };
const option = this.findNote(text);
return option?.lehre ? { note: option.note, punkte: null } : null;
},
findNote(text) {
if (text === '') return null;
const byKey = this.notenOptions.find(n => String(n.note).trim() === text);
if (byKey || !this.config.CIS_GESAMTNOTE_IMPORT_NOTENKUERZEL) return byKey ?? null;
const byKuerzel = this.notenOptions.filter(n => String(n.anmerkung ?? '').trim().toLowerCase() === text.toLowerCase());
return byKuerzel.length === 1 ? byKuerzel[0] : null;
},
warnLine(phrase, kennung, lineNr) {
this.$fhcAlert.alertWarning(this.$p.t('benotungstool/' + phrase, [kennung, lineNr]));
return null;
}
},
template: `
<bs-modal data-cy="modal-pruefung-import" ref="modalPruefungen" class="bootstrap-prompt" dialogClass="modal-lg" bodyClass="px-4 py-4">
<template v-slot:title>{{ $capitalize($p.t('benotungstool/c4pruefungImportieren')) }}</template>
<template v-slot:default>
<div v-html="pruefungenHint"></div>
<Textarea data-cy="pruefung-import-text" v-model="pruefungenText" rows="5" class="w-100 mt-3" :placeholder="$p.t('benotungstool/c4importPlaceholder')"></Textarea>
<NotenlisteLinks class="mt-3" :lehrveranstaltung="lehrveranstaltung" :sem_kurzbz="semKurzbz" :lehreinheiten="lehreinheiten" :selected-lehreinheit="selectedLehreinheit" />
</template>
<template v-slot:footer>
<button type="button" data-cy="pruefung-import-submit" class="btn btn-primary" @click="submitPruefungen">{{ $capitalize($p.t('benotungstool/c4import')) }}</button>
</template>
</bs-modal>
<bs-modal data-cy="modal-lvnoten-import" ref="modalLvNoten" class="bootstrap-prompt" dialogClass="modal-lg" bodyClass="px-4 py-4">
<template v-slot:title>{{ $capitalize($p.t('benotungstool/c4notenImportieren')) }}</template>
<template v-slot:default>
<div v-html="lvNotenHint"></div>
<Textarea data-cy="lvnoten-import-text" v-model="lvNotenText" rows="5" class="w-100 mt-3" :placeholder="$p.t('benotungstool/c4importNotePlaceholder')"></Textarea>
<NotenlisteLinks class="mt-3" :lehrveranstaltung="lehrveranstaltung" :sem_kurzbz="semKurzbz" :lehreinheiten="lehreinheiten" :selected-lehreinheit="selectedLehreinheit" />
</template>
<template v-slot:footer>
<button type="button" data-cy="lvnoten-import-submit" class="btn btn-primary" @click="submitLvNoten">{{ $capitalize($p.t('benotungstool/c4import')) }}</button>
</template>
</bs-modal>
`
};
@@ -0,0 +1,66 @@
// The links to the Excel Notenliste of the LV and of each Lehreinheit.
export const NotenlisteLinks = {
name: "NotenlisteLinks",
props: {
lehrveranstaltung: { type: Object, default: null },
sem_kurzbz: { type: String, default: null },
lehreinheiten: { type: Array, default: () => [] },
selectedLehreinheit: { type: Object, default: null }
},
computed: {
/** A selected Lehreinheit shows only its own link. */
shownLehreinheiten() {
if (this.selectedLehreinheit?.lehreinheit_id == null) return this.lehreinheiten;
return this.lehreinheiten.filter(le => le.lehreinheit_id === this.selectedLehreinheit.lehreinheit_id);
},
baseUrl() {
return FHC_JS_DATA_STORAGE_OBJECT.app_root
+ 'cis/private/lehre/notenliste.xls.php';
},
ready() {
const lv = this.lehrveranstaltung;
return !!(lv && lv.studiengang_kz != null && lv.lv_semester != null
&& lv.lehrveranstaltung_id != null && this.sem_kurzbz);
},
fullListUrl() {
if (!this.ready) return null;
return this.buildUrl();
}
},
methods: {
buildUrl(lehreinheit_id = null) {
const lv = this.lehrveranstaltung;
const params = new URLSearchParams({
stg: lv.studiengang_kz,
sem: lv.lv_semester,
lvid: lv.lehrveranstaltung_id,
stsem: this.sem_kurzbz
});
if (lehreinheit_id != null) params.set('lehreinheit_id', lehreinheit_id);
return this.baseUrl + '?' + params.toString();
}
},
template: `
<div v-if="ready">
<div class="fw-bold mb-2">{{ $capitalize($p.t('benotungstool/c4notenlisten')) }}</div>
<div class="mb-1">
<a class="Item" :href="fullListUrl" target="_blank" rel="noopener">
{{ $capitalize($p.t('benotungstool/c4gesamtliste')) }} {{ lehrveranstaltung.lv_bezeichnung }}
</a>
</div>
<div v-for="le in shownLehreinheiten" :key="le.lehreinheit_id" class="mb-1" style="padding-left: 1.5rem;">
<a class="Item" :href="buildUrl(le.lehreinheit_id)" target="_blank" rel="noopener">
{{ le.infoString }}
</a>
</div>
</div>
<div v-else class="text-muted">
{{ $capitalize($p.t('benotungstool/c4keineStudentenGefunden')) }}
</div>
`
};
export default NotenlisteLinks;
@@ -0,0 +1,295 @@
import BsModal from '../../Bootstrap/Modal.js';
import VueDatePicker from '../../vueDatepicker.js.php';
import ApiNoten from "../../../api/factory/noten.js";
import {debounce} from "../../../helpers/debounce.js";
import {today, toIsoDate, parseIsoDate, addDays} from "../../../helpers/DateHelpers.js";
/**
* The Pruefung dialog. It collects the input and emits it. The parent checks the Antritt chain and
* writes.
*
* openForCell(student, pruefung, field) a new or a changed Pruefung of one student -> 'save-pruefung' (student, pruefung)
* openForSelection() one new Pruefung on one day for the selection -> 'create-pruefungen' (pruefung)
*
* The table holds the selection. The multiselect emits 'select-students', the parent selects the rows,
* and the prop selectedStudents follows.
*/
export default {
name: "BenotungstoolPruefungDialog",
components: {
BsModal,
Datepicker: VueDatePicker,
Dropdown: primevue.dropdown,
InputNumber: primevue.inputnumber,
Multiselect: primevue.multiselect
},
props: {
config: { type: Object, default: null },
notenOptions: { type: Array, default: () => [] },
lvId: { default: null },
semKurzbz: { type: String, default: null },
// the students that can get a Pruefung, in the order of the table
studentOptions: { type: Array, default: () => [] },
selectedStudents: { type: Array, default: () => [] }
},
emits: ['save-pruefung', 'create-pruefungen', 'select-students'],
data() {
return {
// the student of the cell; null in the dialog for the selection
student: null,
pruefung: null,
selectedDate: today(),
selectedNote: null,
selectedPunkte: null,
// a later Pruefung locks the Note; the date stays editable
noteLocked: false,
dateMin: null,
dateMax: null,
// the Pruefung names the Lektor of the Lehreinheit, not the user; with several Lektoren the user selects one
lektoren: [],
selectedLektor: null,
debouncedShowNoteForPunkte: null
};
},
computed: {
title() {
if (!this.student) return this.$capitalize(this.$p.t('benotungstool/c4addNewPruefung'));
const phrase = this.pruefung ? 'benotungstool/editPruefungFor' : 'benotungstool/createPruefungFor';
return this.$capitalize(this.$p.t(phrase)) + ' ' + this.student.vorname + ' ' + this.student.nachname;
},
lehreNoten() {
return this.notenOptions.filter(n => n.lehre === true);
},
/** The students that get the Pruefung. */
students() {
return this.student ? [this.student] : this.selectedStudents;
},
/** The hint: for these students the new Pruefung also writes the LV-Note. */
withoutLvNote() {
return this.pruefung ? [] : this.students.filter(s => !s.verlauf.hasLvNote);
}
},
watch: {
/** Warns early if the selected Note reached its limit (verlauf.notenAtLimit); the server checks it too. */
selectedNote(option, previous) {
if (!option || !this.student) return;
// the changed Pruefung may keep its own Note
const limit = this.student.verlauf.notenAtLimit[option.note];
if (limit == null || option.note == this.pruefung?.note) return;
this.$fhcAlert.alertWarning(this.$capitalize(this.$p.t('benotungstool/c4noteLimitUeberschritten', [
option.bezeichnung, this.student.uid, limit
])));
this.selectedNote = previous;
}
},
created() {
this.debouncedShowNoteForPunkte = debounce(this.showNoteForPunkte, 500);
},
methods: {
openForCell(student, pruefung, field) {
this.student = student;
this.pruefung = pruefung;
this.loadLektoren([student.lehreinheit_id]);
// the 'antritt' column has no date, so a new Pruefung starts with today
const datum = pruefung?.datum ?? (/^\d{4}-\d{2}-\d{2}$/.test(field) ? field : null);
this.selectedDate = datum ? parseIsoDate(datum) : today();
this.selectedNote = pruefung ? this.notenOptions.find(n => n.note == pruefung.note) ?? null : null;
this.selectedPunkte = pruefung?.punkte ?? null;
// the server locks the Note of a Pruefung with a later one
this.noteLocked = !!pruefung?.note_locked;
const bounds = this.pruefungDateBounds(student, pruefung, field);
this.dateMin = bounds.min;
this.dateMax = bounds.max;
this.$refs.modal.show();
},
openForSelection() {
this.student = null;
this.pruefung = null;
this.loadLektoren(this.selectedStudents.map(s => s.lehreinheit_id));
// empty, without a Note
this.selectedDate = today();
this.selectedNote = null;
this.selectedPunkte = null;
this.noteLocked = false;
this.dateMin = null;
this.dateMax = null;
this.$refs.modal.show();
},
/**
* The date of a Pruefung stays strictly between its neighbours, so the order of the Antritte
* stays. A column without a date ('antritt_2') means a new Pruefung after all others.
*
* @returns {{min: Date|null, max: Date|null}} the inclusive bounds of the date picker
*/
pruefungDateBounds(student, pruefung, field) {
const own = String(pruefung?.datum ?? field ?? '').slice(0, 10);
const reference = /^\d{4}-\d{2}-\d{2}$/.test(own) ? own : '9999-12-31';
let lower = null;
let upper = null;
(student.verlauf.pruefungen ?? []).forEach(p => {
if (pruefung && p.pruefung_id === pruefung.pruefung_id) return;
const day = String(p.datum ?? '').slice(0, 10);
if (!day) return;
if (day < reference && (lower === null || day > lower)) lower = day;
if (day > reference && (upper === null || day < upper)) upper = day;
});
return {
min: lower ? addDays(parseIsoDate(lower), 1) : null,
max: upper ? addDays(parseIsoDate(upper), -1) : null
};
},
submit() {
if (this.student) this.submitForCell(); else this.submitForSelection();
},
submitForCell() {
// a typed date can pass the bounds of the date picker
if ((this.dateMin && this.selectedDate < this.dateMin) || (this.dateMax && this.selectedDate > this.dateMax)) {
this.$fhcAlert.alertWarning(this.$capitalize(this.$p.t('benotungstool/pruefungDatumOutOfRangeHint')));
return;
}
// Without a selection the Note stays empty; the server writes 'Noch nicht eingetragen'. Without
// the Punkte mode a 0 would be a value: the server derives the Note from it.
const pruefung = {
pruefung_id: this.pruefung?.pruefung_id ?? null,
lehreinheit_id: this.student.lehreinheit_id,
datum: toIsoDate(this.selectedDate),
note: this.noteLocked ? this.pruefung.note : (this.selectedNote?.note ?? ''),
punkte: this.config.CIS_GESAMTNOTE_PUNKTE ? (this.noteLocked ? this.pruefung.punkte : this.selectedPunkte) ?? null : null,
mitarbeiter_uid: this.selectedLektor
};
this.$refs.modal.hide();
this.$emit('save-pruefung', this.student, pruefung);
},
submitForSelection() {
// a missing LV-Note blocks nothing: the server writes it with the Note of this Pruefung
const pruefung = {
datum: toIsoDate(this.selectedDate),
note: this.selectedNote?.note ?? null,
punkte: this.selectedPunkte ?? null,
mitarbeiter_uid: this.selectedLektor
};
this.$refs.modal.hide();
this.$emit('create-pruefungen', pruefung);
},
/** The parent selects the rows in the table; the Lektoren follow the new selection. */
selectStudents(students) {
this.$emit('select-students', students);
this.loadLektoren(students.map(s => s.lehreinheit_id));
},
/** The Lektoren of the Lehreinheit. With exactly one Lektor the selection stays empty; the server sets that Lektor. */
loadLektoren(lehreinheitIds) {
this.lektoren = [];
this.selectedLektor = null;
// a Lektor selection makes sense only if all students share one Lehreinheit
const distinct = [...new Set(lehreinheitIds.filter(id => id != null))];
if (distinct.length !== 1) return;
this.$api.call(ApiNoten.getLektorenForLehreinheit(this.lvId, this.semKurzbz, distinct[0])).then(res => {
this.lektoren = res.data.map(l => ({
...l,
name: `${l.vorname ?? ''} ${l.nachname ?? ''}`.trim() || l.mitarbeiter_uid
}));
// a selection only if there is something to select
if (this.lektoren.length > 1) {
const current = this.pruefung?.mitarbeiter_uid;
this.selectedLektor = this.lektoren.some(l => l.mitarbeiter_uid === current) ? current : this.lektoren[0].mitarbeiter_uid;
}
});
},
/** In the Punkte mode the dialog shows the Note of the Punkte, from the Notenschluessel of the LV. */
showNoteForPunkte(event) {
const punkte = event.value === '' ? null : event.value;
this.$api.call(ApiNoten.getNoteByPunkte(this.lvId, this.semKurzbz, punkte)).then(res => {
if (res.data >= 0) this.selectedNote = this.notenOptions.find(n => n.note == res.data);
});
},
/** The multiselect label: "uid – Nachname Vorname – Antritte: n". */
studentLabel(student) {
return `${student.uid} – ${student.nachname} ${student.vorname} – ${this.$capitalize(this.$p.t('benotungstool/c4antrittCountv2'))}: ${student.verlauf.antrittCount}`;
}
},
template: `
<bs-modal data-cy="modal-pruefung" ref="modal" class="bootstrap-prompt" dialogClass="modal-lg" bodyClass="px-4 py-4">
<template v-slot:title>{{ title }}</template>
<template v-slot:default>
<div class="row align-items-center justify-content-center">
<div class="col-3 text-center">{{ $capitalize($p.t('benotungstool/c4date')) }}:</div>
<div class="col-6" data-cy="pruefung-datum">
<datepicker v-model="selectedDate" :clearable="false" :enableTimePicker="false" format="dd.MM.yyyy" placeholder="TT.MM.JJJJ"
:min-date="dateMin" :max-date="dateMax" :text-input="true" :auto-apply="true">
</datepicker>
</div>
</div>
<div v-if="noteLocked" class="row mt-2 justify-content-center">
<div class="col-9 text-center text-muted small" data-cy="pruefung-note-locked">{{ $capitalize($p.t('benotungstool/pruefungNoteLockedHint')) }}</div>
</div>
<div v-if="config?.CIS_GESAMTNOTE_PUNKTE" class="row mt-3 align-items-center justify-content-center">
<div class="col-3 text-center">{{ $capitalize($p.t('benotungstool/c4punkte')) }}:</div>
<div class="col-6">
<InputNumber data-cy="pruefung-punkte" v-model="selectedPunkte" @input="debouncedShowNoteForPunkte" :disabled="noteLocked"
inputId="pruefungPunkte" :min="0" :max="100000" class="w-100">
</InputNumber>
</div>
</div>
<div class="row mt-3 align-items-center justify-content-center">
<div class="col-3 text-center">{{ $capitalize($p.t('lehre/note')) }}:</div>
<div class="col-6">
<Dropdown data-cy="pruefung-note" :placeholder="$capitalize($p.t('lehre/note'))" :disabled="config?.CIS_GESAMTNOTE_PUNKTE || noteLocked"
:style="{'width': '100%'}" optionLabel="bezeichnung" v-model="selectedNote" :options="lehreNoten" showClear>
</Dropdown>
</div>
</div>
<div v-if="!student" class="row mt-3 align-items-center justify-content-center">
<div class="col-3 text-center">{{ $capitalize($p.t('benotungstool/prueflingSelectionv2')) }}:</div>
<div class="col-6">
<Multiselect data-cy="pruefung-students" :modelValue="selectedStudents" @update:modelValue="selectStudents" :options="studentOptions" :optionLabel="studentLabel"
:placeholder="$capitalize($p.t('benotungstool/prueflingSelectionv2'))" :maxSelectedLabels="3" showToggleAll class="w-100" />
</div>
</div>
<div v-if="lektoren.length > 1" class="row mt-3 align-items-center justify-content-center">
<div class="col-3 text-center">{{ $capitalize($p.t('benotungstool/c4lektor')) }}:</div>
<div class="col-6">
<Dropdown data-cy="pruefung-lektor" :style="{'width': '100%'}" v-model="selectedLektor" :options="lektoren"
optionLabel="name" optionValue="mitarbeiter_uid">
</Dropdown>
</div>
</div>
<div v-if="withoutLvNote.length" class="alert alert-info mt-3 mb-0 py-2" data-cy="pruefung-without-lvnote">
<div>{{ $p.t('benotungstool/c4lvNoteWirdAngelegtHinweis') }}</div>
<div v-if="!student" class="small mt-1">{{ withoutLvNote.map(s => s.vorname + ' ' + s.nachname + ' (' + s.uid + ')').join(', ') }}</div>
</div>
</template>
<template v-slot:footer>
<button type="button" data-cy="pruefung-submit" class="btn btn-primary" @click="submit">
{{ student ? $capitalize($p.t('global/speichern')) : $capitalize($p.t('benotungstool/c4addNewPruefung')) }}
</button>
</template>
</bs-modal>
`
};
@@ -0,0 +1,122 @@
/**
* The layout of the Benotungstool table: what the browser keeps (column order, width, visibility,
* filters, sort), the sticky columns, and the scroll position during a render.
*/
/** Reads a value that the browser keeps. Private mode or a broken value give `fallback`. */
export function loadKept(key, fallback) {
try {
const value = localStorage.getItem(key);
return value === null ? fallback : JSON.parse(value);
} catch (e) {
return fallback;
}
}
export function saveKept(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (e) {
// private mode: the value lasts until the next load
}
}
/** Keeps the layout of the columns in `fields`. The Pruefung columns change per LV, so they are left out. */
export function saveLayout(key, table, fields) {
saveKept(key, {
columns: table.getColumnLayout()
.filter(col => fields.includes(col.field))
.map(col => ({ field: col.field, visible: col.visible, width: col.width })),
sort: table.getSorters().map(s => ({ field: s.field, dir: s.dir })),
filters: table.getFilters(),
headerFilters: table.getHeaderFilters()
});
}
/**
* The column definitions in the kept order, with the kept width and visibility. New columns come last.
* Apply it BEFORE Tabulator takes the columns: after that the definitions and the Vue titles conflict.
*/
export function restoreColumns(columns, layout) {
if (!layout?.columns) return columns;
const byField = new Map(columns.map(c => [c.field, c]));
const restored = [];
layout.columns.forEach(kept => {
const column = byField.get(kept.field);
if (!column) return;
restored.push({ ...column, width: kept.width, visible: kept.visible });
byField.delete(kept.field);
});
byField.forEach(column => restored.push(column));
return restored;
}
/** Applies the kept filters and sort. Call it once, after the first rows: then the Pruefung columns exist. */
export function restoreFiltersAndSort(table, layout) {
if (layout?.filters?.length) table.setFilter(layout.filters);
(layout?.headerFilters ?? []).forEach(hf => table.setHeaderFilterValue(hf.field, hf.value));
// a Pruefung column of another LV does not exist in this table
const fields = table.getColumns().map(column => column.getField());
const sorters = (layout?.sort ?? [])
.filter(s => fields.includes(s.field))
.map(s => ({ column: s.field, dir: s.dir }));
if (sorters.length) table.setSort(sorters);
}
/**
* Pins the selected columns to the left. A `sticky-on-<field>` class on the container switches one
* column on (see Benotungstool.css), and `--sl-<field>` is its left offset: the width of the pinned
* columns before it, so that the pinned columns stand next to each other.
*/
export function applyStickyColumns(element, table, fields, selected) {
if (!element || !table) return;
let offset = 0;
// the display order, so a moved column keeps the right offset
table.getColumns().forEach(column => {
const field = column.getField();
if (!fields.includes(field)) return;
const pinned = selected.includes(field);
element.classList.toggle('sticky-on-' + field, pinned);
element.style.setProperty('--sl-' + field, offset + 'px');
if (pinned) offset += column.getWidth();
});
}
/**
* Keeps the scroll position while `operation` renders the table again.
*
* Read and write .tabulator-tableholder directly: Tabulator does not update rowManager.scrollLeft.
* Set the position again in the next two frames: Tabulator renders later and resets it, and
* Vue.nextTick is too early.
*/
export function preserveScroll(table, operation) {
const holder = table?.element?.querySelector('.tabulator-tableholder');
const left = holder?.scrollLeft ?? 0;
const top = holder?.scrollTop ?? 0;
const result = operation();
// nothing to keep; do not work against a scroll to the top that the user wants
if (!holder || (!left && !top)) return result;
const restore = () => {
holder.scrollLeft = left;
holder.scrollTop = top;
};
restore();
requestAnimationFrame(() => { restore(); requestAnimationFrame(restore); });
// setData returns a promise; set the position again after the load
if (result && typeof result.then === 'function') result.then(() => requestAnimationFrame(restore));
return result;
}
+20
View File
@@ -0,0 +1,20 @@
export const MobilityLegende = {
name: 'MobilityLegende',
template:`
<div class="col-auto" style="max-width: 60vw">
<!-- TODO: phrasen definieren & verwenden-->
<div class="row" style="font-weight: bold"><h6>Legende</h6></div>
<div class="row"><h6>(i) ... Incoming</h6></div>
<div class="row"><h6>(o) ... Outgoing</h6></div>
<div class="row"><h6>(ar) ... angerechnet </h6></div>
<div class="row"><h6>(iar) ... intern angerechnet</h6></div>
<div class="row"><h6>(nz) ... nicht zugelassen</h6></div>
<div class="row"><h6>(ma) ... MitarbeiterIn</h6></div>
<div class="row"><h6>(a.o.) ... Außerordentliche/r HörerIn</h6></div>
<div class="row"><h6>(d.d.) ... Double Degree Program</h6></div>
</div>
`
};
export default MobilityLegende;
+109
View File
@@ -59,3 +59,112 @@ export function formatDate(d) {
throw new TypeError("The parameter provided for this function is not a string or a Date object, please refere to the function formatDate in the DateHelpers.js file");
}
}
/**
* today in the time zone of the instance, as a Date at midnight
*
* The browser can stand in another time zone than the server. A date from the browser then names the
* next day, and the server refuses the entry as a date in the future.
*
* @returns {Date} today at 00:00 local time, with the day of the instance time zone.
*/
export function today() {
const zone = (typeof FHC_JS_DATA_STORAGE_OBJECT !== "undefined")
? FHC_JS_DATA_STORAGE_OBJECT.timezone
: null;
if (zone && typeof luxon !== "undefined")
{
const now = luxon.DateTime.local().setZone(zone);
if (now.isValid) return new Date(now.year, now.month - 1, now.day);
}
// without the time zone of the instance the browser decides
const local = new Date();
return new Date(local.getFullYear(), local.getMonth(), local.getDate());
}
/**
* Date -> 'YYYY-MM-DD' in local time. An API takes the day, never a timestamp.
*
* @param {Date} date
* @returns {string}
*/
export function toIsoDate(date) {
return `${date.getFullYear()}-${numberPadding(date.getMonth() + 1)}-${numberPadding(date.getDate())}`;
}
/**
* 'YYYY-MM-DD' (a longer string is cut) -> Date at 00:00 local time.
*
* @param {string} iso
* @returns {Date|null}
*/
export function parseIsoDate(iso) {
const parts = String(iso ?? '').slice(0, 10).split('-');
if (parts.length !== 3) return null;
return new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
}
/**
* 'YYYY-MM-DD HH:MM:SS' or 'YYYY-MM-DD' -> Date in local time.
*
* @param {string} timestamp
* @returns {Date|null}
*/
export function parseTimestamp(timestamp) {
if (!timestamp) return null;
const [datePart, timePart] = String(timestamp).trim().split(' ');
const [year, month, day] = (datePart ?? '').split('-').map(Number);
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null;
const [hour, minute, second] = (timePart ?? '').split(':').map(Number);
return new Date(year, month - 1, day,
Number.isFinite(hour) ? hour : 0,
Number.isFinite(minute) ? minute : 0,
Number.isFinite(second) ? second : 0);
}
/**
* 'YYYY-MM-DD' (a longer string is cut) -> 'DD.MM.YYYY'. A string operation: no time zone applies.
*
* @param {string} iso
* @returns {string} an empty string for an invalid value
*/
export function isoToDmy(iso) {
const parts = String(iso ?? '').slice(0, 10).split('-');
if (parts.length !== 3) return '';
return `${parts[2]}.${parts[1]}.${parts[0]}`;
}
/**
* 'DD.MM.YYYY' -> 'YYYY-MM-DD'.
*
* @param {string} dmy
* @returns {string|null} null if the value has another format or the day does not exist (31.02.)
*/
export function dmyToIso(dmy) {
const match = String(dmy ?? '').trim().match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (!match) return null;
const [, day, month, year] = match.map(Number);
const date = new Date(year, month - 1, day);
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) return null;
return `${match[3]}-${match[2]}-${match[1]}`;
}
/**
* @param {Date} date
* @param {number} days
* @returns {Date} a new Date, $days later
*/
export function addDays(date, days) {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
+12
View File
@@ -1,3 +1,15 @@
/**
* Makes a value safe for an HTML string. A Tabulator formatter string becomes innerHTML, therefore
* a value from the database or from an addon must not carry markup.
*/
export function escapeHtml(value) {
if (value === null || value === undefined) return '';
const replacements = {'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'};
return String(value).replace(/[&<>"']/g, (character) => replacements[character]);
}
export function capitalize(string) {
if (!string) return "";
return string[0].toUpperCase() + string.slice(1);
+9
View File
@@ -0,0 +1,9 @@
export function debounce(fn, delay) {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
fn(...args)
}, delay);
};
}
+25
View File
@@ -0,0 +1,25 @@
import {escapeHtml} from "../../helpers/StringHelpers.js";
export function centeredTextFormatter(cell) {
const longForm = cell.getValue()
if(!longForm) return
const data = cell.getData()
const entry = Object.entries(data).find(entry => entry[1] == longForm)
// shortFormKey must have same keyname as longForm but with 'Short' appended
const shortForm = data[entry[0]+'Short']
if(shortForm && longForm) {
return `<div style="display: flex; justify-content: start; align-items: center; height: 100%; width: 100%;">
<span class="full-text" style="max-width: 100%; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; margin: 0px;">
${escapeHtml(longForm)}
</span>
<span class="short-text" style="font-weight: bold; display: none;">
${escapeHtml(shortForm)}
</span>
</div>`;
} else {
return '<div style="display: flex; justify-content: start; align-items: center; height: 100%">' +
'<p style="max-width: 100%; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; margin: 0px;">'+escapeHtml(longForm)+'</p></div>'
}
}
+2
View File
@@ -150,6 +150,8 @@ $berechtigungen = array(
array('lehre','Berechtigung fuer CIS-Seite'),
array('lehre/abgabetool','Projektabgabetool, Studentenansicht'),
array('lehre/abgabetool:download','Download von Projektarbeitsabgaben'),
array('lehre/benotungstool','Cis4 Gesamtnoteneingabe'),
array('lehre/benotungstool_assistenz','Cis4 Gesamtnoteneingabe für Assistenz'),
array('lehre/freifach','Freifachverwaltung'),
array('lehre/lehrfach','Lehrfachverwaltung'),
array('lehre/lehrfach:begrenzt','Lehrfachverwaltung - nur aktiv aenderbar, nur aktive LF werden angezeigt'),
@@ -0,0 +1,17 @@
<?php
if (! defined('DB_NAME')) exit('No direct script access allowed');
if($result = $db->db_query("SELECT 1 FROM public.tbl_vorlage WHERE vorlage_kurzbz = 'Notenfreigabe'"))
{
if($db->db_num_rows($result) === 0)
{
$qry = "INSERT INTO public.tbl_vorlage (vorlage_kurzbz, bezeichnung, anmerkung, mimetype)
VALUES ('Notenfreigabe', 'Notenfreigabe', null, 'text/html')
ON CONFLICT (vorlage_kurzbz) DO NOTHING;";
if(!$db->db_query($qry))
echo '<strong>system.tbl_vorlage: '.$db->db_last_error().'</strong><br>';
else
echo "<br>system.tbl_vorlage Notenfreigabe hinzugefuegt";
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,720 @@
-- Seeder of feature-60873/GesamtnoteneingabeCis4. A "-- ==== <group> ====" line starts a group; tests/cypress/tools/dbCheck.js applies single groups.
-- ==== helper_functions ====
-- Semester helpers for group studiengang_5. BS001_helper_functions.sql does not define them.
CREATE OR REPLACE FUNCTION CurrentSemester() RETURNS varchar(32) AS $$
DECLARE res varchar(32);
BEGIN
SELECT studiensemester_kurzbz into res FROM public.tbl_studiensemester WHERE start<=now() AND ende>=now() ORDER BY start DESC LIMIT 1;
return res;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION LastActiveSemester() RETURNS varchar(32) AS $$
DECLARE res varchar(32);
BEGIN
SELECT studiensemester_kurzbz into res FROM public.tbl_studiensemester WHERE start<=now() ORDER BY start DESC LIMIT 1;
return res;
END;
$$ LANGUAGE plpgsql;
-- ==== studiengang_5 ====
-- Demodaten fuer Studiengang 5: Organisationseinheit, Studienordnung, Studiengang, Lehrveranstaltungen
INSERT INTO public.tbl_organisationseinheit (oe_kurzbz, oe_parent_kurzbz, bezeichnung, organisationseinheittyp_kurzbz, aktiv, mailverteiler, freigabegrenze, kurzzeichen, lehre, standort, warn_semesterstunden_frei, warn_semesterstunden_fix, standort_id)
VALUES('stg5', 'studiengaenge', 'Studiengang 5', 'Studiengang', true, false, NULL, NULL, true, NULL, NULL, NULL, NULL);
INSERT INTO public.tbl_studiengang (studiengang_kz,kurzbz,kurzbzlang,typ,bezeichnung,english,farbe,email,telefon,max_semester,max_verband,max_gruppe,erhalter_kz,bescheid,bescheidbgbl1,bescheidbgbl2,bescheidgz,bescheidvom,titelbescheidvom,aktiv,ext_id,orgform_kurzbz,zusatzinfo_html,moodle,oe_kurzbz,sprache,testtool_sprachwahl,studienplaetze,lgartcode,mischform,projektarbeit_note_anzeige,melderelevant,foerderrelevant,standort_code,onlinebewerbung,melde_studiengang_kz) VALUES
(5,'S5','STG5','b','Studiengang 5','Studiengang 5',NULL,'[email protected]',NULL,6,'B','2',5,NULL,NULL,NULL,NULL,NULL,NULL,true,NULL,'VZ','',true,'stg5','German',true,NULL,NULL,false,true,true,true,NULL,false,'0002')
;
INSERT INTO "system".tbl_benutzerrolle
(rolle_kurzbz, berechtigung_kurzbz, uid, funktion_kurzbz, oe_kurzbz, art, studiensemester_kurzbz, "start", ende, negativ, updateamum, updatevon, insertamum, insertvon, kostenstelle_id, anmerkung)
VALUES('assistenz', NULL, 'demoassistenz', NULL, 'stg5', 'suid', NULL, '2025-09-18', NULL, false, NULL, NULL, '2025-09-18 15:46:02.000', 'demoadmin', NULL, NULL);
INSERT INTO public.tbl_lehrverband (studiengang_kz, semester, verband, gruppe, aktiv, bezeichnung, ext_id, orgform_kurzbz) VALUES
(5, 1, '','', true, null, null, null),
(5, 1, 'A','', true, null, null, null),
(5, 1, 'B','', true, null, null, null),
(5, 2, '','', true, null, null, null),
(5, 2, 'A','', true, null, null, null),
(5, 2, 'B','', true, null, null, null),
(5, 3, '','', true, null, null, null),
(5, 3, 'A','', true, null, null, null),
(5, 3, 'B','', true, null, null, null),
(5, 4, '','', true, null, null, null),
(5, 4, 'A','', true, null, null, null),
(5, 4, 'B','', true, null, null, null),
(5, 5, '','', true, null, null, null),
(5, 5, 'A','', true, null, null, null),
(5, 5, 'B','', true, null, null, null),
(5, 6, '','', true, null, null, null),
(5, 6, 'A','', true, null, null, null),
(5, 6, 'B','', true, null, null, null)
;
INSERT INTO lehre.tbl_studienordnung (studienordnung_id, studiengang_kz,"version",gueltigvon,gueltigbis,bezeichnung,ects,studiengangbezeichnung,studiengangbezeichnung_englisch,studiengangkurzbzlang,akadgrad_id,insertamum,insertvon,updateamum,updatevon,ext_id,status_kurzbz,standort_id) VALUES
(501, 5,'1','WS2023',NULL,'BS5-WS2023',180.00,'Studiengang 5','Studiengang 5','STG5',NULL,'2023-08-24 14:25:41.728215','auto',NULL,NULL,NULL,'development',NULL);
INSERT INTO lehre.tbl_studienplan (studienplan_id, studienordnung_id,orgform_kurzbz,"version",bezeichnung,regelstudiendauer,sprache,aktiv,semesterwochen,testtool_sprachwahl,insertamum,insertvon,updateamum,updatevon,ext_id,ects_stpl,pflicht_sws,pflicht_lvs,onlinebewerbung_studienplan) VALUES
(5011, 501,'VZ','1','BS5-WS2023-VZ',6,NULL,true,15,true,'2023-08-24 14:26:12.751416','auto',NULL,NULL,NULL,180.00,NULL,NULL,true);
INSERT INTO lehre.tbl_lehrveranstaltung (lehrveranstaltung_id, kurzbz,bezeichnung,studiengang_kz,semester,sprache,ects,semesterstunden,anmerkung,lehre,lehreverzeichnis,aktiv,planfaktor,planlektoren,planpersonalkosten,plankostenprolektor,updateamum,updatevon,insertamum,insertvon,ext_id,sort,zeugnis,koordinator,projektarbeit,lehrform_kurzbz,bezeichnung_english,orgform_kurzbz,incoming,lehrmodus_kurzbz,lehrtyp_kurzbz,oe_kurzbz,raumtyp_kurzbz,anzahlsemester,semesterwochen,lvnr,farbe,old_lehrfach_id,semester_alternativ,sws,lvs,alvs,lvps,las,benotung,lvinfo,lehrauftrag,lehrveranstaltung_template_id) VALUES
(511, 'MOD1.1','Sport',5,1,'German',8.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'MOD','Communication','VZ',5,'regulaer','modul','stg5',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
(512, 'MOD1.2','Mathematik',5,1,'German',8.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'MOD','Mathematics','VZ',5,'regulaer','modul','stg5',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
(513, 'MOD1.3','Grundlagen',5,1,'German',8.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'MOD','Basics','VZ',5,'regulaer','modul','stg5',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
(514, 'MOD1.4','Sprachen',5,1,'English',6.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'MOD','Communication','VZ',5,'regulaer','modul','stg5',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
(521, 'MOD2.1','Labor',5,2,'German',10.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'MOD','Laboratory','VZ',5,'regulaer','modul','stg5',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
(522, 'MOD2.2','Mathematik',5,2,'German',10.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'MOD','Mathematics','VZ',5,'regulaer','modul','stg5',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
(523, 'MOD2.3','Sprachen',5,2,'English',10.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'MOD','Communication','VZ',5,'regulaer','modul','stg5',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
(531, 'MOD3.1','Mathematik',5,3,'German',15.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'MOD','Mathematics','VZ',5,'regulaer','modul','stg5',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
(532, 'MOD3.2','Elektrische Signale',5,3,'German',15.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'MOD','Electric Signals','VZ',5,'regulaer','modul','stg5',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
(541, 'MOD4.1','Messtechnik',5,4,'German',15.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'MOD','Measurement Technology','VZ',5,'regulaer','modul','stg5',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
(542, 'MOD4.2','Forschung',5,4,'German',15.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'MOD','Research','VZ',5,'regulaer','modul','stg5',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
(551, 'MOD5.1','Communication',5,5,'English',15.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'MOD','Communication','VZ',5,'regulaer','modul','stg5',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
(552, 'MOD5.2','Regelungstechnik',5,5,'German',15.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'MOD','Control Engineering','VZ',5,'regulaer','modul','stg5',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
(561, 'MOD6.1','Bachelorarbeit',5,6,'German',30.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'MOD','Bachelor Thesis','VZ',5,'regulaer','modul','stg5',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL)
;
INSERT INTO lehre.tbl_lehrveranstaltung (lehrveranstaltung_id, kurzbz,bezeichnung,studiengang_kz,semester,sprache,ects,semesterstunden,anmerkung,lehre,lehreverzeichnis,aktiv,planfaktor,planlektoren,planpersonalkosten,plankostenprolektor,updateamum,updatevon,insertamum,insertvon,ext_id,sort,zeugnis,koordinator,projektarbeit,lehrform_kurzbz,bezeichnung_english,orgform_kurzbz,incoming,lehrmodus_kurzbz,lehrtyp_kurzbz,oe_kurzbz,raumtyp_kurzbz,anzahlsemester,semesterwochen,lvnr,farbe,old_lehrfach_id,semester_alternativ,sws,lvs,alvs,lvps,las,benotung,lvinfo,lehrauftrag,lehrveranstaltung_template_id) VALUES
(5111, 'LS','Leistungssport',5,1,'German',8.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'VO','Communication','VZ',5,'regulaer','lv','kfSprachen',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,true,true,true,NULL),
(5121, 'MAT','Mathematik 1',5,1,'German',8.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'ILV','Mathematics','VZ',5,'regulaer','lv','kfMath',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,true,true,true,NULL),
(5131, 'GL','Grundlagen der Programmierung',5,1,'German',8.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'ILV','Development Basics','VZ',5,'regulaer','lv','kfTech',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,true,true,true,NULL),
(5141, 'ENG','Englisch 1',5,1,'English',6.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'VO','Communication','VZ',5,'regulaer','lv','kfSprachen',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,true,true,true,NULL),
(5211, 'EL','Elektronik Labor',5,2,'German',10.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'LAB','Laboratory','VZ',5,'regulaer','lv','kfTech',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,true,true,true,NULL),
(5221, 'MAT','Mathematik 2',5,2,'German',10.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'ILV','Mathematics','VZ',5,'regulaer','lv','kfMath',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,true,true,true,NULL),
(5231, 'ENG','Communcation Englisch',5,2,'English',10.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'VO','Communication','VZ',5,'regulaer','lv','kfSprachen',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,true,true,true,NULL),
(5311, 'MAT','Mathematik 3',5,3,'German',15.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'ILV','Mathematics','VZ',5,'regulaer','lv','kfMath',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,true,true,true,NULL),
(5321, 'ES','Elektrische Signale',5,3,'German',15.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'ILV','Electric Signals','VZ',5,'regulaer','lv','kfTech',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,true,true,true,NULL),
(5411, 'MT','Messtechnik',5,4,'German',15.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'ILV','Measurement Technology','VZ',5,'regulaer','lv','kfTech',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,true,true,true,NULL),
(5421, 'BAN','Bewegungsanalyse',5,4,'German',15.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'ILV','Movement Analytics','VZ',5,'regulaer','lv','kfTech',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,true,true,true,NULL),
(5511, 'ENG','Communication',5,5,'English',15.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'VO','Communication','VZ',5,'regulaer','lv','kfSprachen',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,true,true,true,NULL),
(5521, 'RT','Regelungstechnik',5,5,'German',15.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'ILV','Control Engineering','VZ',5,'regulaer','lv','kfTech',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,true,true,true,NULL),
(5611, 'BA','Bachelorarbeit',5,6,'German',30.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'SE','Bachelor Thesis','VZ',5,'regulaer','lv','stg5',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,true,true,true,NULL)
;
INSERT INTO lehre.tbl_studienplan_lehrveranstaltung (studienplan_lehrveranstaltung_id, studienplan_id,lehrveranstaltung_id,semester,studienplan_lehrveranstaltung_id_parent,pflicht,koordinator,insertamum,insertvon,updateamum,updatevon,sort,ext_id,curriculum,export,genehmigung) VALUES
-- MODULES in the Studienplan
(501, 5011,511,1,NULL,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
(502, 5011,512,1,NULL,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
(503, 5011,513,1,NULL,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
(504, 5011,514,1,NULL,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
(505, 5011,521,2,NULL,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
(506, 5011,522,2,NULL,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
(507, 5011,523,2,NULL,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
(508, 5011,531,3,NULL,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
(509, 5011,532,3,NULL,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
(510, 5011,541,4,NULL,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
(511, 5011,542,4,NULL,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
(512, 5011,551,5,NULL,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
(513, 5011,552,5,NULL,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
(514, 5011,561,6,NULL,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
-- LVS zu Modulen
(515, 5011,5111,1,501,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
(516, 5011,5121,1,502,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
(517, 5011,5131,1,503,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
(518, 5011,5141,1,504,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
(519, 5011,5211,2,505,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
(520, 5011,5221,2,519,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
(521, 5011,5231,2,507,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
(522, 5011,5311,3,508,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
(523, 5011,5321,3,509,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
(524, 5011,5411,4,510,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
(525, 5011,5421,4,511,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
(526, 5011,5511,5,512,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
(527, 5011,5521,5,513,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
(528, 5011,5611,6,514,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true)
;
INSERT INTO lehre.tbl_studienplan_semester (studienplan_id, studiensemester_kurzbz, semester) VALUES
(5011, 'WS2023', 1),
(5011, 'SS2024', 2),
(5011, 'WS2024', 1),
(5011, 'WS2024', 3),
(5011, 'SS2025', 2),
(5011, 'SS2025', 4),
(5011, 'WS2025', 1),
(5011, 'WS2025', 3),
(5011, 'WS2025', 5),
(5011, 'SS2026', 2),
(5011, 'SS2026', 4),
(5011, 'SS2026', 6);
INSERT INTO lehre.tbl_lehreinheit (lehreinheit_id, lehrveranstaltung_id, studiensemester_kurzbz, lehrfach_id_old, lehrform_kurzbz, stundenblockung, wochenrythmus, start_kw, raumtyp, raumtypalternativ, sprache, lehre, anmerkung, unr, lvnr, updateamum, updatevon, insertamum, insertvon, ext_id, lehrfach_id, gewicht)
VALUES(51001, 5221, COALESCE(CurrentSemester(), LastActiveSemester()), NULL, 'VO', 2, 1, NULL, 'Dummy', 'Dummy', 'German', true, NULL, 5221, NULL, '2023-08-24 14:54:43.000', 'ma0646', '2023-08-24 14:54:43.000', 'ma0646', NULL, 5221, 1);
INSERT INTO lehre.tbl_lehreinheitgruppe
(lehreinheit_id, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id)
VALUES(51001, 5, 1, 'A', NULL, NULL, NULL, NULL, '2023-08-24 14:58:26.000', 'ma0646', NULL);
INSERT INTO lehre.tbl_lehreinheitgruppe
(lehreinheit_id, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id)
VALUES(51001, 5, 1, 'B', NULL, NULL, NULL, NULL, '2023-08-24 14:58:26.000', 'ma0646', NULL);
INSERT INTO lehre.tbl_lehreinheitmitarbeiter
(lehreinheit_id, mitarbeiter_uid, lehrfunktion_kurzbz, planstunden, stundensatz, faktor, anmerkung, bismelden, updateamum, updatevon, insertamum, insertvon, ext_id, semesterstunden, standort_id, vertrag_id)
VALUES(51001, 'demolektor1', 'Lektor', 0, NULL, 1.00, NULL, true, NULL, NULL, '2023-08-24 14:58:40.000', 'ma0646', NULL, 0.00, NULL, NULL);
INSERT INTO lehre.tbl_lehreinheit (lehreinheit_id, lehrveranstaltung_id, studiensemester_kurzbz, lehrfach_id_old, lehrform_kurzbz, stundenblockung, wochenrythmus, start_kw, raumtyp, raumtypalternativ, sprache, lehre, anmerkung, unr, lvnr, updateamum, updatevon, insertamum, insertvon, ext_id, lehrfach_id, gewicht)
VALUES(51002, 5121, COALESCE(CurrentSemester(), LastActiveSemester()), NULL, 'VO', 2, 1, NULL, 'Dummy', 'Dummy', 'German', true, NULL, 5121, NULL, '2023-08-24 14:54:43.000', 'ma0646', '2023-08-24 14:54:43.000', 'ma0646', NULL, 5121, 1);
INSERT INTO lehre.tbl_lehreinheitgruppe
(lehreinheit_id, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id)
VALUES(51002, 5, 1, 'B', NULL, NULL, NULL, NULL, '2023-08-24 14:58:26.000', 'ma0646', NULL);
INSERT INTO lehre.tbl_lehreinheitmitarbeiter
(lehreinheit_id, mitarbeiter_uid, lehrfunktion_kurzbz, planstunden, stundensatz, faktor, anmerkung, bismelden, updateamum, updatevon, insertamum, insertvon, ext_id, semesterstunden, standort_id, vertrag_id)
VALUES(51002, 'demolektor2', 'Lektor', 0, NULL, 1.00, NULL, true, NULL, NULL, '2023-08-24 14:58:40.000', 'ma0646', NULL, 0.00, NULL, NULL);
INSERT INTO lehre.tbl_lehreinheit (lehreinheit_id, lehrveranstaltung_id, studiensemester_kurzbz, lehrfach_id_old, lehrform_kurzbz, stundenblockung, wochenrythmus, start_kw, raumtyp, raumtypalternativ, sprache, lehre, anmerkung, unr, lvnr, updateamum, updatevon, insertamum, insertvon, ext_id, lehrfach_id, gewicht)
VALUES(51003, 5141, COALESCE(CurrentSemester(), LastActiveSemester()), NULL, 'VO', 2, 1, NULL, 'Dummy', 'Dummy', 'German', true, NULL, 5141, NULL, '2023-08-24 14:54:43.000', 'ma0646', '2023-08-24 14:54:43.000', 'ma0646', NULL, 5141, 1);
INSERT INTO lehre.tbl_lehreinheitgruppe
(lehreinheit_id, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id)
VALUES(51003, 5, 1, 'A', NULL, NULL, NULL, NULL, '2023-08-24 14:58:26.000', 'ma0646', NULL);
INSERT INTO lehre.tbl_lehreinheitmitarbeiter
(lehreinheit_id, mitarbeiter_uid, lehrfunktion_kurzbz, planstunden, stundensatz, faktor, anmerkung, bismelden, updateamum, updatevon, insertamum, insertvon, ext_id, semesterstunden, standort_id, vertrag_id)
VALUES(51003, 'demolektor3', 'Lektor', 0, NULL, 1.00, NULL, true, NULL, NULL, '2023-08-24 14:58:40.000', 'ma0646', NULL, 0.00, NULL, NULL);
INSERT INTO lehre.tbl_lehreinheit (lehreinheit_id, lehrveranstaltung_id, studiensemester_kurzbz, lehrfach_id_old, lehrform_kurzbz, stundenblockung, wochenrythmus, start_kw, raumtyp, raumtypalternativ, sprache, lehre, anmerkung, unr, lvnr, updateamum, updatevon, insertamum, insertvon, ext_id, lehrfach_id, gewicht)
VALUES(51004, 5211, COALESCE(CurrentSemester(), LastActiveSemester()), NULL, 'VO', 2, 1, NULL, 'Dummy', 'Dummy', 'German', true, NULL, 5211, NULL, '2023-08-24 14:54:43.000', 'ma0646', '2023-08-24 14:54:43.000', 'ma0646', NULL, 5211, 1);
INSERT INTO lehre.tbl_lehreinheitgruppe
(lehreinheit_id, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id)
VALUES(51004, 5, 1, 'A', NULL, NULL, NULL, NULL, '2023-08-24 14:58:26.000', 'ma0646', NULL);
INSERT INTO lehre.tbl_lehreinheitmitarbeiter
(lehreinheit_id, mitarbeiter_uid, lehrfunktion_kurzbz, planstunden, stundensatz, faktor, anmerkung, bismelden, updateamum, updatevon, insertamum, insertvon, ext_id, semesterstunden, standort_id, vertrag_id)
VALUES(51004, 'demolektor4', 'Lektor', 0, NULL, 1.00, NULL, true, NULL, NULL, '2023-08-24 14:58:40.000', 'ma0646', NULL, 0.00, NULL, NULL);
INSERT INTO lehre.tbl_lehreinheit (lehreinheit_id, lehrveranstaltung_id, studiensemester_kurzbz, lehrfach_id_old, lehrform_kurzbz, stundenblockung, wochenrythmus, start_kw, raumtyp, raumtypalternativ, sprache, lehre, anmerkung, unr, lvnr, updateamum, updatevon, insertamum, insertvon, ext_id, lehrfach_id, gewicht)
VALUES(51005, 5311, COALESCE(CurrentSemester(), LastActiveSemester()), NULL, 'VO', 2, 1, NULL, 'Dummy', 'Dummy', 'German', true, NULL, 5311, NULL, '2023-08-24 14:54:43.000', 'ma0646', '2023-08-24 14:54:43.000', 'ma0646', NULL, 5311, 1);
INSERT INTO lehre.tbl_lehreinheitgruppe
(lehreinheit_id, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id)
VALUES(51005, 5, 1, 'A', NULL, NULL, NULL, NULL, '2023-08-24 14:58:26.000', 'ma0646', NULL);
INSERT INTO lehre.tbl_lehreinheitmitarbeiter
(lehreinheit_id, mitarbeiter_uid, lehrfunktion_kurzbz, planstunden, stundensatz, faktor, anmerkung, bismelden, updateamum, updatevon, insertamum, insertvon, ext_id, semesterstunden, standort_id, vertrag_id)
VALUES(51005, 'demolektor4', 'Lektor', 0, NULL, 1.00, NULL, true, NULL, NULL, '2023-08-24 14:58:40.000', 'ma0646', NULL, 0.00, NULL, NULL);
INSERT INTO lehre.tbl_lehreinheit (lehreinheit_id, lehrveranstaltung_id, studiensemester_kurzbz, lehrfach_id_old, lehrform_kurzbz, stundenblockung, wochenrythmus, start_kw, raumtyp, raumtypalternativ, sprache, lehre, anmerkung, unr, lvnr, updateamum, updatevon, insertamum, insertvon, ext_id, lehrfach_id, gewicht)
VALUES(51006, 5321, COALESCE(CurrentSemester(), LastActiveSemester()), NULL, 'VO', 2, 1, NULL, 'Dummy', 'Dummy', 'German', true, NULL, 5321, NULL, '2023-08-24 14:54:43.000', 'ma0646', '2023-08-24 14:54:43.000', 'ma0646', NULL, 5321, 1);
INSERT INTO lehre.tbl_lehreinheitgruppe
(lehreinheit_id, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id)
VALUES(51006, 5, 1, 'C', NULL, NULL, NULL, NULL, '2023-08-24 14:58:26.000', 'ma0646', NULL);
INSERT INTO lehre.tbl_lehreinheitmitarbeiter
(lehreinheit_id, mitarbeiter_uid, lehrfunktion_kurzbz, planstunden, stundensatz, faktor, anmerkung, bismelden, updateamum, updatevon, insertamum, insertvon, ext_id, semesterstunden, standort_id, vertrag_id)
VALUES(51006, 'demolektor1', 'Lektor', 0, NULL, 1.00, NULL, true, NULL, NULL, '2023-08-24 14:58:40.000', 'ma0646', NULL, 0.00, NULL, NULL);
-- Stundenplan Eintraege
INSERT INTO lehre.tbl_stundenplandev
(stundenplandev_id, lehreinheit_id, unr, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, mitarbeiter_uid, ort_kurzbz, datum, stunde, titel, anmerkung, fix, updateamum, updatevon, insertamum, insertvon)
VALUES(510011, 51001, 51001, 5, 1, 'A', '', NULL, 'demolektor1', 'EG04', '2025-07-31', 7, NULL, NULL, false, '2025-07-31 13:50:49.419', 'demoadmin', '2025-07-31 13:50:49.419', NULL);
INSERT INTO lehre.tbl_stundenplan
(stundenplan_id, lehreinheit_id, unr, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, mitarbeiter_uid, ort_kurzbz, datum, stunde, titel, anmerkung, fix, updateamum, updatevon, insertamum, insertvon)
VALUES(510011, 51001, 51001, 5, 1, 'A', '', NULL, 'demolektor1', 'EG04', '2025-07-31', 7, NULL, NULL, false, '2025-07-31 13:50:49.419', 'demoadmin', '2025-07-31 13:50:49.419', NULL);
INSERT INTO lehre.tbl_stundenplandev
(stundenplandev_id, lehreinheit_id, unr, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, mitarbeiter_uid, ort_kurzbz, datum, stunde, titel, anmerkung, fix, updateamum, updatevon, insertamum, insertvon)
VALUES(510021, 31002, 31002, 5, 1, 'B', '', NULL, 'demolektor1', 'EG04', '2025-08-01', 7, NULL, NULL, false, '2025-07-31 13:50:49.419', 'demoadmin', '2025-07-31 13:50:49.419', NULL);
INSERT INTO lehre.tbl_stundenplan
(stundenplan_id, lehreinheit_id, unr, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, mitarbeiter_uid, ort_kurzbz, datum, stunde, titel, anmerkung, fix, updateamum, updatevon, insertamum, insertvon)
VALUES(510021, 31002, 31002, 5, 1, 'B', '', NULL, 'demolektor1', 'EG04', '2025-08-01', 7, NULL, NULL, false, '2025-07-31 13:50:49.419', 'demoadmin', '2025-07-31 13:50:49.419', NULL);
INSERT INTO public.tbl_studentlehrverband
(student_uid, studiensemester_kurzbz, studiengang_kz, semester, verband, gruppe, updateamum, updatevon, insertamum, insertvon, ext_id)
VALUES('s125b101', LastActiveSemester(), 5, 2, ' ', ' ', NULL, NULL, '2026-06-11 14:33:58.403', 'auto', NULL);
INSERT INTO public.tbl_gruppe (gruppe_kurzbz, studiengang_kz, semester, bezeichnung, beschreibung, sichtbar, lehre, aktiv, sort, mailgrp, generiert, updateamum, updatevon, insertamum, insertvon, ext_id, orgform_kurzbz, content_visible, gesperrt, direktinskription, zutrittssystem, aufnahmegruppe) VALUES
('GRP_51003', 5, 2, 'BS5 2 ENG', null, false, false, true, NULL, false, false, now(), 'demoadmin', now(), 'demoadmin', NULL, NULL, true, false, true, false, false),
('GRP_51004', 5, 2, 'BS5 2 EL', null, false, false, true, NULL, false, false, now(), 'demoadmin', now(), 'demoadmin', NULL, NULL, true, false, true, false, false)
;
INSERT INTO lehre.tbl_lehreinheitgruppe
(lehreinheit_id, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id)
VALUES(51003, 5, 2, NULL, NULL, 'GRP_51003', NULL, NULL, '2023-08-24 14:58:26.000', 'ma0646', NULL);
INSERT INTO lehre.tbl_lehreinheitgruppe
(lehreinheit_id, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id)
VALUES(51004, 5, 2, NULL, NULL, 'GRP_51004', NULL, NULL, '2023-08-24 14:58:26.000', 'ma0646', NULL);
INSERT INTO public.tbl_benutzergruppe (uid, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id, studiensemester_kurzbz) VALUES
('s125b101', 'GRP_51003', now(), 'demoadmin', now(), 'demoadmin', NULL, NULL),
('s125b101', 'GRP_51004', now(), 'demoadmin', now(), 'demoadmin', NULL, NULL)
;
-- ==== benotungstool_noten ====
-- Demo data for the Benotungstool / Gesamtnoteneingabe test suite.
--
-- campus.vw_student_lehrveranstaltung, the source of getStudentsByLv(), links students to
-- Lehreinheiten through tbl_lehreinheitgruppe.gruppe_kurzbz = tbl_benutzergruppe.gruppe_kurzbz AND
-- a matching Studiensemester.
--
-- RESULT: demolektor1 -> LV 5221 (12 students), demolektor2 -> LV 5121 (6, the foreign LV for the
-- access tests).
BEGIN;
-- Test semester: the running one, else the last started. Same rule as the suite.
CREATE TEMP TABLE sem AS
SELECT studiensemester_kurzbz AS kurzbz
FROM public.tbl_studiensemester
WHERE start <= now()
ORDER BY (ende >= now()) DESC, start DESC
LIMIT 1;
-- The two LV fixtures. Own Lehreinheiten (511xx) so the tempus fixtures 51001-51006 stay untouched.
-- members_up_to = highest person_id that joins this group.
CREATE TEMP TABLE lv AS
SELECT v.*, l.semester
FROM (VALUES
(true, 5221, 51101, 'GRP_CYNOTEN_H', 'Cypress Noten Haupt', 'demolektor1', 512),
(false, 5121, 51102, 'GRP_CYNOTEN_F', 'Cypress Noten Fremd', 'demolektor2', 506)
) AS v(main, lv_id, le_id, grp, grp_text, lektor, members_up_to)
JOIN lehre.tbl_lehrveranstaltung l ON l.lehrveranstaltung_id = v.lv_id;
-- The 12 students. person_id is the anchor for every derived key.
CREATE TEMP TABLE stud AS
SELECT id AS person_id, 5000 + id AS prestudent_id, 's525b' || id AS uid
FROM generate_series(501, 512) AS g(id);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM sem) THEN
RAISE EXCEPTION 'No Studiensemester found, the base dump is missing.';
END IF;
IF (SELECT count(*) FROM lv) <> 2 THEN
RAISE EXCEPTION 'LV 5221/5121 missing, apply group studiengang_5.';
END IF;
END $$;
-- Termin3 is absent from the base dump; without it every Termin3 insert hits a foreign key error.
INSERT INTO lehre.tbl_pruefungstyp (pruefungstyp_kurzbz, beschreibung)
VALUES ('Termin3', '3. Termin')
ON CONFLICT DO NOTHING;
-- ------------------------------------------------------------------------------------- students
INSERT INTO public.tbl_person (person_id, vorname, nachname, gebdatum, geschlecht, aktiv)
SELECT person_id, 'Noten', 'Student ' || (person_id - 500), '2000-01-15', 'm', true
FROM stud
ON CONFLICT DO NOTHING;
INSERT INTO public.tbl_prestudent (prestudent_id, person_id, studiengang_kz)
SELECT prestudent_id, person_id, 5
FROM stud
ON CONFLICT DO NOTHING;
INSERT INTO public.tbl_prestudentstatus
(prestudent_id, status_kurzbz, studiensemester_kurzbz, ausbildungssemester)
SELECT s.prestudent_id, 'Student', sem.kurzbz, lv.semester
FROM stud s, sem, lv
WHERE lv.main
ON CONFLICT DO NOTHING;
INSERT INTO public.tbl_benutzer (uid, person_id, aktiv)
SELECT uid, person_id, true
FROM stud
ON CONFLICT DO NOTHING;
-- matrikelnr is UNIQUE; scheme of the existing seeders is '251000' + studiengang_kz + person_id
INSERT INTO public.tbl_student
(student_uid, matrikelnr, prestudent_id, studiengang_kz, semester, verband, gruppe)
SELECT s.uid, '2510005' || s.person_id, s.prestudent_id, 5, lv.semester, '', ''
FROM stud s, lv
WHERE lv.main
ON CONFLICT DO NOTHING;
-- Lehrverband assignment; (5, <semester>, '', '') is created by group studiengang_5.
INSERT INTO public.tbl_studentlehrverband
(student_uid, studiensemester_kurzbz, studiengang_kz, semester, verband, gruppe)
SELECT s.uid, sem.kurzbz, 5, lv.semester, '', ''
FROM stud s, sem, lv
WHERE lv.main
ON CONFLICT DO NOTHING;
-- ------------------------------------------------------------------- Lehreinheiten and Lektoren
INSERT INTO lehre.tbl_lehreinheit
(lehreinheit_id, lehrveranstaltung_id, studiensemester_kurzbz, lehrform_kurzbz,
wochenrythmus, raumtyp, raumtypalternativ, sprache, lehrfach_id)
SELECT lv.le_id, lv.lv_id, sem.kurzbz, 'VO', 1, 'Dummy', 'Dummy', 'German', lv.lv_id
FROM lv, sem
ON CONFLICT DO NOTHING;
-- Fix the semester if the Lehreinheit is left over from a run in a different one.
UPDATE lehre.tbl_lehreinheit le
SET studiensemester_kurzbz = sem.kurzbz
FROM lv, sem
WHERE le.lehreinheit_id = lv.le_id
AND le.studiensemester_kurzbz IS DISTINCT FROM sem.kurzbz;
INSERT INTO lehre.tbl_lehreinheitmitarbeiter (lehreinheit_id, mitarbeiter_uid, lehrfunktion_kurzbz)
SELECT le_id, lektor, 'Lektor'
FROM lv
ON CONFLICT DO NOTHING;
-- --------------------------------------------------------------------------------------- groups
INSERT INTO public.tbl_gruppe (gruppe_kurzbz, studiengang_kz, semester, bezeichnung, aktiv)
SELECT grp, 5, semester, grp_text, true
FROM lv
ON CONFLICT DO NOTHING;
-- The row the existing seeders lack: attach the group to the Lehreinheit with gruppe_kurzbz set.
-- The primary key is a serial, so NOT EXISTS instead of ON CONFLICT.
INSERT INTO lehre.tbl_lehreinheitgruppe (lehreinheit_id, studiengang_kz, semester, gruppe_kurzbz)
SELECT lv.le_id, 5, lv.semester, lv.grp
FROM lv
WHERE NOT EXISTS (SELECT 1 FROM lehre.tbl_lehreinheitgruppe g
WHERE g.lehreinheit_id = lv.le_id AND g.gruppe_kurzbz = lv.grp);
-- Membership WITH a semester. The primary key is (uid, gruppe_kurzbz) and excludes the semester,
-- so DO UPDATE repairs a row from an earlier run instead of duplicating it.
INSERT INTO public.tbl_benutzergruppe (uid, gruppe_kurzbz, studiensemester_kurzbz)
SELECT s.uid, lv.grp, sem.kurzbz
FROM stud s, lv, sem
WHERE s.person_id <= lv.members_up_to
ON CONFLICT (uid, gruppe_kurzbz) DO UPDATE
SET studiensemester_kurzbz = EXCLUDED.studiensemester_kurzbz;
-- -------------------------------------------------------------------------------- Notenschluessel
INSERT INTO lehre.tbl_notenschluessel (notenschluessel_kurzbz, bezeichnung)
VALUES ('CYNOTEN', 'Cypress Testnotenschluessel')
ON CONFLICT DO NOTHING;
-- getNote() takes the row with the largest punkte value <= the points given. The 0 threshold makes
-- sure every value maps to a grade. Serial primary key, so NOT EXISTS.
INSERT INTO lehre.tbl_notenschluesselaufteilung (notenschluessel_kurzbz, note, punkte)
SELECT 'CYNOTEN', a.note, a.punkte
FROM (VALUES (5, 0.0), (4, 51.0), (3, 64.0), (2, 77.0), (1, 90.0)) AS a(note, punkte)
WHERE NOT EXISTS (SELECT 1 FROM lehre.tbl_notenschluesselaufteilung x
WHERE x.notenschluessel_kurzbz = 'CYNOTEN' AND x.punkte = a.punkte);
-- Set the semester explicitly: getKurzbzForLv() builds "... AND studiensemester_kurzbz = ? OR
-- studiensemester_kurzbz IS NULL" without parentheses, so a NULL-semester assignment would apply
-- to every Lehrveranstaltung through AND/OR precedence.
INSERT INTO lehre.tbl_notenschluesselzuordnung
(notenschluessel_kurzbz, lehrveranstaltung_id, studiensemester_kurzbz)
SELECT 'CYNOTEN', lv.lv_id, sem.kurzbz
FROM lv, sem
WHERE lv.main
AND NOT EXISTS (SELECT 1 FROM lehre.tbl_notenschluesselzuordnung z
WHERE z.notenschluessel_kurzbz = 'CYNOTEN'
AND z.lehrveranstaltung_id = lv.lv_id
AND z.studiensemester_kurzbz = sem.kurzbz);
COMMIT;
-- Fires if the view stays empty anyway, which is the bug this seeder exists to fix.
DO $$
DECLARE v_count integer;
BEGIN
SELECT count(*) INTO v_count
FROM campus.vw_student_lehrveranstaltung v, sem
WHERE v.lehrveranstaltung_id = 5221 AND v.studiensemester_kurzbz = sem.kurzbz;
IF v_count = 0 THEN
RAISE EXCEPTION 'No students in LV 5221 (%), fixture unusable.', (SELECT kurzbz FROM sem);
END IF;
RAISE NOTICE 'OK: % students in LV 5221 (%)', v_count, (SELECT kurzbz FROM sem);
END $$;
SELECT (SELECT kurzbz FROM sem) AS semester,
(SELECT count(*) FROM campus.vw_student_lehrveranstaltung v, sem
WHERE v.lehrveranstaltung_id = 5221 AND v.studiensemester_kurzbz = sem.kurzbz) AS lv5221,
(SELECT count(*) FROM campus.vw_student_lehrveranstaltung v, sem
WHERE v.lehrveranstaltung_id = 5121 AND v.studiensemester_kurzbz = sem.kurzbz) AS lv5121,
(SELECT note FROM lehre.tbl_note WHERE bezeichnung = 'entschuldigt') AS entschuldigt,
(SELECT note FROM lehre.tbl_note WHERE bezeichnung = 'Noch nicht eingetragen') AS noch_nicht,
(SELECT count(*) FROM lehre.tbl_notenschluesselaufteilung
WHERE notenschluessel_kurzbz = 'CYNOTEN') AS notenschluessel;
-- ==== schema_grants ====
-- Full access to ALL application schemas for all DB roles.
DO $$
DECLARE
v_grantee text;
v_target text;
v_schema text;
v_grantees text[] := ARRAY['web', 'vilesci', 'wawi', 'admin', 'PUBLIC'];
v_done integer := 0;
BEGIN
FOREACH v_grantee IN ARRAY v_grantees
LOOP
IF v_grantee = 'PUBLIC' THEN
v_target := 'PUBLIC';
ELSE
CONTINUE WHEN NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = v_grantee);
v_target := quote_ident(v_grantee);
END IF;
FOR v_schema IN
SELECT nspname
FROM pg_namespace
WHERE nspname NOT LIKE 'pg\_%' AND nspname <> 'information_schema'
ORDER BY nspname
LOOP
EXECUTE format('GRANT ALL ON SCHEMA %I TO %s', v_schema, v_target);
EXECUTE format('GRANT ALL ON ALL TABLES IN SCHEMA %I TO %s', v_schema, v_target);
EXECUTE format('GRANT ALL ON ALL SEQUENCES IN SCHEMA %I TO %s', v_schema, v_target);
EXECUTE format('GRANT ALL ON ALL FUNCTIONS IN SCHEMA %I TO %s', v_schema, v_target);
EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT ALL ON TABLES TO %s', v_schema, v_target);
EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT ALL ON SEQUENCES TO %s', v_schema, v_target);
EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT ALL ON FUNCTIONS TO %s', v_schema, v_target);
v_done := v_done + 1;
END LOOP;
END LOOP;
RAISE NOTICE 'Grants auf % Schema/Rollen-Kombinationen vergeben.', v_done;
END $$;
-- ==== benotungstool_berechtigungen ====
-- Benotungstool permissions for the demo accounts. Without them every endpoint answers 403.
--
-- demolektor1 teacher; no oe_kurzbz - assertLvAccess scopes him by the Lehreinheiten he teaches
-- demoassistenz programme assistant for stg3 and stg5 (test courses 5221 and 5121 live in stg5)
--
-- Idempotent.
BEGIN;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM public.tbl_benutzer WHERE uid = 'demolektor1') THEN
RAISE EXCEPTION 'Benutzer demolektor1 fehlt, die Basisdaten sind unvollstaendig.';
END IF;
IF NOT EXISTS (SELECT 1 FROM public.tbl_benutzer WHERE uid = 'demoassistenz') THEN
RAISE EXCEPTION 'Benutzer demoassistenz fehlt, die Basisdaten sind unvollstaendig.';
END IF;
END $$;
-- The Lektor: may open the Benotungstool, but sees only the LVs that this Lektor teaches.
INSERT INTO system.tbl_benutzerrolle (uid, berechtigung_kurzbz, art, oe_kurzbz, insertvon, insertamum)
SELECT 'demolektor1', 'lehre/benotungstool', 'suid', NULL, 'seeder', now()
WHERE NOT EXISTS (
SELECT 1 FROM system.tbl_benutzerrolle
WHERE uid = 'demolektor1'
AND berechtigung_kurzbz = 'lehre/benotungstool'
AND oe_kurzbz IS NULL
);
-- The Assistenz: may grade the LVs of its Studiengaenge.
INSERT INTO system.tbl_benutzerrolle (uid, berechtigung_kurzbz, art, oe_kurzbz, insertvon, insertamum)
SELECT 'demoassistenz', 'lehre/benotungstool_assistenz', 'suid', oe.oe_kurzbz, 'seeder', now()
FROM (VALUES ('stg3'), ('stg5')) AS oe(oe_kurzbz)
WHERE NOT EXISTS (
SELECT 1 FROM system.tbl_benutzerrolle b
WHERE b.uid = 'demoassistenz'
AND b.berechtigung_kurzbz = 'lehre/benotungstool_assistenz'
AND b.oe_kurzbz = oe.oe_kurzbz
);
COMMIT;
-- Undo:
-- DELETE FROM system.tbl_benutzerrolle
-- WHERE insertvon = 'seeder' AND berechtigung_kurzbz LIKE 'lehre/benotungstool%';
-- ==== benotungstool_fixture_erweitert ====
-- Extensions of the Benotungstool fixture. Apply after group benotungstool_noten.
--
-- LE 51103 a second Lehreinheit of LV 5221 with two Lektoren and two students of its own. The write
-- paths replace a foreign Lehreinheit, and the grader rules need a Lehreinheit with more
-- than one Lektor. demoadmin sorts before demolektor1, so "the caller" and "the first
-- Lektor" give different results.
-- LE 51104 demolektor1 in LV 5221 in the last Sommersemester with a passed grade entry deadline.
-- The deadline tests need a semester in which the user teaches.
-- Vorlagen demo texts for 'Notenfreigabe' and 'Sancho_Mail_Template'. Without them every release
-- mail has an empty body. The dbupdate script creates only the tbl_vorlage row.
--
-- The new students sort after 'Student 1'..'Student 12', so the first twelve students of the suite
-- stay the same. Idempotent.
BEGIN;
-- group benotungstool_noten creates temp tables with the same names when both groups run in one session
DROP TABLE IF EXISTS pg_temp.sem, pg_temp.sem_frist, pg_temp.lv, pg_temp.stud;
-- Test semester: the running one, else the last started. Same rule as the suite and group benotungstool_noten.
CREATE TEMP TABLE sem AS
SELECT studiensemester_kurzbz AS kurzbz
FROM public.tbl_studiensemester
WHERE start <= now()
ORDER BY (ende >= now()) DESC, start DESC
LIMIT 1;
-- The last Sommersemester after its deadline. NOTENEINTRAGUNGSFRIST_SS defaults to 15 November.
CREATE TEMP TABLE sem_frist AS
SELECT studiensemester_kurzbz AS kurzbz
FROM public.tbl_studiensemester
WHERE studiensemester_kurzbz ~ '^SS[0-9]{4}$'
AND make_date(substring(studiensemester_kurzbz FROM 3 FOR 4)::int, 11, 15) < current_date
ORDER BY start DESC
LIMIT 1;
CREATE TEMP TABLE lv AS
SELECT lehrveranstaltung_id AS lv_id, semester
FROM lehre.tbl_lehrveranstaltung
WHERE lehrveranstaltung_id = 5221;
CREATE TEMP TABLE stud AS
SELECT id AS person_id, 5000 + id AS prestudent_id, 's525b' || id AS uid, vorname
FROM (VALUES (513, 'Eins'), (514, 'Zwei')) AS g(id, vorname);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM sem) THEN
RAISE EXCEPTION 'No Studiensemester found, the base dump is missing.';
END IF;
IF NOT EXISTS (SELECT 1 FROM sem_frist) THEN
RAISE EXCEPTION 'No Sommersemester with a passed grade entry deadline.';
END IF;
IF NOT EXISTS (SELECT 1 FROM lv) THEN
RAISE EXCEPTION 'LV 5221 missing, apply group studiengang_5.';
END IF;
IF NOT EXISTS (SELECT 1 FROM lehre.tbl_lehreinheit WHERE lehreinheit_id = 51101) THEN
RAISE EXCEPTION 'Lehreinheit 51101 missing, apply group benotungstool_noten first.';
END IF;
IF (SELECT count(*) FROM public.tbl_mitarbeiter WHERE mitarbeiter_uid IN ('demoadmin', 'demolektor1')) <> 2 THEN
RAISE EXCEPTION 'demoadmin or demolektor1 missing, apply BS002_mitarbeiter.sql.';
END IF;
END $$;
-- ------------------------------------------------------------------------------------- students
INSERT INTO public.tbl_person (person_id, vorname, nachname, gebdatum, geschlecht, aktiv)
SELECT person_id, vorname, 'Zweitgruppe', '2000-01-15', 'm', true
FROM stud
ON CONFLICT DO NOTHING;
INSERT INTO public.tbl_prestudent (prestudent_id, person_id, studiengang_kz)
SELECT prestudent_id, person_id, 5
FROM stud
ON CONFLICT DO NOTHING;
INSERT INTO public.tbl_prestudentstatus
(prestudent_id, status_kurzbz, studiensemester_kurzbz, ausbildungssemester)
SELECT s.prestudent_id, 'Student', sem.kurzbz, lv.semester
FROM stud s, sem, lv
ON CONFLICT DO NOTHING;
INSERT INTO public.tbl_benutzer (uid, person_id, aktiv)
SELECT uid, person_id, true
FROM stud
ON CONFLICT DO NOTHING;
INSERT INTO public.tbl_student
(student_uid, matrikelnr, prestudent_id, studiengang_kz, semester, verband, gruppe)
SELECT s.uid, '2510005' || s.person_id, s.prestudent_id, 5, lv.semester, '', ''
FROM stud s, lv
ON CONFLICT DO NOTHING;
INSERT INTO public.tbl_studentlehrverband
(student_uid, studiensemester_kurzbz, studiengang_kz, semester, verband, gruppe)
SELECT s.uid, sem.kurzbz, 5, lv.semester, '', ''
FROM stud s, sem, lv
ON CONFLICT DO NOTHING;
-- ------------------------------------------------------------------------------ Lehreinheit 51103
INSERT INTO lehre.tbl_lehreinheit
(lehreinheit_id, lehrveranstaltung_id, studiensemester_kurzbz, lehrform_kurzbz,
wochenrythmus, raumtyp, raumtypalternativ, sprache, lehrfach_id)
SELECT 51103, lv.lv_id, sem.kurzbz, 'VO', 1, 'Dummy', 'Dummy', 'German', lv.lv_id
FROM lv, sem
ON CONFLICT DO NOTHING;
UPDATE lehre.tbl_lehreinheit le
SET studiensemester_kurzbz = sem.kurzbz
FROM sem
WHERE le.lehreinheit_id = 51103
AND le.studiensemester_kurzbz IS DISTINCT FROM sem.kurzbz;
INSERT INTO lehre.tbl_lehreinheitmitarbeiter (lehreinheit_id, mitarbeiter_uid, lehrfunktion_kurzbz)
SELECT 51103, l.uid, 'Lektor'
FROM (VALUES ('demoadmin'), ('demolektor1')) AS l(uid)
ON CONFLICT DO NOTHING;
INSERT INTO public.tbl_gruppe (gruppe_kurzbz, studiengang_kz, semester, bezeichnung, aktiv)
SELECT 'GRP_CYNOTEN_Z', 5, semester, 'Cypress Noten Zweitgruppe', true
FROM lv
ON CONFLICT DO NOTHING;
INSERT INTO lehre.tbl_lehreinheitgruppe (lehreinheit_id, studiengang_kz, semester, gruppe_kurzbz)
SELECT 51103, 5, lv.semester, 'GRP_CYNOTEN_Z'
FROM lv
WHERE NOT EXISTS (SELECT 1 FROM lehre.tbl_lehreinheitgruppe g
WHERE g.lehreinheit_id = 51103 AND g.gruppe_kurzbz = 'GRP_CYNOTEN_Z');
-- the students of 51103 are members of this group only, so each has exactly one Lehreinheit
INSERT INTO public.tbl_benutzergruppe (uid, gruppe_kurzbz, studiensemester_kurzbz)
SELECT s.uid, 'GRP_CYNOTEN_Z', sem.kurzbz
FROM stud s, sem
ON CONFLICT (uid, gruppe_kurzbz) DO UPDATE
SET studiensemester_kurzbz = EXCLUDED.studiensemester_kurzbz;
-- ------------------------------------------------------------------------------ Lehreinheit 51104
-- getLvForLektorInSemester needs no group, only the Lehreinheit and its Lektor
INSERT INTO lehre.tbl_lehreinheit
(lehreinheit_id, lehrveranstaltung_id, studiensemester_kurzbz, lehrform_kurzbz,
wochenrythmus, raumtyp, raumtypalternativ, sprache, lehrfach_id)
SELECT 51104, lv.lv_id, sf.kurzbz, 'VO', 1, 'Dummy', 'Dummy', 'German', lv.lv_id
FROM lv, sem_frist sf
ON CONFLICT DO NOTHING;
UPDATE lehre.tbl_lehreinheit le
SET studiensemester_kurzbz = sf.kurzbz
FROM sem_frist sf
WHERE le.lehreinheit_id = 51104
AND le.studiensemester_kurzbz IS DISTINCT FROM sf.kurzbz;
INSERT INTO lehre.tbl_lehreinheitmitarbeiter (lehreinheit_id, mitarbeiter_uid, lehrfunktion_kurzbz)
VALUES (51104, 'demolektor1', 'Lektor')
ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------------------- Vorlagen
INSERT INTO public.tbl_vorlage (vorlage_kurzbz, bezeichnung, anmerkung, mimetype)
VALUES ('Notenfreigabe', 'Notenfreigabe', NULL, 'text/html'),
('Sancho_Mail_Template', 'Sancho Mail Template', 'Demo layout of the test instance', 'text/html')
ON CONFLICT (vorlage_kurzbz) DO NOTHING;
-- Placeholders of Noten::sendFreigabeEmail: lektor, lvaname, neuenotencount, studlist, adressen.
-- An existing active text stays. Studiengang 0 and oe 'etw' are the root, as for the other Vorlagen.
INSERT INTO public.tbl_vorlagestudiengang
(vorlage_kurzbz, studiengang_kz, version, text, oe_kurzbz, aktiv, insertamum, insertvon)
SELECT v.kurzbz, 0, 1, v.text, 'etw', true, now(), 'seeder'
FROM (VALUES
('Notenfreigabe',
'<p>{lektor} hat {neuenotencount} Note(n) freigegeben: {lvaname}</p>{studlist}<p>Empf&auml;nger: {adressen}</p>'),
('Sancho_Mail_Template', '<html><body>{content}</body></html>')
) AS v(kurzbz, text)
WHERE NOT EXISTS (SELECT 1 FROM public.tbl_vorlagestudiengang x
WHERE x.vorlage_kurzbz = v.kurzbz AND x.aktiv);
COMMIT;
DO $$
BEGIN
IF (SELECT count(*) FROM campus.vw_student_lehrveranstaltung v, sem
WHERE v.lehreinheit_id = 51103 AND v.studiensemester_kurzbz = sem.kurzbz) = 0 THEN
RAISE EXCEPTION 'No students in Lehreinheit 51103 (%), fixture unusable.', (SELECT kurzbz FROM sem);
END IF;
END $$;
SELECT (SELECT kurzbz FROM sem) AS semester,
(SELECT kurzbz FROM sem_frist) AS semester_frist,
(SELECT count(*) FROM campus.vw_student_lehrveranstaltung v, sem
WHERE v.lehreinheit_id = 51103 AND v.studiensemester_kurzbz = sem.kurzbz) AS le51103_studenten,
(SELECT count(*) FROM lehre.tbl_lehreinheitmitarbeiter
WHERE lehreinheit_id = 51103) AS le51103_lektoren,
(SELECT count(*) FROM public.tbl_vorlagestudiengang
WHERE vorlage_kurzbz IN ('Notenfreigabe', 'Sancho_Mail_Template') AND aktiv) AS vorlagen;
+9
View File
@@ -0,0 +1,9 @@
# Copy to tests/cypress/.env (not in Git) and fill in.
# Only the keys that all suites share. The noten suite keeps its keys in tests/cypress/suites/.env.
# The instance under test. There is no default: an empty value stops the run.
BASE_URL=
# A login for Cypress.env("adminusername") / ("adminpassword"). The noten suite does not use it.
USER_NAME=
USER_PASSWORD=
@@ -0,0 +1,286 @@
/**
* Access control: the scope of `assertLvAccess`.
*
* The Lektor tests use an LV of the semester that NOTEN_LEKTOR_USER does not teach (readForeignLv).
*/
import { apiGet, notenApi, lektorAuth, loginAsLektor } from "../../../../support/api/notenApi";
import { expectAuthError, expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
import {
antrittDate,
loadNotenContext,
readAuthUid,
readLvGesamtnoteViaDb,
requireDbReset,
resetNotenState,
seedBaseline,
} from "../../../../support/helpers/notenTestData";
import { requireConfig, skipIf } from "../../../../support/helpers/notenConfig";
import {
assistenzAuth,
assistenzContext,
assistenzLvWithLehreinheiten,
requireAssistenz,
} from "../../../../support/helpers/notenAssistenz";
describe("Noten API - Zugriffsschutz", () => {
let ctx;
before(() => {
loadNotenContext().then((loaded) => {
ctx = loaded;
});
});
beforeEach(() => loginAsLektor());
it("lehnt eine Anfrage ohne Anmeldung ab", () => {
cy.clearAllCookies(); // otherwise the previous login is still active
apiGet("getCisConfig", undefined, null).then((response) => expectAuthError(response));
apiGet("getCisConfig", undefined, { username: "no-such-user", password: "no-such-password" }).then((response) =>
expectAuthError(response),
);
});
describe("der konfigurierte API-Benutzer", () => {
let lvWithoutStudents = null;
before(() => {
readAuthUid()
.then((uid) => cy.task("noten:db:readLvWithoutStudents", { uid }))
.then((found) => {
lvWithoutStudents = found;
});
});
it("liest die Studierenden der Test-LV", () => {
notenApi.getStudentenNoten(ctx.lvId, ctx.semKurzbz).then((response) => {
const data = expectNotenSuccess(response, "getStudentenNoten auf der eigenen LV");
expect(data.students, "Liste der Studierenden").to.be.an("array").and.not.be.empty;
});
});
it("liefert für eine LV ohne Studierende eine leere Liste", function () {
skipIf(this, !lvWithoutStudents, "Übersprungen: NOTEN_LEKTOR_USER unterrichtet keine LV ohne Studierende.");
notenApi.getStudentenNoten(lvWithoutStudents.lvId, lvWithoutStudents.semKurzbz).then((response) => {
const data = expectNotenSuccess(response, "getStudentenNoten für eine LV ohne Studierende");
expect(data.students, "Liste der Studierenden").to.be.an("array").that.is.empty;
});
});
});
describe("Scope der Lehrperson", () => {
let foreignLvId = null;
before(() => {
readAuthUid()
.then((uid) => cy.task("noten:db:readForeignLv", { semKurzbz: ctx.semKurzbz, uid }))
.then((lvId) => {
foreignLvId = lvId;
});
});
beforeEach(function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_LEKTOR_NUR_EIGENE_LV", true);
skipIf(
this,
!foreignLvId,
"Übersprungen: das Semester hat keine LV, die NOTEN_LEKTOR_USER nicht unterrichtet.",
);
});
// assertLvAccess denies through terminateWithError -> 500 + phrase, not a 401.
it("verweigert einer Lehrperson eine fremde LV", () => {
notenApi
.getStudentenNoten(foreignLvId, ctx.semKurzbz)
.then((response) => expectNotenError(response, "keineBerechtigungNoten"));
});
it("verweigert einer Lehrperson die Note in einer fremden LV", () => {
notenApi
.saveLvNote(foreignLvId, ctx.semKurzbz, ctx.students[0].uid, ctx.notenScale[0])
.then((response) => expectNotenError(response, "keineBerechtigungNoten"));
});
it("verweigert einer Lehrperson die Lehreinheiten einer fremden LV", () => {
notenApi
.getLehreinheitenForLv(foreignLvId, ctx.semKurzbz)
.then((response) => expectNotenError(response, "keineBerechtigungNoten"));
});
});
// getLvForStudiengang is NOT tested here: for an admin isBerechtigt('admin') applies, and every
// call succeeds. It needs a non-admin login.
describe("getBenotungstoolContext als Assistenz", () => {
beforeEach(function () {
requireAssistenz(this);
cy.clearAllCookies();
});
it("liefert die berechtigten Studiengänge", () => {
assistenzContext(ctx.semKurzbz).then((result) => {
expect(result, "ein Semester mit Studiengängen der Assistenz").to.not.be.null;
expect(result.data.isAssistenz, "isAssistenz").to.be.true;
expect(result.data.studiengaenge, "studiengaenge").to.be.an("array").and.not.be.empty;
});
});
it("ignoriert eine lv_id, die nicht nur aus Ziffern besteht", () => {
apiGet(
"getBenotungstoolContext",
{ sem_kurzbz: ctx.semKurzbz, lv_id: ctx.semKurzbz },
assistenzAuth(),
).then((response) => {
const data = expectNotenSuccess(response, "getBenotungstoolContext mit dem Semester als lv_id");
expect(data.preselectStudiengang_kz, "keine Vorauswahl").to.be.null;
});
});
});
// An empty or non-numeric lv_id would extend the access check to all LVs.
describe("Parameter der Zugriffsprüfung", () => {
const s0 = () => ctx.students[0];
// saveFreigabe is left out: it checks the password first and sends a mail.
const endpoints = {
getStudentenNoten: (lvId, semKurzbz) => notenApi.getStudentenNoten(lvId, semKurzbz),
getLehreinheitenForLv: (lvId, semKurzbz) => notenApi.getLehreinheitenForLv(lvId, semKurzbz),
getNoteByPunkte: (lvId, semKurzbz) => notenApi.getNoteByPunkte(lvId, semKurzbz, 50),
saveLvNote: (lvId, semKurzbz) => notenApi.saveLvNote(lvId, semKurzbz, s0().uid, ctx.noten.negativ),
importLvNoten: (lvId, semKurzbz) => notenApi.importLvNoten(lvId, semKurzbz, []),
savePruefung: (lvId, semKurzbz) =>
notenApi.savePruefung(lvId, semKurzbz, s0().uid, {
note: ctx.noten.negativ,
datum: antrittDate(ctx, 1),
}),
// createPruefungen checks the students before the access check
createPruefungen: (lvId, semKurzbz) =>
notenApi.createPruefungen(lvId, semKurzbz, [{ uid: s0().uid }], { datum: antrittDate(ctx, 1) }),
importPruefungen: (lvId, semKurzbz) => notenApi.importPruefungen(lvId, semKurzbz, []),
};
Object.entries(endpoints).forEach(([name, call]) => {
[null, 0, "", "abc", "1abc", -1].forEach((lvId) => {
it(`${name} lehnt lv_id=${JSON.stringify(lvId)} ab`, () => {
call(lvId, ctx.semKurzbz).then((response) => expectNotenError(response, "wrongParameters"));
});
});
[null, "", " "].forEach((semKurzbz) => {
it(`${name} lehnt sem_kurzbz=${JSON.stringify(semKurzbz)} ab`, () => {
call(ctx.lvId, semKurzbz).then((response) => expectNotenError(response, "wrongParameters"));
});
});
});
describe("ohne LV-Filter", () => {
let student = null;
before(() => {
requireDbReset();
const search = (i) =>
i >= ctx.students.length
? cy.wrap(null, { log: false })
: cy
.task("noten:db:countOtherLvNoten", {
lvId: ctx.lvId,
semKurzbz: ctx.semKurzbz,
studentUid: ctx.students[i].uid,
})
.then((count) => (count === 0 ? ctx.students[i] : search(i + 1)));
search(0).then((found) => {
student = found;
});
});
it("ändert bei lv_id=null keine LV-Note", function () {
skipIf(this, !student, "Übersprungen: jeder Studierende hat LV-Noten in anderen LVs.");
const g2 = ctx.notenScale.find((n) => String(n) !== String(ctx.noten.negativ));
resetNotenState(ctx);
seedBaseline(ctx, student);
notenApi
.saveLvNote(null, ctx.semKurzbz, student.uid, g2)
.then((response) => expectNotenError(response, "wrongParameters"));
readLvGesamtnoteViaDb(ctx, student).then((row) => {
expect(String(row.note), "die LV-Note bleibt").to.eq(String(ctx.noten.negativ));
});
});
});
});
// The endpoint returns all Lehreinheiten of the LV, for every role with access to the LV.
describe("getLehreinheitenForLv", () => {
const lehreinheitenOfLv = (lvId) =>
cy
.task("noten:db:readLehreinheitenOfLv", { lvId, semKurzbz: ctx.semKurzbz })
.then((ids) => ids.map(String).sort());
// The query returns one row per group, so a Lehreinheit can appear more than once
const idsOf = (rows) => [...new Set(rows.map((r) => String(r.lehreinheit_id)))].sort();
before(() => requireDbReset());
it("liefert dieselben Lehreinheiten wie die Datenbank", () => {
notenApi.getLehreinheitenForLv(ctx.lvId, ctx.semKurzbz).then((response) => {
const rows = expectNotenSuccess(response, "getLehreinheitenForLv");
expect(rows, "Lehreinheiten").to.be.an("array").and.not.be.empty;
expect(rows[0], "Felder einer Lehreinheit").to.include.all.keys(
"lehreinheit_id",
"lehrveranstaltung_id",
"lehrform_kurzbz",
"direktinskription",
"semester",
"verband",
"gruppe",
"gruppe_kurzbz",
"kurzbz",
"kurzbzlang",
"termincount",
"studentcount",
);
lehreinheitenOfLv(ctx.lvId).then((ids) => {
expect(idsOf(rows), "lehreinheit_id").to.deep.eq(ids);
});
});
});
it("liefert einer Assistenz die Lehreinheiten einer LV ihres Studiengangs", function () {
requireAssistenz(this);
cy.clearAllCookies();
assistenzLvWithLehreinheiten(ctx.semKurzbz).then((target) => {
expect(target, "eine LV der Assistenz mit Lehreinheiten").to.not.be.null;
apiGet(
"getLehreinheitenForLv",
{ lv_id: target.lvId, sem_kurzbz: target.semKurzbz },
assistenzAuth(),
).then((response) => {
const rows = expectNotenSuccess(response, "getLehreinheitenForLv als Assistenz");
expect(idsOf(rows), "lehreinheit_id").to.deep.eq(target.lehreinheiten);
});
});
});
it("ersetzt den alten Endpunkt Lehre/getLeForLv", () => {
cy.request({
method: "GET",
url: "/index.ci.php/api/frontend/v1/Lehre/getLeForLv",
auth: lektorAuth(),
failOnStatusCode: false,
}).then((response) => {
expect(response.status, "Status von Lehre/getLeForLv").to.not.eq(200);
});
});
});
});
@@ -0,0 +1,422 @@
/**
* Pruefungsordnung §1: the Antritte.
*
* The server checks the rules in this order: A (maximum) -> B (date order) -> C (occurrence limit).
* Each test builds a state in which only one rule can apply; otherwise an earlier rule answers first.
*
* The NOTE decides what counts: "entschuldigt" and "Noch nicht eingetragen" never count.
* Antritt 1 is a real Pruefung row (givenBaseline seeds it).
*/
import { notenApi, loginAsLektor } from "../../../../support/api/notenApi";
import { expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
import {
requireKommissionellerAntritt,
requireConfig,
requireRepeat,
skipIf,
} from "../../../../support/helpers/notenConfig";
import {
antrittDate,
baselineDate,
loadNotenContext,
requireDbReset,
seedPruefung,
seedZeugnisnote,
shiftDate,
} from "../../../../support/helpers/notenTestData";
import {
addPruefung,
pruefungenOf,
antritteOf,
editPruefung,
givenBaseline,
readStateViaApi,
verlaufOf,
} from "../../../../support/helpers/notenScenario";
describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
let ctx;
before(() => {
requireDbReset();
loadNotenContext().then((loaded) => {
ctx = loaded;
cy.log(
`LV ${ctx.lvId} / ${ctx.semKurzbz} | maxAntritte=${ctx.maxAntritte} | ` +
`entschuldigt=${ctx.noten.entschuldigt}`,
);
expect(ctx.maxAntritte, "Platz für mindestens eine Wiederholung nach Antritt 1").to.be.greaterThan(1);
});
});
beforeEach(() => loginAsLektor());
/** Skips if the last Antritt is not the kommissionelle Pruefung. */
const skipUnlessLastKommissionell = (test) =>
skipIf(
test,
ctx.cisConfig.CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT !== ctx.maxAntritte,
"Übersprungen: der letzte Antritt ist nicht kommissionell.",
);
/** Adds counting Antritte up to maxAntritte. The baseline already has Antritt 1. */
const fillToCap = (student, firstIndex = 1) => {
for (let i = 0; i < ctx.maxAntritte - 1; i += 1) {
addPruefung(ctx, student, {
note: ctx.noten.negativ,
datum: antrittDate(ctx, firstIndex + i),
}).then((response) => {
expectNotenSuccess(response, `Antritt ${i + 2} von ${ctx.maxAntritte}`);
});
}
return antrittDate(ctx, firstIndex + ctx.maxAntritte - 1);
};
// The configuration names the special Noten by Bezeichnung. The API returns the resolved id
// (14 on the demo data). If the lookup fails, every rule uses the wrong Note without an error.
it("löst die Sondernoten über ihre Bezeichnung aus tbl_note auf", () => {
notenApi.getCisConfig().then((response) => {
const config = expectNotenSuccess(response, "getCisConfig");
expect(String(config.NOTE_ENTSCHULDIGT)).to.eq(String(ctx.noten.entschuldigt));
expect(config.NOTEN_OHNE_ANTRITT.map(String)).to.include.members([
String(ctx.noten.entschuldigt),
String(ctx.noten.nochNichtEingetragen),
]);
expect(
Object.keys(config.NOTEN_OCCURRENCE_LIMIT_MAP).map(String),
"the occurrence limit must be keyed on the resolved PK",
).to.include(String(ctx.noten.entschuldigt));
});
});
describe("Regel A - die Höchstzahl der Prüfungsantritte", () => {
it("lehnt einen Antritt ab, sobald das konfigurierte Maximum erreicht ist", function () {
// without CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF the chain ends earlier, see the kommPruefNichtErlaubt test
requireConfig(this, ctx, "CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF", true);
const student = ctx.students[0];
givenBaseline(ctx, student);
const nextDate = fillToCap(student);
addPruefung(ctx, student, { note: ctx.notenScale[0], datum: nextDate }).then((response) => {
expectNotenError(response, "maxAntritteReached");
});
// and the rejected Antritt wrote nothing
readStateViaApi(ctx).then((data) => {
const dates = pruefungenOf(data, student.uid).map((p) => String(p.datum).slice(0, 10));
expect(dates, "keine Zeile trägt das abgelehnte Datum").to.not.include(nextDate);
expect(antritteOf(data, student.uid), "genau die möglichen zählenden Zeilen").to.have.length(
ctx.maxAntritte,
);
});
});
});
describe("Regel B - die Antritte folgen der Zeitordnung", () => {
beforeEach(function () {
requireRepeat(this, ctx);
});
// "Noch nicht eingetragen" takes a date without counting, so rule A does not answer first
const givenOpenPruefungOn = (student, datum) => {
givenBaseline(ctx, student);
addPruefung(ctx, student, { note: ctx.noten.nochNichtEingetragen, datum }).then((response) => {
expectNotenSuccess(response, "eine offene Prüfung ohne Antritt");
});
};
it("lehnt einen neuen Antritt vor einem bestehenden ab", () => {
const student = ctx.students[1];
givenOpenPruefungOn(student, antrittDate(ctx, 2));
addPruefung(ctx, student, { note: ctx.notenScale[0], datum: antrittDate(ctx, 1) }).then((response) => {
expectNotenError(response, "pruefungDatumBeforeExisting");
});
});
it("nimmt einen neuen Antritt nach allen bestehenden an", () => {
const student = ctx.students[1];
givenOpenPruefungOn(student, antrittDate(ctx, 2));
addPruefung(ctx, student, { note: ctx.notenScale[0], datum: antrittDate(ctx, 3) }).then((response) => {
expectNotenSuccess(response, "eine Prüfung streng nach der vorhandenen");
});
});
});
describe("'entschuldigt' gilt nur einmal", () => {
beforeEach(function () {
requireRepeat(this, ctx);
});
it("lehnt einen zweiten entschuldigten Antritt ab", () => {
const student = ctx.students[2];
givenBaseline(ctx, student);
addPruefung(ctx, student, { note: ctx.noten.entschuldigt, datum: antrittDate(ctx, 1) }).then((response) =>
expectNotenSuccess(response, "erste entschuldigte Prüfung"),
);
addPruefung(ctx, student, { note: ctx.noten.entschuldigt, datum: antrittDate(ctx, 2) }).then((response) =>
expectNotenError(response, "noteOccuranceLimitReached"),
);
readStateViaApi(ctx).then((data) => {
const entschuldigt = pruefungenOf(data, student.uid).filter(
(p) => String(p.note) === String(ctx.noten.entschuldigt),
);
expect(entschuldigt, "nur eine entschuldigte Prüfung ist erlaubt").to.have.length(1);
});
});
});
describe("'entschuldigt' verbraucht keinen Antritt", () => {
beforeEach(function () {
requireRepeat(this, ctx);
});
it("nimmt nach einem entschuldigten Antritt weiter eine echte Note an", () => {
const student = ctx.students[0];
givenBaseline(ctx, student);
addPruefung(ctx, student, { note: ctx.noten.entschuldigt, datum: antrittDate(ctx, 1) }).then((response) =>
expectNotenSuccess(response, "entschuldigte Prüfung"),
);
// entschuldigt uses no Antritt, so this Note is still within the limit
addPruefung(ctx, student, { note: ctx.notenScale[0], datum: antrittDate(ctx, 2) }).then((response) =>
expectNotenSuccess(
response,
"eine Note nach einer entschuldigten Prüfung (entschuldigt verbraucht keinen Antritt)",
),
);
readStateViaApi(ctx).then((data) => {
expect(
verlaufOf(data, student.uid).antrittCount,
"Antritt 1 and the new Note; entschuldigt uses no Antritt",
).to.eq(2);
});
});
});
describe("die kommissionelle Prüfung ist der letzte Antritt", () => {
// §17 Abs 1: the second repeat is kommissionell. The server derives the Pruefungstyp from the
// position and writes it to `pruefungstyp_kurzbz`, because the StV still reads this column.
it("schreibt den letzten Antritt als kommissionelle Prüfung", function () {
requireKommissionellerAntritt(this, ctx);
skipUnlessLastKommissionell(this);
const student = ctx.students[3];
givenBaseline(ctx, student);
// fill up to the second-to-last Antritt
for (let i = 1; i < ctx.maxAntritte - 1; i += 1) {
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, i) }).then((response) =>
expectNotenSuccess(response, `Antritt ${i + 1}`),
);
}
addPruefung(ctx, student, {
note: ctx.notenScale[0],
datum: antrittDate(ctx, ctx.maxAntritte),
}).then((response) => {
const { pruefung } = expectNotenSuccess(response, "letzter Antritt")[student.uid];
expect(pruefung.pruefungstyp_kurzbz, "der letzte Antritt ist kommissionell").to.eq(
ctx.cisConfig.PRUEFUNG_TYP_KOMMISSIONELL,
);
});
readStateViaApi(ctx).then((data) => {
const verlauf = verlaufOf(data, student.uid);
expect(verlauf.antrittCount, "die kommissionelle zählt als Antritt").to.eq(ctx.maxAntritte);
expect(verlauf.canAdd, "danach ist kein Antritt mehr möglich").to.be.false;
});
});
it("verweigert den letzten Antritt, wenn das Tool ihn nicht anlegen darf", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF", false);
skipUnlessLastKommissionell(this);
const student = ctx.students[4];
givenBaseline(ctx, student);
// fill up to the second-to-last Antritt
for (let i = 1; i < ctx.maxAntritte - 1; i += 1) {
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, i) }).then((response) =>
expectNotenSuccess(response, `Antritt ${i + 1}`),
);
}
readStateViaApi(ctx).then((data) => {
const verlauf = verlaufOf(data, student.uid);
expect(verlauf.kommPruefLocked, "der Verlauf nennt den Grund").to.be.true;
expect(verlauf.canAdd, "und lässt keinen Antritt mehr zu").to.be.false;
});
addPruefung(ctx, student, {
note: ctx.notenScale[0],
datum: antrittDate(ctx, ctx.maxAntritte),
}).then((response) => expectNotenError(response, "kommPruefNichtErlaubt"));
});
// A kommissionelle Pruefung closes the chain also without a counting Note. Otherwise a new
// Antritt would be possible after an ungraded kommissionelle Pruefung.
it("sperrt weitere Antritte auch bei einer kommPruef ohne zählende Note", () => {
const student = ctx.students[1];
givenBaseline(ctx, student);
seedPruefung(ctx, student, {
note: ctx.noten.nochNichtEingetragen,
datum: antrittDate(ctx, 1),
type: "kommPruef",
});
readStateViaApi(ctx).then((data) => {
const verlauf = verlaufOf(data, student.uid);
expect(verlauf.terminal, "die Kette ist geschlossen").to.be.true;
expect(verlauf.canAdd, "kein weiterer Antritt möglich").to.be.false;
});
addPruefung(ctx, student, { note: ctx.notenScale[0], datum: antrittDate(ctx, 2) }).then((response) =>
expectNotenError(response, "maxAntritteReached"),
);
});
});
describe("'Noch nicht eingetragen' verbraucht keinen Antritt", () => {
beforeEach(function () {
requireRepeat(this, ctx);
});
it("zählt einen offenen Termin nicht als Antritt", () => {
const student = ctx.students[3];
givenBaseline(ctx, student);
addPruefung(ctx, student, { note: ctx.noten.nochNichtEingetragen, datum: antrittDate(ctx, 1) }).then(
(response) => expectNotenSuccess(response, "offener Termin"),
);
readStateViaApi(ctx).then((data) => {
const open = pruefungenOf(data, student.uid).find(
(p) => String(p.note) === String(ctx.noten.nochNichtEingetragen),
);
expect(open, "der offene Termin").to.exist;
expect(open.is_antritt, "der offene Termin verbraucht keinen Antritt").to.be.false;
const verlauf = verlaufOf(data, student.uid);
expect(verlauf.antrittCount, "nur Antritt 1").to.eq(1);
expect(verlauf.canAdd, "ein weiterer Antritt bleibt möglich").to.be.true;
});
});
});
describe("Anrechnung - gar keine Prüfungen", () => {
it("lehnt einen Antritt bei angerechneter Zeugnisnote ab", function () {
const angerechnet = (ctx.cisConfig.NOTEN_ANRECHNUNG || [])[0];
skipIf(this, angerechnet === undefined, "Übersprungen: NOTEN_ANRECHNUNG löst keine Note auf.");
const student = ctx.students[4];
givenBaseline(ctx, student);
seedZeugnisnote(ctx, student, angerechnet);
readStateViaApi(ctx).then((data) => {
const verlauf = verlaufOf(data, student.uid);
expect(verlauf.angerechnet, "der Verlauf nennt die Anrechnung").to.be.true;
expect(verlauf.canAdd, "und blockiert weitere Antritte").to.be.false;
});
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, 1) }).then((response) =>
expectNotenError(response, "c4angerechnetKeinePruefung"),
);
});
});
describe("Schutz beim Bearbeiten", () => {
beforeEach(function () {
requireRepeat(this, ctx);
});
/** Antritt 1 < entschuldigt < a real Note. Returns the entschuldigt row, which has a neighbor on both sides. */
const givenThreePruefungen = (student) => {
givenBaseline(ctx, student);
addPruefung(ctx, student, { note: ctx.noten.entschuldigt, datum: antrittDate(ctx, 1) });
addPruefung(ctx, student, { note: ctx.notenScale[0], datum: antrittDate(ctx, 2) });
return readStateViaApi(ctx).then((data) => {
const entschuldigt = pruefungenOf(data, student.uid).find(
(p) => String(p.note) === String(ctx.noten.entschuldigt),
);
expect(entschuldigt, "entschuldigte Prüfung zum Ändern").to.exist;
return entschuldigt;
});
};
it("lehnt eine Notenänderung nach einem späteren Antritt ab", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETERER_PRUEFUNG", true);
const student = ctx.students[1];
givenThreePruefungen(student).then((entschuldigt) => {
editPruefung(ctx, student, {
pruefungId: entschuldigt.pruefung_id,
note: ctx.notenScale[1], // another Note -> locked
datum: antrittDate(ctx, 1),
}).then((response) => expectNotenError(response, "pruefungNoteLocked"));
});
});
it("erlaubt weiter eine reine Datumskorrektur zwischen den Nachbarantritten", () => {
const student = ctx.students[1];
givenThreePruefungen(student).then((entschuldigt) => {
// same Note -> the lock does not apply; the date stays strictly between baseline and Antritt 2
editPruefung(ctx, student, {
pruefungId: entschuldigt.pruefung_id,
note: entschuldigt.note,
datum: shiftDate(antrittDate(ctx, 1), 3),
})
.then((response) => expectNotenSuccess(response, "reine Datumskorrektur innerhalb der Grenzen"))
.then(() => readStateViaApi(ctx))
.then((data) => {
const moved = pruefungenOf(data, student.uid).find(
(p) => p.pruefung_id === entschuldigt.pruefung_id,
);
expect(moved, "die angesprochene Zeile bleibt").to.exist;
expect(String(moved.datum).slice(0, 10), "und genau sie ist verschoben").to.eq(
shiftDate(antrittDate(ctx, 1), 3),
);
});
});
});
it("lehnt ein Datum am oder vor dem vorherigen Antritt ab", () => {
const student = ctx.students[2];
givenThreePruefungen(student).then((entschuldigt) => {
editPruefung(ctx, student, {
pruefungId: entschuldigt.pruefung_id,
note: entschuldigt.note,
datum: baselineDate(ctx), // == the Antritt-1 date -> not strictly after
}).then((response) => expectNotenError(response, "pruefungDatumOutOfRange"));
});
});
});
});
@@ -0,0 +1,248 @@
/**
* When a Note closes the Antritt chain ("bestanden").
*
* negative -> a repeat is allowed
* positive, not final -> a repeat only with CIS_GESAMTNOTE_NOTENVERBESSERUNG
* final -> the chain is closed, always
*
* tbl_note.positiv alone is not the rule: 'entschuldigt' and 'Teilgenommen' have it too. Only the
* Note of a Pruefung that uses an Antritt decides.
*/
import { notenApi, loginAsLektor } from "../../../../support/api/notenApi";
import {
expectBulkRowAccepted,
expectBulkRowError,
expectNotenError,
expectNotenSuccess,
} from "../../../../support/helpers/notenErrors";
import {
antrittDate,
loadNotenContext,
readLvGesamtnoteViaDb,
requireDbReset,
resetNotenState,
seedBaseline,
} from "../../../../support/helpers/notenTestData";
import {
addPruefung,
pruefungenOf,
editPruefung,
givenBaseline,
readStateViaApi,
verlaufOf,
} from "../../../../support/helpers/notenScenario";
import { requireConfig, requireRepeat, skipIf } from "../../../../support/helpers/notenConfig";
describe("Noten API - wann eine Note die Kette schliesst", () => {
let ctx;
before(() => {
loadNotenContext().then((loaded) => {
ctx = loaded;
cy.log(`NOTEN_ABSCHLIESSEND = ${JSON.stringify(ctx.cisConfig.NOTEN_ABSCHLIESSEND)}`);
cy.log(`NOTENVERBESSERUNG = ${ctx.cisConfig.CIS_GESAMTNOTE_NOTENVERBESSERUNG}`);
cy.log(`VERBESSERUNG_BESSERE_GEWINNT = ${ctx.cisConfig.CIS_GESAMTNOTE_VERBESSERUNG_BESSERE_GEWINNT}`);
cy.log(`noten.negativ=${ctx.noten.negativ} positiv=${ctx.noten.positiv} bestnote=${ctx.noten.bestnote}`);
});
});
beforeEach(() => loginAsLektor());
beforeEach(() => requireDbReset());
it("lässt die Kette nach einer negativen Note offen", function () {
requireRepeat(this, ctx);
const student = ctx.students[0];
givenBaseline(ctx, student, { note: ctx.noten.negativ });
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, 1) })
.then((response) => expectNotenSuccess(response, "Wiederholung nach negativer Note"))
.then(() => readStateViaApi(ctx))
.then((data) => {
const verlauf = verlaufOf(data, student.uid);
expect(verlauf.bestanden, "eine negative Note ist kein Abschluss").to.be.false;
});
});
it("schliesst die Kette nach einer abschliessenden Note", function () {
skipIf(this, ctx.noten.bestnote === null, "Übersprungen: keine Note aus NOTEN_ABSCHLIESSEND in der 1-5-Skala.");
const student = ctx.students[1];
givenBaseline(ctx, student, { note: ctx.noten.bestnote });
readStateViaApi(ctx)
.then((data) => {
const verlauf = verlaufOf(data, student.uid);
expect(verlauf.bestanden, "die Bestnote schliesst ab").to.be.true;
expect(verlauf.canAdd, "kein weiterer Termin nach der Bestnote").to.be.false;
})
.then(() => addPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, 1) }))
.then((response) => expectNotenError(response, "pruefungNachBestandenerNote"));
});
// The bestnote ALWAYS closes the chain, also with CIS_GESAMTNOTE_NOTENVERBESSERUNG.
it("schliesst die Kette nach der Bestnote auch bei erlaubter Verbesserung", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENVERBESSERUNG", true);
skipIf(this, ctx.noten.bestnote === null, "Übersprungen: keine Note aus NOTEN_ABSCHLIESSEND in der 1-5-Skala.");
const student = ctx.students[2];
givenBaseline(ctx, student, { note: ctx.noten.bestnote });
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, 1) }).then((response) =>
expectNotenError(response, "pruefungNachBestandenerNote"),
);
});
describe("eine positive Note ausserhalb NOTEN_ABSCHLIESSEND", () => {
beforeEach(function () {
skipIf(
this,
ctx.noten.positiv === null,
"Übersprungen: keine positive Note ausserhalb NOTEN_ABSCHLIESSEND.",
);
});
it("schliesst die Kette ohne Notenverbesserung", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENVERBESSERUNG", false);
const student = ctx.students[3];
givenBaseline(ctx, student, { note: ctx.noten.positiv });
addPruefung(ctx, student, { note: ctx.noten.positiv, datum: antrittDate(ctx, 1) }).then((response) =>
expectNotenError(response, "pruefungNachBestandenerNote"),
);
});
it("lässt mit Notenverbesserung einen weiteren Antritt zu", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENVERBESSERUNG", true);
const student = ctx.students[3];
givenBaseline(ctx, student, { note: ctx.noten.positiv });
addPruefung(ctx, student, { note: ctx.noten.positiv, datum: antrittDate(ctx, 1) }).then((response) =>
expectNotenSuccess(response, "Verbesserung nach positiver Note"),
);
});
// With VERBESSERUNG_BESSERE_GEWINNT a worse repeat does not lower the LV-Note.
it("behält bei einer schlechteren Verbesserung die bessere LV-Note", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENVERBESSERUNG", true);
requireConfig(this, ctx, "CIS_GESAMTNOTE_VERBESSERUNG_BESSERE_GEWINNT", true);
const student = ctx.students[4];
givenBaseline(ctx, student, { note: ctx.noten.positiv });
// the repeat is worse than Antritt 1
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, 1) })
.then((response) => expectNotenSuccess(response, "schlechtere Verbesserung"))
.then(() => readLvGesamtnoteViaDb(ctx, student))
.then((row) => {
expect(String(row.note), "die LV-Note behält die bessere Note").to.eq(String(ctx.noten.positiv));
});
// the Pruefung itself keeps the Note that the student got
readStateViaApi(ctx).then((data) => {
const pruefungen = pruefungenOf(data, student.uid);
const latest = pruefungen[pruefungen.length - 1];
expect(String(latest.note), "der Termin trägt die echte Note").to.eq(String(ctx.noten.negativ));
});
});
it("schreibt bei einer schlechteren Verbesserung die letzte Note", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENVERBESSERUNG", true);
requireConfig(this, ctx, "CIS_GESAMTNOTE_VERBESSERUNG_BESSERE_GEWINNT", false);
const student = ctx.students[4];
givenBaseline(ctx, student, { note: ctx.noten.positiv });
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, 1) })
.then((response) => expectNotenSuccess(response, "schlechtere Verbesserung"))
.then(() => readLvGesamtnoteViaDb(ctx, student))
.then((row) => {
expect(String(row.note), "die LV-Note folgt dem letzten Antritt").to.eq(String(ctx.noten.negativ));
});
});
});
// Only one Pruefung without a Note per LV. A Pruefung with a Note after it stays allowed.
describe("nur ein Termin ohne Note", () => {
beforeEach(function () {
requireRepeat(this, ctx);
});
const open = () => ctx.noten.nochNichtEingetragen;
const openPruefungen = (data, student) =>
pruefungenOf(data, student.uid).filter((p) => String(p.note) === String(open()));
it("lehnt einen zweiten Termin ohne Note ab", () => {
const student = ctx.students[0];
givenBaseline(ctx, student);
addPruefung(ctx, student, { note: open(), datum: antrittDate(ctx, 1) }).then((response) =>
expectNotenSuccess(response, "erster offener Termin"),
);
addPruefung(ctx, student, { note: open(), datum: antrittDate(ctx, 2) }).then((response) =>
expectNotenError(response, "pruefungOhneErgebnis"),
);
readStateViaApi(ctx).then((data) => {
expect(openPruefungen(data, student), "offene Termine").to.have.length(1);
});
});
it("lehnt im Sammeldialog je Zeile einen zweiten offenen Termin ab", () => {
const students = [ctx.students[0], ctx.students[1]];
const bulkRows = students.map((s) => ({ uid: s.uid, lehreinheit_id: s.lehreinheit_id }));
resetNotenState(ctx);
students.forEach((s) => seedBaseline(ctx, s));
notenApi
.createPruefungen(ctx.lvId, ctx.semKurzbz, bulkRows, { datum: antrittDate(ctx, 1) })
.then((response) => {
const data = expectNotenSuccess(response, "erster Sammeltermin ohne Note");
students.forEach((s) => expectBulkRowAccepted(data, s.uid));
});
notenApi
.createPruefungen(ctx.lvId, ctx.semKurzbz, bulkRows, { datum: antrittDate(ctx, 2) })
.then((response) => {
const data = expectNotenSuccess(response, "zweiter Sammeltermin ohne Note");
students.forEach((s) => expectBulkRowError(data, s.uid, "pruefungOhneErgebnis"));
});
});
it("lehnt eine Änderung auf 'Noch nicht eingetragen' neben einem offenen Termin ab", () => {
const student = ctx.students[2];
let latest;
givenBaseline(ctx, student);
addPruefung(ctx, student, { note: open(), datum: antrittDate(ctx, 1) }).then((response) =>
expectNotenSuccess(response, "offener Termin"),
);
// decision of the product owner: a Pruefung with a Note after the open one stays allowed
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, 2) }).then((response) => {
latest = expectNotenSuccess(response, "Termin mit Note nach dem offenen Termin")[student.uid].pruefung;
});
cy.then(() =>
editPruefung(ctx, student, {
pruefungId: latest.pruefung_id,
note: open(),
datum: antrittDate(ctx, 2),
}),
).then((response) => expectNotenError(response, "pruefungOhneErgebnis"));
});
});
});
@@ -0,0 +1,303 @@
/**
* The bulk and import endpoints. The same rules as for a single save, but each row reports its own
* result: HTTP 200, and an error message in data[uid].
*/
import { notenApi, loginAsLektor } from "../../../../support/api/notenApi";
import {
expectBulkRowAccepted,
expectBulkRowError,
expectNotenError,
expectNotenSuccess,
} from "../../../../support/helpers/notenErrors";
import {
requireConfig,
requireNotenMode,
requirePunkteMode,
requireRepeat,
} from "../../../../support/helpers/notenConfig";
import {
antrittDate,
loadNotenContext,
readLvGesamtnoteViaDb,
requireDbReset,
resetNotenState,
seedBaseline,
} from "../../../../support/helpers/notenTestData";
import { addPruefung, pruefungenOf, readStateViaApi } from "../../../../support/helpers/notenScenario";
describe("Noten API - Sammelpfade", () => {
let ctx;
before(() => {
requireDbReset();
loadNotenContext().then((loaded) => {
ctx = loaded;
});
});
beforeEach(() => loginAsLektor());
/** Adds Antritte up to maxAntritte. The baseline has Antritt 1. */
const bringToCap = (student) => {
for (let i = 0; i < ctx.maxAntritte - 1; i += 1) {
addPruefung(ctx, student, {
note: ctx.noten.negativ,
datum: antrittDate(ctx, i + 1),
}).then((response) => {
expectNotenSuccess(response, `${student.uid} bis zur Grenze füllen (Antritt ${i + 2})`);
});
}
return antrittDate(ctx, ctx.maxAntritte + 1);
};
describe("createPruefungen", () => {
beforeEach(function () {
requireRepeat(this, ctx);
// without CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF the limit answers "kommPruefNichtErlaubt"
requireConfig(this, ctx, "CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF", true);
});
it("lehnt nur die Zeile gegen eine §1-Regel ab und nimmt die übrigen an", () => {
const atCap = ctx.students[0];
const fresh = ctx.students[1];
resetNotenState(ctx);
seedBaseline(ctx, atCap);
seedBaseline(ctx, fresh);
const datum = bringToCap(atCap);
notenApi
.createPruefungen(
ctx.lvId,
ctx.semKurzbz,
[
{ uid: atCap.uid, lehreinheit_id: atCap.lehreinheit_id },
{ uid: fresh.uid, lehreinheit_id: fresh.lehreinheit_id },
],
{ datum },
)
.then((response) => {
// the request as a whole succeeds - errors are per row
const data = expectNotenSuccess(response, "createPruefungen");
expectBulkRowError(data, atCap.uid, "maxAntritteReached");
expectBulkRowAccepted(data, fresh.uid);
});
readStateViaApi(ctx).then((data) => {
const created = pruefungenOf(data, fresh.uid);
expect(
created.map((p) => String(p.datum).slice(0, 10)),
"the accepted row was really created",
).to.include(datum);
});
});
});
describe("importPruefungen", () => {
beforeEach(function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNGSIMPORT", true);
requireDbReset();
requireRepeat(this, ctx);
requireConfig(this, ctx, "CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF", true);
});
it("wendet die §1-Regeln je Zeile an", function () {
// in the Punkte mode the endpoint derives the Note from the Punkte; the Punkte test below
// covers the rule check
requireNotenMode(this, ctx);
const atCap = ctx.students[0];
const fresh = ctx.students[1];
resetNotenState(ctx);
seedBaseline(ctx, atCap);
seedBaseline(ctx, fresh);
const datum = bringToCap(atCap);
notenApi
.importPruefungen(ctx.lvId, ctx.semKurzbz, [
{
uid: atCap.uid,
note: ctx.notenScale[0],
punkte: null,
datum,
lehreinheit_id: atCap.lehreinheit_id,
},
{
uid: fresh.uid,
note: ctx.notenScale[0],
punkte: null,
datum,
lehreinheit_id: fresh.lehreinheit_id,
},
])
.then((response) => {
const data = expectNotenSuccess(response, "importPruefungen");
expectBulkRowError(data, atCap.uid, "maxAntritteReached");
expectBulkRowAccepted(data, fresh.uid);
});
});
// Punkte mode: the Note comes from the Notenschluessel; the row sends only Punkte
describe("Punktemodus", () => {
beforeEach(function () {
requirePunkteMode(this, ctx);
});
it("wendet die Regeln je Zeile auf die abgeleitete Note an", () => {
const atCap = ctx.students[0];
const fresh = ctx.students[1];
const punkte = 70;
resetNotenState(ctx);
seedBaseline(ctx, atCap);
seedBaseline(ctx, fresh);
const datum = bringToCap(atCap);
const bulkRow = (student) => ({
uid: student.uid,
note: null,
punkte,
datum,
lehreinheit_id: student.lehreinheit_id,
});
notenApi
.importPruefungen(ctx.lvId, ctx.semKurzbz, [bulkRow(atCap), bulkRow(fresh)])
.then((response) => {
const data = expectNotenSuccess(response, "importPruefungen mit Punkten");
expectBulkRowError(data, atCap.uid, "maxAntritteReached");
expectBulkRowAccepted(data, fresh.uid);
});
notenApi.getNoteByPunkte(ctx.lvId, ctx.semKurzbz, punkte).then((punkteResponse) => {
const expectedNote = punkteResponse.body.data;
readStateViaApi(ctx).then((data) => {
const newPruefung = pruefungenOf(data, fresh.uid).find(
(p) => String(p.datum).slice(0, 10) === datum,
);
expect(newPruefung, "der neue Termin").to.exist;
expect(String(newPruefung.note), "die aus den Punkten abgeleitete Note").to.eq(
String(expectedNote),
);
});
});
});
});
});
describe("importLvNoten", () => {
beforeEach(function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENIMPORT", true);
});
it("schreibt für jede Zeile eine LV-Note", function () {
// in the Punkte mode the Note comes from the Notenschluessel, see the Punktemodus block
requireNotenMode(this, ctx);
const [a, b] = ctx.students;
resetNotenState(ctx);
notenApi
.importLvNoten(ctx.lvId, ctx.semKurzbz, [
{ uid: a.uid, note: ctx.notenScale[0], punkte: null },
{ uid: b.uid, note: ctx.notenScale[1], punkte: null },
])
.then((response) => {
// by uid: each row has the LV-Note or an error
const data = expectNotenSuccess(response, "importLvNoten");
expectBulkRowAccepted(data, a.uid);
expectBulkRowAccepted(data, b.uid);
});
readLvGesamtnoteViaDb(ctx, a).then((row) => {
expect(row, `Zeile von ${a.uid}`).to.not.be.null;
expect(String(row.note)).to.eq(String(ctx.notenScale[0]));
});
readLvGesamtnoteViaDb(ctx, b).then((row) => {
expect(row, `Zeile von ${b.uid}`).to.not.be.null;
expect(String(row.note)).to.eq(String(ctx.notenScale[1]));
});
});
describe("Punktemodus", () => {
beforeEach(function () {
requirePunkteMode(this, ctx);
});
it("überspringt eine Zeile ohne Punkte und schreibt die übrigen", () => {
const withoutPunkte = ctx.students[0];
const withPunkte = ctx.students[1];
resetNotenState(ctx);
notenApi
.importLvNoten(ctx.lvId, ctx.semKurzbz, [
{ uid: withoutPunkte.uid, note: null, punkte: null },
{ uid: withPunkte.uid, note: null, punkte: 100 },
])
.then((response) => {
// a broken row must not stop the whole import
const data = expectNotenSuccess(response, "Notenimport mit Luecke");
expectBulkRowError(data, withoutPunkte.uid, "c4punkteKeineNoteErmittelt");
expectBulkRowAccepted(data, withPunkte.uid);
});
readLvGesamtnoteViaDb(ctx, withoutPunkte).then((row) => {
expect(row, "die übersprungene Zeile bleibt ungeschrieben").to.be.null;
});
});
});
});
// the switch hides the button; the server rejects a direct call too
describe("Importschalter", () => {
it("lehnt den Notenimport ab, wenn der Schalter aus ist", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENIMPORT", false);
const student = ctx.students[0];
resetNotenState(ctx);
notenApi
.importLvNoten(ctx.lvId, ctx.semKurzbz, [{ uid: student.uid, note: ctx.notenScale[0], punkte: null }])
.then((response) => expectNotenError(response, "importAusgeschaltet"));
readLvGesamtnoteViaDb(ctx, student).then((row) => {
expect(row, "der abgelehnte Import schreibt keine LV-Note").to.be.null;
});
});
it("lehnt den Prüfungsimport ab, wenn der Schalter aus ist", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNGSIMPORT", false);
const student = ctx.students[0];
resetNotenState(ctx);
seedBaseline(ctx, student);
notenApi
.importPruefungen(ctx.lvId, ctx.semKurzbz, [
{
uid: student.uid,
lehreinheit_id: student.lehreinheit_id,
datum: antrittDate(ctx, 1),
note: ctx.noten.negativ,
punkte: null,
},
])
.then((response) => expectNotenError(response, "importAusgeschaltet"));
readStateViaApi(ctx).then((data) => {
expect(pruefungenOf(data, student.uid), "nur Antritt 1 der Baseline").to.have.length(1);
});
});
});
});
@@ -0,0 +1,100 @@
/**
* Two requests at the same time for the same student in the same LV.
*
* Noten::beginStudentTransaction takes a transaction-wide advisory lock. The second request waits and reads
* the state after the first commit. One run proves little for a race condition, so each test repeats it.
*/
import { loginAsLektor } from "../../../../support/api/notenApi";
import { expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
import { requireConfig, requireRepeat } from "../../../../support/helpers/notenConfig";
import {
antrittDate,
baselineDate,
loadNotenContext,
requireDbReset,
resetNotenState,
} from "../../../../support/helpers/notenTestData";
import { pruefungenOf, givenBaseline, readStateViaApi } from "../../../../support/helpers/notenScenario";
const ROUNDS = 5;
const concurrently = (path, bodies) => cy.task("noten:http:postParallel", { path, bodies });
describe("Noten API - gleichzeitige Schreibvorgänge", () => {
let ctx;
before(() => {
requireDbReset();
loadNotenContext().then((loaded) => {
ctx = loaded;
});
});
beforeEach(() => loginAsLektor());
it("legt bei zwei gleichzeitigen Terminen am selben Tag nur einen an", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNG_GLEICHER_TAG", false);
requireRepeat(this, ctx);
const student = ctx.students[0];
const datum = antrittDate(ctx, 1);
// 'entschuldigt' uses no Antritt. So after the first commit only the date rule rejects the
// second Pruefung, whatever maxAntritte and the kommPruef rule say.
const body = {
student_uid: student.uid,
note: ctx.noten.entschuldigt,
punkte: null,
datum,
lv_id: ctx.lvId,
lehreinheit_id: student.lehreinheit_id,
sem_kurzbz: ctx.semKurzbz,
pruefung_id: null,
};
Cypress._.times(ROUNDS, (i) => {
givenBaseline(ctx, student);
concurrently("savePruefung", [body, body]).then((responses) => {
const [accepted, rejected] = [...responses].sort((x, y) => x.status - y.status);
expectNotenSuccess(accepted, `Durchlauf ${i + 1}: erster Request`);
expectNotenError(rejected, "pruefungDatumBeforeExisting");
});
readStateViaApi(ctx).then((data) => {
const onDay = pruefungenOf(data, student.uid).filter((p) => String(p.datum).slice(0, 10) === datum);
expect(onDay, `Durchlauf ${i + 1}: Termine am ${datum}`).to.have.length(1);
});
});
});
it("schreibt bei zwei gleichzeitigen Übernahmen nur einen Antritt 1", function () {
// without ERSTANTRITT_BEI_UEBERNAHME the proposal writes no Antritt 1, so there is no race
requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true);
const student = ctx.students[1];
const body = (note) => ({
lv_id: ctx.lvId,
sem_kurzbz: ctx.semKurzbz,
student_uid: student.uid,
note,
punkte: null,
datum: baselineDate(ctx),
});
Cypress._.times(ROUNDS, (i) => {
resetNotenState(ctx);
concurrently("saveLvNote", [body(ctx.notenScale[0]), body(ctx.notenScale[1])]).then((responses) => {
responses.forEach((response, n) =>
expectNotenSuccess(response, `Durchlauf ${i + 1}, Request ${n + 1}`),
);
});
readStateViaApi(ctx).then((data) => {
expect(pruefungenOf(data, student.uid), `Durchlauf ${i + 1}: Antritte`).to.have.length(1);
});
});
});
});
@@ -0,0 +1,358 @@
/**
* A successful Freigabe sends the Freigabe mail, unless CIS_GESAMTNOTE_FREIGABEMAIL is false.
* The dev instances send every mail to a debug mailbox (MAIL_DEBUG), so the tests run with the mail on.
* The suite cannot see the mail; it checks the templates and the response.
*/
import { freigabePassword, notenApi, loginAsLektor } from "../../../../support/api/notenApi";
import {
expectBulkRowAccepted,
expectBulkRowError,
expectNotenError,
expectNotenSuccess,
} from "../../../../support/helpers/notenErrors";
import { requireConfig, requireNotenMode } from "../../../../support/helpers/notenConfig";
import {
antrittDate,
baselineBenotungsdatum,
loadNotenContext,
readLvGesamtnoteViaDb,
requireDbReset,
resetNotenState,
seedBaseline,
seedPruefung,
} from "../../../../support/helpers/notenTestData";
import {
addPruefung,
pruefungenOf,
givenBaseline,
studentOf,
readStateViaApi,
verlaufOf,
} from "../../../../support/helpers/notenScenario";
describe("Noten API - Notenfreigabe", () => {
let ctx;
before(() => {
loadNotenContext().then((loaded) => {
ctx = loaded;
});
});
beforeEach(() => loginAsLektor());
describe("Passwortschutz", () => {
it("lehnt eine Freigabe mit falschem Passwort ab und ändert nichts", function () {
// without CIS_GESAMTNOTE_FREIGABE_PASSWORT every password passes
requireConfig(this, ctx, "CIS_GESAMTNOTE_FREIGABE_PASSWORT", true);
requireDbReset();
const student = ctx.students[0];
resetNotenState(ctx);
seedBaseline(ctx, student, {
note: ctx.notenScale[0],
freigegeben: false,
erstantritt: false,
benotungsdatum: baselineBenotungsdatum(ctx),
});
notenApi
.saveFreigabe(ctx.lvId, ctx.semKurzbz, "definitely-not-the-password", [student.uid])
.then((response) => {
expectNotenError(response, "wrongPassword");
});
readLvGesamtnoteViaDb(ctx, student).then((row) => {
expect(row, "die Zeile bleibt").to.not.be.null;
expect(row.freigabedatum, "eine abgelehnte Freigabe setzt kein freigabedatum").to.be.null;
});
});
});
describe("der normale Ablauf", () => {
beforeEach(() => {
requireDbReset();
});
it("setzt das freigabedatum auf einer geänderten Note", () => {
const student = ctx.students[0];
resetNotenState(ctx);
seedBaseline(ctx, student, {
note: ctx.notenScale[0],
freigegeben: false,
erstantritt: false,
benotungsdatum: baselineBenotungsdatum(ctx),
});
notenApi.saveFreigabe(ctx.lvId, ctx.semKurzbz, freigabePassword(), [student.uid]).then((response) => {
const entry = expectNotenSuccess(response, "saveFreigabe")[student.uid];
expect(entry, `${student.uid} wird als freigegeben gemeldet`).to.exist;
expect(entry.lvgesamtnote.freigabedatum, "freigabedatum ist gesetzt").to.exist;
expect(entry.verlauf, "der Verlauf kommt mit der Antwort zurück").to.exist;
expect(entry.verlauf.pruefungen, "inklusive der Termine").to.be.an("array");
});
readStateViaApi(ctx).then((data) => {
const lvNote = studentOf(data, student.uid);
expect(lvNote.freigabedatum, "die Note ist jetzt freigegeben").to.exist;
});
// the Freigabe used to stop before it wrote
readLvGesamtnoteViaDb(ctx, student).then((row) => {
expect(row.freigabedatum, "C1: die Freigabe schreibt freigabedatum").to.not.be.null;
});
});
it("legt mit der Freigabe den ersten Antritt an", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true);
const student = ctx.students[0];
resetNotenState(ctx);
seedBaseline(ctx, student, { note: ctx.noten.negativ, freigegeben: false, erstantritt: false });
notenApi.saveFreigabe(ctx.lvId, ctx.semKurzbz, freigabePassword(), [student.uid]).then((response) => {
const entry = expectNotenSuccess(response, "saveFreigabe")[student.uid];
// without the verlauf in the response, the table shows the new Pruefung only after a reload
expect(entry.verlauf.pruefungen, "der erste Antritt kommt mit der Antwort zurück").to.have.length(1);
});
readStateViaApi(ctx).then((data) => {
const pruefungen = pruefungenOf(data, student.uid);
expect(pruefungen, "genau ein Termin").to.have.length(1);
expect(pruefungen[0].antritt_nr, "als Antritt 1").to.eq(1);
});
});
it("legt ohne CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME keinen Termin an", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", false);
const student = ctx.students[0];
resetNotenState(ctx);
seedBaseline(ctx, student, { note: ctx.noten.negativ, freigegeben: false, erstantritt: false });
notenApi.saveFreigabe(ctx.lvId, ctx.semKurzbz, freigabePassword(), [student.uid]).then((response) => {
const entry = expectNotenSuccess(response, "saveFreigabe")[student.uid];
expect(entry.lvgesamtnote.freigabedatum, "die Freigabe gilt trotzdem").to.exist;
});
readStateViaApi(ctx).then((data) => {
expect(pruefungenOf(data, student.uid), "kein Termin").to.have.length(0);
expect(verlaufOf(data, student.uid).antrittCount, "die LV-Note zählt als Antritt 1").to.eq(1);
});
});
it("gibt ohne Passwort frei, wenn die Konfiguration keines verlangt", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_FREIGABE_PASSWORT", false);
const student = ctx.students[1];
resetNotenState(ctx);
seedBaseline(ctx, student, { note: ctx.noten.negativ, freigegeben: false, erstantritt: false });
notenApi.saveFreigabe(ctx.lvId, ctx.semKurzbz, "", [student.uid]).then((response) => {
const data = expectNotenSuccess(response, "Freigabe ohne Passwort");
expect(Object.keys(data), "freigegebene Zeilen").to.include(student.uid);
});
readLvGesamtnoteViaDb(ctx, student).then((row) => {
expect(row.freigabedatum, "freigabedatum").to.not.be.null;
});
});
// A single repeat is not Antritt 1. The Freigabe does not overwrite its Note.
it("behält die Note einer einzelnen Wiederholung", () => {
const student = ctx.students[1];
const g2 = ctx.notenScale.find((n) => String(n) !== String(ctx.noten.negativ));
resetNotenState(ctx);
seedBaseline(ctx, student, { note: ctx.noten.negativ, freigegeben: false, erstantritt: false });
seedPruefung(ctx, student, { note: g2, datum: antrittDate(ctx, 1), type: "Termin2" });
notenApi
.saveFreigabe(ctx.lvId, ctx.semKurzbz, freigabePassword(), [student.uid])
.then((response) => expectNotenSuccess(response, "saveFreigabe"));
readStateViaApi(ctx).then((data) => {
const pruefungen = pruefungenOf(data, student.uid);
expect(pruefungen, "die Freigabe legt keine Prüfung an").to.have.length(1);
expect(String(pruefungen[0].note), "die Wiederholung behält ihre Note").to.eq(String(g2));
});
});
// The person who enters a Pruefung owns its date. The Freigabe makes the Note binding and
// keeps the date.
it("behält das Datum einer bestehenden Prüfung", () => {
const student = ctx.students[2];
const pruefungsdatum = antrittDate(ctx, 2);
resetNotenState(ctx);
seedBaseline(ctx, student, {
note: ctx.noten.negativ,
freigegeben: false,
erstantritt: false,
benotungsdatum: baselineBenotungsdatum(ctx),
});
// Antritt 1 with its own date; without it, the Freigabe would create Antritt 1 on the benotungsdatum
seedPruefung(ctx, student, { note: ctx.noten.negativ, datum: pruefungsdatum, type: "Termin1" });
notenApi
.saveFreigabe(ctx.lvId, ctx.semKurzbz, freigabePassword(), [student.uid])
.then((response) => expectNotenSuccess(response, "saveFreigabe"));
readStateViaApi(ctx).then((data) => {
const pruefungen = pruefungenOf(data, student.uid);
expect(pruefungen, "der Termin bleibt der einzige").to.have.length(1);
expect(String(pruefungen[0].datum).slice(0, 10), "mit seinem eigenen Datum").to.eq(pruefungsdatum);
});
});
it("ändert nur Zeilen mit einem benotungsdatum nach dem freigabedatum", () => {
const changed = ctx.students[0];
const alreadyFreigegeben = ctx.students[1];
resetNotenState(ctx);
// changed: benotungsdatum after freigabedatum -> must be re-freigegeben
seedBaseline(ctx, changed, {
note: ctx.notenScale[0],
freigegeben: true,
freigabedatum: `${ctx.semKurzbz.slice(2, 6)}-01-05 08:00:00`,
benotungsdatum: `${ctx.semKurzbz.slice(2, 6)}-01-10 08:00:00`,
});
// already freigegeben and untouched since -> must be left alone
seedBaseline(ctx, alreadyFreigegeben, {
note: ctx.notenScale[0],
freigegeben: true,
freigabedatum: `${ctx.semKurzbz.slice(2, 6)}-01-10 08:00:00`,
benotungsdatum: `${ctx.semKurzbz.slice(2, 6)}-01-10 08:00:00`,
});
notenApi
.saveFreigabe(ctx.lvId, ctx.semKurzbz, freigabePassword(), [changed.uid, alreadyFreigegeben.uid])
.then((response) => {
const data = expectNotenSuccess(response, "selektive Freigabe");
const uids = Object.keys(data);
expect(uids, "die geänderte Note ist freigegeben").to.include(changed.uid);
expect(
uids,
"an unchanged, already freigegebene note must not be freigegeben again",
).to.not.include(alreadyFreigegeben.uid);
});
readLvGesamtnoteViaDb(ctx, alreadyFreigegeben).then((row) => {
expect(
new Date(row.freigabedatum).toISOString().slice(0, 10),
"the untouched row keeps its original freigabedatum",
).to.eq(`${ctx.semKurzbz.slice(2, 6)}-01-10`);
});
});
});
// The mail goes to a debug mailbox, and the suite does not read it. So it checks what the mail needs:
// both templates have a text, and a Freigabe with the mail on returns a full response.
describe("Freigabemail", () => {
beforeEach(function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_FREIGABEMAIL", true);
requireDbReset();
});
it("findet die Vorlage der Freigabemail und das Sancho-Layout", () => {
const vorlage = ctx.cisConfig.CIS_GESAMTNOTE_FREIGABEMAIL_VORLAGE;
cy.task("noten:db:readVorlagen", { kurzbz: [vorlage, "Sancho_Mail_Template"] }).then((rows) => {
const text = (kurzbz) => (rows.find((r) => r.vorlage_kurzbz === kurzbz) || {}).text || "";
// without a text the mail is empty, and nobody notices
expect(text(vorlage), `Text der Vorlage ${vorlage}`).to.not.be.empty;
expect(text(vorlage), "die Vorlage setzt die Notenliste ein").to.include("{studlist}");
expect(text("Sancho_Mail_Template"), "das Layout setzt den Inhalt ein").to.include("{content}");
});
});
it("gibt mit eingeschalteter Mail frei", () => {
const student = ctx.students[3];
resetNotenState(ctx);
seedBaseline(ctx, student, { note: ctx.noten.negativ, freigegeben: false, erstantritt: false });
notenApi.saveFreigabe(ctx.lvId, ctx.semKurzbz, freigabePassword(), [student.uid]).then((response) => {
const entry = expectNotenSuccess(response, "Freigabe mit Mail")[student.uid];
expect(entry, `${student.uid} ist freigegeben`).to.exist;
});
readLvGesamtnoteViaDb(ctx, student).then((row) => {
expect(row.freigabedatum, "freigabedatum").to.not.be.null;
});
});
});
// CIS_GESAMTNOTE_FREIGABE_FINAL: no endpoint changes a freigegeben Note. The tests seed the
// Freigabe, so they need no password.
describe("endgültige Freigabe", () => {
beforeEach(function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_FREIGABE_FINAL", true);
requireDbReset();
});
const otherNote = () => ctx.notenScale.find((n) => String(n) !== String(ctx.noten.negativ));
it("lehnt eine Übernahme nach der Freigabe ab", () => {
const student = ctx.students[0];
// freigegeben on purpose: with FINAL the baseline seeds an open Note by default
givenBaseline(ctx, student, { freigegeben: true });
notenApi
.saveLvNote(ctx.lvId, ctx.semKurzbz, student.uid, otherNote())
.then((response) => expectNotenError(response, "freigabeEndgueltig"));
readLvGesamtnoteViaDb(ctx, student).then((row) => {
expect(String(row.note), "die LV-Note bleibt").to.eq(String(ctx.noten.negativ));
});
});
it("lehnt einen neuen Termin nach der Freigabe ab", () => {
const student = ctx.students[1];
givenBaseline(ctx, student, { freigegeben: true });
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, 1) }).then((response) =>
expectNotenError(response, "freigabeEndgueltig"),
);
readStateViaApi(ctx).then((data) => {
expect(pruefungenOf(data, student.uid), "nur Antritt 1").to.have.length(1);
});
});
it("lehnt im Import nur die freigegebene Zeile ab", function () {
// Punkte mode: the Note comes from the Notenschluessel; the row sends only Punkte
requireNotenMode(this, ctx);
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENIMPORT", true);
const [freigegeben, open] = ctx.students;
resetNotenState(ctx);
seedBaseline(ctx, freigegeben, { freigegeben: true });
seedBaseline(ctx, open, { freigegeben: false, erstantritt: false });
notenApi
.importLvNoten(ctx.lvId, ctx.semKurzbz, [
{ uid: freigegeben.uid, note: otherNote(), punkte: null },
{ uid: open.uid, note: otherNote(), punkte: null },
])
.then((response) => {
const data = expectNotenSuccess(response, "importLvNoten");
expectBulkRowError(data, freigegeben.uid, "freigabeEndgueltig");
expectBulkRowAccepted(data, open.uid);
});
});
});
});
@@ -0,0 +1,189 @@
/**
* Pruefungsordnung §1: the Noteneintragungsfrist.
*
* Two Fristen answer two questions: CIS_GESAMTNOTE_FRIST_EINGABE limits the day of the entry,
* CIS_GESAMTNOTE_FRIST_PRUEFUNGSDATUM limits the date of the Pruefung. Every write endpoint checks the
* entry Frist right after the access check, so a rejected request writes nothing. The message names
* the Frist, so the test also checks how the server derives it from NOTENEINTRAGUNGSFRIST_SS/WS.
*
* Seeder group benotungstool_fixture_erweitert creates a Sommersemester after its Frist in which the Lektor teaches.
*/
import { freigabePassword, notenApi, loginAsLektor } from "../../../../support/api/notenApi";
import { expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
import {
antrittDate,
expectedFristString,
fristHasPassed,
loadNotenContext,
requireDbReset,
teachingSemesterWithExpiredFrist,
} from "../../../../support/helpers/notenTestData";
import { addPruefung, givenBaseline } from "../../../../support/helpers/notenScenario";
import { requireConfig, requireRepeat } from "../../../../support/helpers/notenConfig";
describe("Noten API - Noteneintragungsfrist (Prüfungsordnung §1)", () => {
let ctx;
// a semester and LV in which the Lektor teaches and the Frist has passed
const expired = { SS: null, WS: null };
const fristSS = () => ctx.cisConfig.NOTENEINTRAGUNGSFRIST_SS;
const fristWS = () => ctx.cisConfig.NOTENEINTRAGUNGSFRIST_WS;
before(() => {
loadNotenContext().then((loaded) => {
ctx = loaded;
cy.log(
`FRIST_EINGABE=${ctx.cisConfig.CIS_GESAMTNOTE_FRIST_EINGABE} ` +
`FRIST_PRUEFUNGSDATUM=${ctx.cisConfig.CIS_GESAMTNOTE_FRIST_PRUEFUNGSDATUM} ` +
`AUSNAHME_GILT=${ctx.cisConfig.CIS_GESAMTNOTE_FRIST_AUSNAHME_GILT}`,
);
["SS", "WS"].forEach((type) =>
teachingSemesterWithExpiredFrist(type, fristSS(), fristWS()).then((pair) => {
expired[type] = pair;
cy.log(`${type} mit abgelaufener Frist: ${JSON.stringify(pair)}`);
}),
);
});
});
beforeEach(() => loginAsLektor());
// A missing semester is a fixture error, not a skipped test
const pairFor = (type) => {
expect(expired[type], `ein ${type} mit abgelaufener Frist, in dem der Testbenutzer unterrichtet`).to.not.be
.null;
return expired[type];
};
// §7 and §11: the Pruefung must TAKE PLACE before the Frist. That is a different question from
// "may the Lektor still enter it now?"
describe("Prüfungsdatum nach der Frist", () => {
beforeEach(() => requireDbReset());
// the Frist of the current semester is before this date
const afterFrist = () => `${Number(ctx.semKurzbz.slice(2, 6)) + 1}-12-31`;
it("lehnt einen Termin ab, der nach der Frist liegt", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_FRIST_PRUEFUNGSDATUM", true);
const student = ctx.students[0];
givenBaseline(ctx, student);
addPruefung(ctx, student, { note: ctx.notenScale[0], datum: afterFrist() }).then((response) =>
expectNotenError(response, "pruefungsdatumNachFrist"),
);
});
it("nimmt einen Termin nach der Frist an, wenn das Prüfungsdatum an keine Frist gebunden ist", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_FRIST_PRUEFUNGSDATUM", false);
requireRepeat(this, ctx);
const student = ctx.students[0];
givenBaseline(ctx, student);
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: afterFrist() }).then((response) =>
expectNotenSuccess(response, "Termin nach der Frist"),
);
});
it("nimmt einen Termin vor der Frist an", function () {
requireRepeat(this, ctx);
const student = ctx.students[1];
givenBaseline(ctx, student);
addPruefung(ctx, student, { note: ctx.notenScale[0], datum: antrittDate(ctx, 1) }).then((response) =>
expectNotenSuccess(response, "Termin innerhalb der Frist"),
);
});
});
describe("Eingabe nach der Frist", () => {
const s0 = () => ctx.students[0];
// The student is not in the LV of that earlier semester. Without the Frist check the server would
// reject the student instead. Either way the request writes nothing.
const writePaths = {
savePruefung: (pair) =>
notenApi.savePruefung(pair.lvId, pair.semKurzbz, s0().uid, {
pruefung_id: null,
lehreinheit_id: s0().lehreinheit_id,
datum: antrittDate(ctx, 1),
note: ctx.notenScale[0],
punkte: null,
}),
saveLvNote: (pair) => notenApi.saveLvNote(pair.lvId, pair.semKurzbz, s0().uid, ctx.notenScale[0]),
importLvNoten: (pair) =>
notenApi.importLvNoten(pair.lvId, pair.semKurzbz, [
{ uid: s0().uid, note: ctx.notenScale[0], punkte: null },
]),
createPruefungen: (pair) =>
notenApi.createPruefungen(
pair.lvId,
pair.semKurzbz,
[{ uid: s0().uid, lehreinheit_id: s0().lehreinheit_id }],
{
datum: antrittDate(ctx, 1),
},
),
importPruefungen: (pair) =>
notenApi.importPruefungen(pair.lvId, pair.semKurzbz, [
{
uid: s0().uid,
note: ctx.notenScale[0],
punkte: null,
datum: antrittDate(ctx, 1),
lehreinheit_id: s0().lehreinheit_id,
},
]),
saveFreigabe: (pair) => notenApi.saveFreigabe(pair.lvId, pair.semKurzbz, freigabePassword(), [s0().uid]),
};
const reportsFrist = (response) =>
(response.body?.errors ?? []).some((e) => e.code === "noteneintragungsfristVorbei");
Object.entries(writePaths).forEach(([writePath, call]) => {
["SS", "WS"].forEach((type) => {
it(`${writePath} lehnt eine Eingabe im ${type} nach der Frist ab`, function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_FRIST_EINGABE", true);
requireConfig(this, ctx, "CIS_GESAMTNOTE_FRIST_AUSNAHME_GILT", false);
const pair = pairFor(type);
expect(
fristHasPassed(pair.semKurzbz, fristSS(), fristWS()),
`die Frist von ${pair.semKurzbz} ist vorbei`,
).to.be.true;
call(pair).then((response) => {
expectNotenError(response, "noteneintragungsfristVorbei");
const errorMessage = response.body.errors.map((e) => e.message).join(" | ");
expect(errorMessage, `die Frist, die der Server für ${pair.semKurzbz} ableitet`).to.include(
expectedFristString(pair.semKurzbz, fristSS(), fristWS()),
);
});
});
});
// the exception covers the day of the ENTRY, not the date of the Pruefung
it(`${writePath} lässt eine Ausnahmerolle nach der Frist eintragen`, function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_FRIST_EINGABE", true);
requireConfig(this, ctx, "CIS_GESAMTNOTE_FRIST_AUSNAHME_GILT", true);
call(pairFor("SS")).then((response) => {
expect(reportsFrist(response), "die Eingabefrist greift nicht").to.be.false;
});
});
it(`${writePath} nimmt ohne Eingabefrist eine späte Eingabe an`, function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_FRIST_EINGABE", false);
call(pairFor("SS")).then((response) => {
expect(reportsFrist(response), "die Eingabefrist greift nicht").to.be.false;
});
});
});
});
});
@@ -0,0 +1,68 @@
/**
* From which Antritt the Pruefung is kommissionell.
*
* The number comes from CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT, not from this test. Checks that
* the built chain follows it: role -> legacy type -> the `kommissionell` flag.
*/
import { loginAsLektor } from "../../../../support/api/notenApi";
import { expectNotenSuccess } from "../../../../support/helpers/notenErrors";
import { skipIf } from "../../../../support/helpers/notenConfig";
import { antrittDate, loadNotenContext, requireDbReset } from "../../../../support/helpers/notenTestData";
import { addPruefung, pruefungenOf, givenBaseline, readStateViaApi } from "../../../../support/helpers/notenScenario";
describe("Noten API - ab welchem Antritt kommissionell", () => {
let ctx;
let fromAntritt; // null means no Antritt is kommissionell
before(() => {
loadNotenContext().then((loaded) => {
ctx = loaded;
fromAntritt = ctx.cisConfig.CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT ?? null;
cy.log(`KOMMISSIONELL_AB_ANTRITT = ${fromAntritt} maxAntritte = ${ctx.maxAntritte}`);
});
});
beforeEach(() => loginAsLektor());
beforeEach(() => requireDbReset());
it("liefert eine Antrittsnummer, die zur Kette passt", function () {
skipIf(this, fromAntritt === null, "Übersprungen: kein Antritt ist kommissionell.");
expect(fromAntritt, "die Nummer ist eine Zahl ab 1").to.be.a("number").and.to.be.at.least(1);
expect(fromAntritt, "sie liegt innerhalb der Kette").to.be.at.most(ctx.maxAntritte);
});
it("baut die Kette nach der konfigurierten Nummer", function () {
skipIf(
this,
ctx.cisConfig.CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF === false,
"Übersprungen: das Werkzeug darf den letzten Antritt nicht anlegen.",
);
const student = ctx.students[0];
expect(student, "ein Student der LV").to.exist;
// the baseline is Antritt 1; a negative Note keeps the chain open
givenBaseline(ctx, student, { note: ctx.noten.negativ });
for (let nr = 2; nr <= ctx.maxAntritte; nr += 1) {
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, nr - 1) }).then((response) =>
expectNotenSuccess(response, `Antritt ${nr}`),
);
}
readStateViaApi(ctx).then((data) => {
const pruefungen = pruefungenOf(data, student.uid);
expect(pruefungen.length, "die Kette ist vollständig").to.eq(ctx.maxAntritte);
pruefungen.forEach((termin, index) => {
const nr = index + 1;
const expected = fromAntritt !== null && nr >= fromAntritt;
expect(termin.kommissionell, `Antritt ${nr} kommissionell?`).to.eq(expected);
});
});
});
});
@@ -0,0 +1,340 @@
/**
* saveLvNote: the proposal writes the LV-Note, and the Freigabe state follows.
*
* Two timestamps decide the state: benotungsdatum after freigabedatum = changed. The case "changed
* after the Freigabe" starts from a baseline that is already freigegeben, so this spec needs no LDAP
* password and sends no Freigabe mail.
*/
import { notenApi, loginAsLektor } from "../../../../support/api/notenApi";
import { expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
import { requireConfig, requireRepeat } from "../../../../support/helpers/notenConfig";
import {
antrittDate,
baselineBenotungsdatum,
baselineDate,
loadNotenContext,
readLvGesamtnoteViaDb,
requireDbReset,
resetNotenState,
seedBaseline,
seedPruefung,
} from "../../../../support/helpers/notenTestData";
import {
addPruefung,
pruefungenOf,
studentOf,
readStateViaApi,
verlaufOf,
} from "../../../../support/helpers/notenScenario";
describe("Noten API - LV-Note", () => {
let ctx;
before(() => {
requireDbReset();
loadNotenContext().then((loaded) => {
ctx = loaded;
});
});
beforeEach(() => loginAsLektor());
describe("ein Notenvorschlag für einen Studierenden ohne Note", () => {
it("gibt die gespeicherte Note mit benotungsdatum und ohne freigabedatum zurück", () => {
const student = ctx.students[0];
resetNotenState(ctx);
notenApi.saveLvNote(ctx.lvId, ctx.semKurzbz, student.uid, ctx.notenScale[0]).then((response) => {
const row = expectNotenSuccess(response, "saveLvNote")[student.uid].lvgesamtnote;
expect(String(row.note), "gespeicherte Note").to.eq(String(ctx.notenScale[0]));
expect(row.benotungsdatum, "benotungsdatum wird beim Speichern gesetzt").to.exist;
expect(row.freigabedatum, "eine neue LV-Note ist nicht freigegeben").to.be.oneOf([null, undefined, ""]);
});
});
// getStudentenNoten uses the unfiltered getter, so it also reads Noten that are not freigegeben.
// Otherwise the LV-Note and the Freigabe state would be empty after a reload.
it("meldet die offene Note über getStudentenNoten zurück", () => {
const student = ctx.students[0];
resetNotenState(ctx);
notenApi.saveLvNote(ctx.lvId, ctx.semKurzbz, student.uid, ctx.notenScale[0]);
readStateViaApi(ctx).then((data) => {
const row = studentOf(data, student.uid);
expect(row, `Zeile von ${student.uid}`).to.exist;
expect(
String(row.lv_note),
"a saved LV-Note that is not freigegeben must be readable again - if this is " +
"null, getLvGesamtNoten's `freigabedatum < NOW()` filter dropped the row",
).to.eq(String(ctx.notenScale[0]));
expect(row.freigabedatum, "noch nicht freigegeben").to.be.oneOf([null, undefined, ""]);
});
});
});
// `erstantritt: false` is the old data form: a freigegeben LV-Note without a Pruefung row. Only then may
// the proposal change the LV-Note directly; with a Pruefung the server rejects it (validateLvNote,
// see overwrite.cy.js). The date comes from the dialog of the proposal. The server uses it as the
// date of Antritt 1, so the same rules apply as for a Pruefung date.
describe("das gewählte Benotungsdatum", () => {
// The LV-Note IS Antritt 1. Without this row the next Pruefung would get "Termin2", and the
// legacy Pruefungstyp of the whole chain would move by one.
it("schreibt Antritt 1 mit dem gewählten Tag", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true);
const student = ctx.students[1];
const datum = baselineDate(ctx);
resetNotenState(ctx);
notenApi
.saveLvNote(ctx.lvId, ctx.semKurzbz, student.uid, ctx.notenScale[0], null, datum)
.then((response) => {
const { verlauf } = expectNotenSuccess(response, "saveLvNote")[student.uid];
expect(verlauf, "die Antwort trägt den Verlauf").to.exist;
expect(verlauf.antrittCount, "die LV-Note ist Antritt 1").to.eq(1);
expect(verlauf.hasRepeat, "Antritt 1 ist keine Wiederholung").to.be.false;
})
.then(() => readStateViaApi(ctx))
.then((data) => {
const pruefungen = pruefungenOf(data, student.uid);
expect(pruefungen, "genau ein Antritt").to.have.length(1);
expect(String(pruefungen[0].datum).slice(0, 10), "mit dem gewählten Datum").to.eq(datum);
expect(pruefungen[0].antritt_nr, "als Antritt 1").to.eq(1);
});
});
it("macht die nächste Prüfung zu Antritt 2 statt wieder zu Antritt 1", function () {
requireRepeat(this, ctx);
const student = ctx.students[2];
resetNotenState(ctx);
notenApi
.saveLvNote(ctx.lvId, ctx.semKurzbz, student.uid, ctx.noten.negativ, null, baselineDate(ctx))
.then((response) => expectNotenSuccess(response, "übernehmen"));
addPruefung(ctx, student, { note: ctx.notenScale[1], datum: antrittDate(ctx, 1) })
.then((response) => {
const { pruefung } = expectNotenSuccess(response, "Wiederholung")[student.uid];
expect(pruefung.pruefungstyp_kurzbz, "die Wiederholung ist nicht Antritt 1").to.not.eq("Termin1");
})
.then(() => readStateViaApi(ctx))
.then((data) => {
const verlauf = verlaufOf(data, student.uid);
expect(verlauf.antrittCount, "die Übernahme und die Prüfung sind zwei Antritte").to.eq(2);
});
});
it("schreibt ohne CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME nur die LV-Note", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", false);
const student = ctx.students[1];
resetNotenState(ctx);
notenApi
.saveLvNote(ctx.lvId, ctx.semKurzbz, student.uid, ctx.noten.negativ, null, baselineDate(ctx))
.then((response) => {
const { lvgesamtnote, verlauf } = expectNotenSuccess(response, "saveLvNote")[student.uid];
expect(String(lvgesamtnote.note), "die LV-Note").to.eq(String(ctx.noten.negativ));
expect(verlauf.antrittCount, "die LV-Note zählt als Antritt 1").to.eq(1);
});
readStateViaApi(ctx).then((data) => {
expect(pruefungenOf(data, student.uid), "kein Termin").to.have.length(0);
});
});
// Without ERSTANTRITT_BEI_UEBERNAHME the LV-Note stays the implicit Antritt 1. The first Pruefung
// writes that row and becomes Antritt 2 itself.
it("legt ohne CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME Antritt 1 mit dem ersten Termin an", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", false);
requireRepeat(this, ctx);
const student = ctx.students[2];
resetNotenState(ctx);
notenApi
.saveLvNote(ctx.lvId, ctx.semKurzbz, student.uid, ctx.noten.negativ)
.then((response) => expectNotenSuccess(response, "übernehmen ohne Erstantritt"));
addPruefung(ctx, student, { note: ctx.notenScale[1], datum: antrittDate(ctx, 1) }).then((response) =>
expectNotenSuccess(response, "erster Termin"),
);
readStateViaApi(ctx).then((data) => {
const pruefungen = pruefungenOf(data, student.uid);
expect(pruefungen, "Antritt 1 und der neue Termin").to.have.length(2);
expect(String(pruefungen[0].note), "Antritt 1 trägt die LV-Note").to.eq(String(ctx.noten.negativ));
expect(
pruefungen.map((p) => p.antritt_nr),
"Antrittsnummern",
).to.deep.eq([1, 2]);
expect(verlaufOf(data, student.uid).antrittCount, "zwei Antritte").to.eq(2);
});
});
});
// One query reads all LV-Noten of the LV. The result for each student stays the same.
describe("Lesedaten", () => {
it("liefert LV-Note und Zeitstempel je Studierendem", () => {
const [freigegeben, open, withoutNote] = ctx.students;
resetNotenState(ctx);
seedBaseline(ctx, freigegeben, { freigegeben: true });
seedBaseline(ctx, open, { freigegeben: false, erstantritt: false });
readStateViaApi(ctx).then((data) => {
const f = studentOf(data, freigegeben.uid);
expect(f.lv_note, "LV-Note der freigegebenen Zeile").to.exist;
expect(f.freigabedatum, "freigabedatum der freigegebenen Zeile").to.exist;
expect(f.benotungsdatum, "benotungsdatum der freigegebenen Zeile").to.exist;
const o = studentOf(data, open.uid);
expect(o.lv_note, "LV-Note der offenen Zeile").to.exist;
expect(o.benotungsdatum, "benotungsdatum der offenen Zeile").to.exist;
expect(o.freigabedatum, "die offene Zeile ist nicht freigegeben").to.be.oneOf([null, undefined, ""]);
expect((studentOf(data, withoutNote.uid) || {}).lv_note, "keine LV-Note").to.be.oneOf([
null,
undefined,
]);
});
});
});
// A single repeat without Antritt 1, as the StV can leave it. The Benotungstool no longer creates
// this state: its first Pruefung writes Antritt 1.
describe("eine einzelne Wiederholung", () => {
const student = () => ctx.students[3];
// g2: the Note of the repeat, g3: the new LV-Note
const g2 = () =>
ctx.noten.positiv ??
ctx.noten.bestnote ??
ctx.notenScale.find((n) => String(n) !== String(ctx.noten.negativ));
const g3 = () => ctx.notenScale.find((n) => String(n) !== String(g2()));
/** The LV-Note without Antritt 1, and one repeat. Yields the id of the repeat. */
const givenWiederholung = () => {
resetNotenState(ctx);
seedBaseline(ctx, student(), { erstantritt: false });
return seedPruefung(ctx, student(), { note: g2(), datum: antrittDate(ctx, 1), type: "Termin2" }).then(
(seeded) => seeded.pruefungId,
);
};
it("sperrt die Übernahme", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_VORSCHLAG_NACH_WIEDERHOLUNG", false);
let wiederholungId;
givenWiederholung().then((id) => {
wiederholungId = id;
});
readStateViaApi(ctx).then((data) => {
expect(verlaufOf(data, student().uid).hasRepeat, "hasRepeat").to.be.true;
expect(verlaufOf(data, student().uid).lvNoteLocked, "lvNoteLocked").to.be.true;
});
notenApi
.saveLvNote(ctx.lvId, ctx.semKurzbz, student().uid, g3(), null, baselineDate(ctx))
.then((response) => expectNotenError(response, "c4notenvorschlagGesperrt"));
readStateViaApi(ctx).then((data) => {
const pruefung = pruefungenOf(data, student().uid).find(
(p) => String(p.pruefung_id) === String(wiederholungId),
);
expect(String(pruefung.note), "die Wiederholung behält ihre Note").to.eq(String(g2()));
expect(String(pruefung.datum).slice(0, 10), "die Wiederholung behält ihr Datum").to.eq(
antrittDate(ctx, 1),
);
});
});
it("übernimmt die Note trotz Wiederholung, wenn der Schalter an ist", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_VORSCHLAG_NACH_WIEDERHOLUNG", true);
givenWiederholung();
readStateViaApi(ctx).then((data) => {
expect(verlaufOf(data, student().uid).lvNoteLocked, "lvNoteLocked").to.be.false;
});
notenApi
.saveLvNote(ctx.lvId, ctx.semKurzbz, student().uid, g3(), null, baselineDate(ctx))
.then((response) => expectNotenSuccess(response, "saveLvNote nach einer Wiederholung"));
readLvGesamtnoteViaDb(ctx, student()).then((row) => {
expect(String(row.note), "die neue LV-Note").to.eq(String(g3()));
});
});
});
describe("eine bereits freigegebene Note neu benoten", () => {
// a final Freigabe forbids exactly this, see freigabe.cy.js
beforeEach(function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_FREIGABE_FINAL", false);
});
it("wechselt den Status von freigegeben auf geändert", () => {
const student = ctx.students[1];
resetNotenState(ctx);
seedBaseline(ctx, student, {
note: ctx.notenScale[0],
freigegeben: true,
erstantritt: false,
benotungsdatum: baselineBenotungsdatum(ctx),
});
// baseline: benotungsdatum == freigabedatum -> freigegeben, not changed
readStateViaApi(ctx).then((data) => {
const lvNote = studentOf(data, student.uid);
expect(lvNote.lv_note, "die freigegebene Note ist sichtbar").to.exist;
expect(
new Date(lvNote.benotungsdatum) > new Date(lvNote.freigabedatum),
"baseline must not already look changed",
).to.be.false;
});
notenApi.saveLvNote(ctx.lvId, ctx.semKurzbz, student.uid, ctx.notenScale[1]).then((response) => {
expectNotenSuccess(response, "eine neue LV-Note nach der Freigabe");
});
readStateViaApi(ctx).then((data) => {
const lvNote = studentOf(data, student.uid);
expect(String(lvNote.lv_note), "die neue LV-Note ist gespeichert").to.eq(String(ctx.notenScale[1]));
expect(lvNote.freigabedatum, "das alte freigabedatum bleibt").to.exist;
expect(
new Date(lvNote.benotungsdatum) > new Date(lvNote.freigabedatum),
"benotungsdatum must now be newer than freigabedatum (state: changed)",
).to.be.true;
});
});
it("überschreibt die Note, statt eine zweite Zeile anzulegen", () => {
const student = ctx.students[1];
resetNotenState(ctx);
seedBaseline(ctx, student, {
note: ctx.notenScale[0],
freigegeben: true,
erstantritt: false,
});
notenApi.saveLvNote(ctx.lvId, ctx.semKurzbz, student.uid, ctx.notenScale[1]);
readLvGesamtnoteViaDb(ctx, student).then((row) => {
expect(row, "die einzige lvgesamtnote-Zeile").to.not.be.null;
expect(String(row.note)).to.eq(String(ctx.notenScale[1]));
});
});
});
});
@@ -0,0 +1,179 @@
/**
* The Notenschluessel: Punkte to Note. Read only.
*
* The test does not rebuild the mapping. It scans the Punkte range and checks the properties of a
* valid scale: no gap, monotonic, and exact at every limit.
*/
import { notenApi, loginAsLektor } from "../../../../support/api/notenApi";
import { expectBulkRowError, expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
import { requirePunkteMode, skipIf } from "../../../../support/helpers/notenConfig";
import {
antrittDate,
loadNotenContext,
requireDbReset,
resetNotenState,
seedBaseline,
} from "../../../../support/helpers/notenTestData";
import { pruefungenOf, givenBaseline, readStateViaApi } from "../../../../support/helpers/notenScenario";
const MAX_PUNKTE = 100;
describe("Noten API - Notenschlüssel (getNoteByPunkte)", () => {
let ctx;
let lvId;
before(() => {
loadNotenContext().then((loaded) => {
ctx = loaded;
lvId = ctx.lvId;
});
});
beforeEach(() => loginAsLektor());
/** Sweeps 0..MAX_PUNKTE and returns [{ punkte, note }] as the server reports it. */
const sweepScale = () => {
const observed = [];
for (let punkte = 0; punkte <= MAX_PUNKTE; punkte += 1) {
notenApi.getNoteByPunkte(lvId, ctx.semKurzbz, punkte).then((response) => {
const note = expectNotenSuccess(response, `getNoteByPunkte(${punkte})`);
observed.push({ punkte, note });
});
}
return cy.then(() => observed);
};
it("bildet jeden Punktewert auf eine monotone und exakte Notenskala ab", () => {
sweepScale().then((observed) => {
const withNote = observed.filter((entry) => entry.note !== null && entry.note !== undefined);
expect(
withNote.length,
`LV ${lvId} in ${ctx.semKurzbz} has no Notenschlüssel - getNoteByPunkte returned null ` +
`for every value in 0..${MAX_PUNKTE}. Pin NOTEN_LV_ID to an LV that has one.`,
).to.be.greaterThan(0);
// Below the lowest limit the server returns no Note. That block must be at the start:
// no Note AFTER a Note is a gap in the scale
const firstWithNoteIndex = observed.findIndex((e) => e.note !== null && e.note !== undefined);
observed.slice(firstWithNoteIndex).forEach((entry) => {
expect(
entry.note,
`${entry.punkte} Punkte are inside the range with a Note and must give a Note`,
).to.not.be.oneOf([null, undefined]);
});
// monotonic: the ids go from 1 (best) to 5, so the number must never go up
for (let i = 1; i < withNote.length; i += 1) {
expect(
Number(withNote[i].note),
`the Note at ${withNote[i].punkte} Punkte must not be worse than at ${withNote[i - 1].punkte}`,
).to.be.at.most(Number(withNote[i - 1].note));
}
// report each step on its own, so a moved limit shows in the output
const boundaries = [];
for (let i = 1; i < withNote.length; i += 1) {
if (Number(withNote[i].note) !== Number(withNote[i - 1].note)) {
boundaries.push({
punkte: withNote[i].punkte,
note: withNote[i].note,
previousNote: withNote[i - 1].note,
});
}
}
cy.log(`Notenschlüssel of LV ${lvId}: ${boundaries.map((b) => `>=${b.punkte} -> ${b.note}`).join(", ")}`);
expect(
boundaries.length,
"a usable Notenschlüssel has at least one Note limit in the scanned range",
).to.be.greaterThan(0);
// re-assert each boundary individually so a failure names the exact threshold
boundaries.forEach((boundary) => {
notenApi.getNoteByPunkte(lvId, ctx.semKurzbz, boundary.punkte).then((response) => {
const note = expectNotenSuccess(response, `Schwelle ${boundary.punkte}`);
expect(Number(note), `die Note genau an der Schwelle von ${boundary.punkte} Punkten`).to.eq(
Number(boundary.note),
);
});
notenApi.getNoteByPunkte(lvId, ctx.semKurzbz, boundary.punkte - 1).then((response) => {
const note = expectNotenSuccess(response, `just below ${boundary.punkte}`);
expect(
Number(note),
`one Punkt below the limit of ${boundary.punkte} Punkte must still be the worse Note`,
).to.eq(Number(boundary.previousNote));
});
});
});
});
// Punkte without a derivable Note: every endpoint rejects them the same way.
describe("Punkte ohne ableitbare Note", () => {
let thresholdBelowZero = false;
before(() => {
notenApi.getNoteByPunkte(lvId, ctx.semKurzbz, -1).then((response) => {
const note = expectNotenSuccess(response, "getNoteByPunkte(-1)");
thresholdBelowZero = note !== null && note !== undefined;
});
});
beforeEach(function () {
requirePunkteMode(this, ctx);
skipIf(this, thresholdBelowZero, "Übersprungen: der Notenschlüssel hat eine Schwelle unter 0 Punkten.");
requireDbReset();
});
const expectOnlyAntritt1 = (students) =>
readStateViaApi(ctx).then((data) => {
students.forEach((s) => {
expect(pruefungenOf(data, s.uid), `Prüfungen von ${s.uid}`).to.have.length(1);
});
});
it("savePruefung lehnt negative Punkte ab", () => {
const student = ctx.students[0];
givenBaseline(ctx, student);
notenApi
.savePruefung(lvId, ctx.semKurzbz, student.uid, {
pruefung_id: null,
lehreinheit_id: student.lehreinheit_id,
datum: antrittDate(ctx, 1),
note: ctx.notenScale[0],
punkte: -1,
})
.then((response) => expectNotenError(response, "c4punkteKeineNoteErmittelt"));
expectOnlyAntritt1([student]);
});
it("createPruefungen lehnt negative Punkte für alle gewählten Studierenden ab", () => {
const students = ctx.students.slice(0, 2);
resetNotenState(ctx);
students.forEach((s) => seedBaseline(ctx, s));
notenApi
.createPruefungen(
lvId,
ctx.semKurzbz,
students.map((s) => ({ uid: s.uid, lehreinheit_id: s.lehreinheit_id })),
{ datum: antrittDate(ctx, 1), note: null, punkte: -1 },
)
.then((response) => {
const data = expectNotenSuccess(response, "createPruefungen mit negativen Punkten");
students.forEach((s) => expectBulkRowError(data, s.uid, "c4punkteKeineNoteErmittelt"));
});
expectOnlyAntritt1(students);
});
});
});
@@ -0,0 +1,214 @@
/**
* The overwrite rules of the proposal column.
*
* Three rules: the editor list offers only Noten of the Lehre; the column locks after a repeat; and a
* Zeugnisnote with lkt_ueberschreibbar = false also locks it. Benotungstool.js AND Noten::validateLvNote
* both have all three, because the direct API call and the CSV import never reach the client.
*/
import { expectBulkRowError, expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
import { requireConfig, requireNotenMode, requireRepeat, skipIf } from "../../../../support/helpers/notenConfig";
import {
antrittDate,
loadNotenContext,
readLvGesamtnoteViaDb,
requireDbReset,
resetNotenState,
seedZeugnisnote,
} from "../../../../support/helpers/notenTestData";
import { addPruefung, pruefungenOf, givenBaseline, readStateViaApi } from "../../../../support/helpers/notenScenario";
import { notenApi, loginAsLektor } from "../../../../support/api/notenApi";
describe("Noten API - Regeln zum Überschreiben der LV-Note", () => {
let ctx;
before(() => {
requireDbReset();
loadNotenContext().then((loaded) => {
ctx = loaded;
});
});
beforeEach(() => loginAsLektor());
it("lehnt eine Note ab, die die Auswahlliste nicht anbietet", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_LVNOTE_NUR_LEHRENOTEN", true);
// on this instance every active Note can be a lehre Note
skipIf(this, ctx.noten.notLehre === null, "Übersprungen: keine Verwaltungsnote in tbl_note.");
const student = ctx.students[0];
// without Antritt 1: otherwise the Pruefung rule answers first, and the Note rule stays untested
givenBaseline(ctx, student, { erstantritt: false });
notenApi
.saveLvNote(ctx.lvId, ctx.semKurzbz, student.uid, ctx.noten.notLehre)
.then((response) => expectNotenError(response, "c4noteNichtInLehre"))
.then(() => readLvGesamtnoteViaDb(ctx, student))
.then((row) => {
expect(String(row.note), "eine Verwaltungsnote wird nicht zur LV-Note").to.eq(
String(ctx.noten.negativ),
);
});
});
it("lehnt eine Änderung des Notenvorschlags nach einer Prüfung ab", function () {
requireRepeat(this, ctx);
requireConfig(this, ctx, "CIS_GESAMTNOTE_VORSCHLAG_NACH_WIEDERHOLUNG", false);
const student = ctx.students[1];
givenBaseline(ctx, student);
addPruefung(ctx, student, { note: ctx.notenScale[1], datum: antrittDate(ctx, 1) }).then((response) => {
expectNotenSuccess(response, "eine Prüfung anlegen");
});
// the Note now belongs to the Antritt chain; a direct edit would bypass the Antritt rules
notenApi
.saveLvNote(ctx.lvId, ctx.semKurzbz, student.uid, ctx.notenScale[0])
.then((response) => expectNotenError(response, "c4notenvorschlagGesperrt"))
.then(() => readLvGesamtnoteViaDb(ctx, student))
.then((row) => {
expect(String(row.note), "die Note des Antritts bleibt erhalten").to.eq(String(ctx.notenScale[1]));
});
});
it("lehnt das Überschreiben einer gesperrten Zeugnisnote ab", function () {
// on this instance the Lektor may overwrite every active Note
skipIf(this, ctx.noten.notUeberschreibbar === null, "Übersprungen: keine gesperrte Note in tbl_note.");
const student = ctx.students[4];
givenBaseline(ctx, student, { erstantritt: false });
seedZeugnisnote(ctx, student, ctx.noten.notUeberschreibbar);
notenApi
.saveLvNote(ctx.lvId, ctx.semKurzbz, student.uid, ctx.notenScale[1])
.then((response) => expectNotenError(response, "c4zeugnisnoteGesperrt"))
.then(() => readLvGesamtnoteViaDb(ctx, student))
.then((row) => {
expect(String(row.note), "die LV-Note ändert sich nicht").to.eq(String(ctx.noten.negativ));
});
});
// A Pruefung writes the same LV-Note as the proposal and checks the same Note rules.
describe("Prüfungspfade", () => {
/** An administrative Note that uses an Antritt. Noten without an Antritt stay allowed. */
const skipWithoutVerwaltungsnote = (test) => {
requireConfig(test, ctx, "CIS_GESAMTNOTE_LVNOTE_NUR_LEHRENOTEN", true);
const withoutAntritt = (ctx.cisConfig.NOTEN_OHNE_ANTRITT || []).map(String);
const usable = ctx.noten.notLehre !== null && !withoutAntritt.includes(String(ctx.noten.notLehre));
skipIf(test, !usable, "Übersprungen: keine Verwaltungsnote, die einen Antritt verbraucht.");
};
const expectOnlyAntritt1 = (student) =>
readStateViaApi(ctx).then((data) => {
expect(pruefungenOf(data, student.uid), "nur Antritt 1").to.have.length(1);
});
it("lehnt einen Termin mit einer Verwaltungsnote ab", function () {
skipWithoutVerwaltungsnote(this);
requireRepeat(this, ctx);
const student = ctx.students[0];
givenBaseline(ctx, student);
addPruefung(ctx, student, { note: ctx.noten.notLehre, datum: antrittDate(ctx, 1) }).then((response) =>
expectNotenError(response, "c4noteNichtInLehre"),
);
expectOnlyAntritt1(student);
readLvGesamtnoteViaDb(ctx, student).then((row) => {
expect(String(row.note), "die LV-Note bleibt").to.eq(String(ctx.noten.negativ));
});
});
it("lehnt einen Termin bei gesperrter Zeugnisnote ab", function () {
requireRepeat(this, ctx);
// an Anrechnung Note answers "c4angerechnetKeinePruefung", so the test leaves it out
const anrechnung = (ctx.cisConfig.NOTEN_ANRECHNUNG || []).map(String);
const locked = ctx.notenOptions.find(
(n) => n.lkt_ueberschreibbar === false && !anrechnung.includes(String(n.note)),
);
skipIf(this, !locked, "Übersprungen: keine gesperrte Note ausser den Anrechnungsnoten.");
const student = ctx.students[1];
givenBaseline(ctx, student);
seedZeugnisnote(ctx, student, locked.note);
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, 1) }).then((response) =>
expectNotenError(response, "c4zeugnisnoteGesperrt"),
);
expectOnlyAntritt1(student);
});
it("lehnt im Sammeldialog jede Zeile mit einer Verwaltungsnote ab", function () {
skipWithoutVerwaltungsnote(this);
const students = [ctx.students[0], ctx.students[1]];
resetNotenState(ctx);
notenApi
.createPruefungen(
ctx.lvId,
ctx.semKurzbz,
students.map((s) => ({ uid: s.uid, lehreinheit_id: s.lehreinheit_id })),
{ datum: antrittDate(ctx, 1), note: ctx.noten.notLehre },
)
.then((response) => {
const data = expectNotenSuccess(response, "createPruefungen mit einer Verwaltungsnote");
students.forEach((s) => expectBulkRowError(data, s.uid, "c4noteNichtInLehre"));
});
students.forEach((s) =>
readLvGesamtnoteViaDb(ctx, s).then((row) => {
expect(row, `keine LV-Note für ${s.uid}`).to.be.null;
}),
);
});
});
// The same flow through the CSV import, the way an Assistenz works. The bulk endpoint returns
// HTTP 200 and reports the rejected row in data[uid].error.
describe("importLvNoten", () => {
beforeEach(function () {
// in the Punkte mode the import derives the Note from the Punkte and drops a row without
// Punkte before these rules apply
requireNotenMode(this, ctx);
requireRepeat(this, ctx);
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENIMPORT", true);
});
it("lehnt je Zeile eine Änderung des Notenvorschlags nach einer Prüfung ab", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_VORSCHLAG_NACH_WIEDERHOLUNG", false);
const student = ctx.students[3];
givenBaseline(ctx, student);
addPruefung(ctx, student, { note: ctx.notenScale[1], datum: antrittDate(ctx, 1) }).then((response) => {
expectNotenSuccess(response, "eine Prüfung anlegen");
});
notenApi
.importLvNoten(ctx.lvId, ctx.semKurzbz, [{ uid: student.uid, note: ctx.notenScale[0], punkte: null }])
.then((response) => {
const data = expectNotenSuccess(response, "importLvNoten");
expectBulkRowError(data, student.uid, "c4notenvorschlagGesperrt");
})
.then(() => readLvGesamtnoteViaDb(ctx, student))
.then((row) => {
expect(String(row.note), "die Note des Antritts überlebt den Import").to.eq(
String(ctx.notenScale[1]),
);
});
});
});
});
@@ -0,0 +1,445 @@
/**
* The write path (savePruefungForStudent) that savePruefung, createPruefungen and
* importPruefungen share. The other specs cover the rules.
*
* Invariant: one action writes exactly one Pruefung. Exception: next to an LV-Note without a Pruefung
* row, the first new Pruefung first writes Antritt 1.
*/
import { expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
import { requireConfig, requireRepeat } from "../../../../support/helpers/notenConfig";
import {
antrittDate,
baselineDate,
loadNotenContext,
readLvGesamtnoteViaDb,
requireDbReset,
resetNotenState,
seedPruefung,
shiftDate,
} from "../../../../support/helpers/notenTestData";
import {
addPruefung,
pruefungenOf,
editPruefung,
givenBaseline,
readStateViaApi,
verlaufOf,
} from "../../../../support/helpers/notenScenario";
import { notenApi, loginAsLektor } from "../../../../support/api/notenApi";
const dayOf = (value) => String(value).slice(0, 10);
describe("Noten API - Prüfungstermin (Schreibpfad)", () => {
let ctx;
before(() => {
requireDbReset();
loadNotenContext().then((loaded) => {
ctx = loaded;
expect(ctx.maxAntritte, "Platz für mindestens eine Wiederholung").to.be.greaterThan(1);
});
});
beforeEach(() => loginAsLektor());
it("schreibt je Anlage genau eine Prüfung, ohne Kopie daneben", function () {
requireRepeat(this, ctx);
const student = ctx.students[0];
givenBaseline(ctx, student);
addPruefung(ctx, student, { note: ctx.notenScale[1], datum: antrittDate(ctx, 1) })
.then((response) => {
const { pruefung, lvgesamtnote, verlauf } = expectNotenSuccess(response, "Prüfung anlegen")[
student.uid
];
expect(pruefung, "die gespeicherte Prüfung").to.exist;
expect(String(pruefung.note)).to.eq(String(ctx.notenScale[1]));
expect(lvgesamtnote, "die Antwort trägt die LV-Note für die Zeile").to.exist;
expect(verlauf, "die Antwort trägt den Verlauf").to.exist;
expect(verlauf.pruefungen, "der Verlauf trägt die Antritte").to.be.an("array");
})
.then(() => readStateViaApi(ctx))
.then((data) => {
const pruefungen = pruefungenOf(data, student.uid);
expect(pruefungen, "Antritt 1 der Vorlage und genau eine neue Zeile").to.have.length(2);
});
});
it("leitet Position und Antrittsnummer auf dem Server ab", function () {
requireRepeat(this, ctx);
const student = ctx.students[1];
givenBaseline(ctx, student);
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, 1) })
.then(() => readStateViaApi(ctx))
.then((data) => {
const pruefungen = pruefungenOf(data, student.uid);
expect(
pruefungen.map((p) => p.position),
"positions are 1..n in date order",
).to.deep.eq([1, 2]);
expect(
pruefungen.map((p) => p.antritt_nr),
"beide sind Antritte",
).to.deep.eq([1, 2]);
pruefungen.forEach((p) => expect(p.is_antritt, `Antritt an Position ${p.position}`).to.be.true);
const verlauf = verlaufOf(data, student.uid);
expect(verlauf.antrittCount, "beide Antritte gezählt").to.eq(2);
expect(verlauf.maxAntritte, "die Grenze kommt vom Server").to.eq(ctx.maxAntritte);
// the last Antritt is kommissionell; if the Benotungstool cannot create it, the chain ends here
const creatable =
ctx.cisConfig.CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF !== false
? ctx.maxAntritte
: ctx.maxAntritte - 1;
expect(verlauf.canAdd, `canAdd bei 2 von ${creatable} möglichen`).to.eq(2 < creatable);
});
});
it("hängt je Anlage eine neue Zeile an, statt die vorherige zu überschreiben", function () {
requireRepeat(this, ctx);
// only an explicit pruefung_id updates; an add always inserts a row
const student = ctx.students[0];
givenBaseline(ctx, student);
let firstId;
addPruefung(ctx, student, { note: ctx.noten.entschuldigt, datum: antrittDate(ctx, 1) })
.then((response) => {
firstId = expectNotenSuccess(response, "erste Anlage")[student.uid].pruefung.pruefung_id;
return addPruefung(ctx, student, { note: ctx.notenScale[1], datum: antrittDate(ctx, 2) });
})
.then((response) => expectNotenSuccess(response, "zweite Anlage"))
.then(() => readStateViaApi(ctx))
.then((data) => {
const pruefungen = pruefungenOf(data, student.uid);
expect(pruefungen, "Vorlage und zwei neue Zeilen").to.have.length(3);
expect(
pruefungen.map((p) => p.pruefung_id),
"the first add still exists",
).to.include(firstId);
});
});
it("bearbeitet die Prüfung mit der übergebenen pruefung_id", () => {
// an entschuldigt and a real Pruefung side by side: no endpoint builds this, so the test seeds it
const student = ctx.students[2];
const entschuldigtDate = antrittDate(ctx, 1);
const withNoteDate = antrittDate(ctx, 2);
const movedTo = shiftDate(entschuldigtDate, 5);
givenBaseline(ctx, student);
let entschuldigtId;
seedPruefung(ctx, student, { note: ctx.noten.entschuldigt, datum: entschuldigtDate, type: "Termin2" })
.then((seeded) => {
entschuldigtId = seeded.pruefungId;
return seedPruefung(ctx, student, { note: ctx.notenScale[0], datum: withNoteDate, type: "Termin2" });
})
// note stays entschuldigt: a later pruefung exists, so only the datum may move
.then(() =>
editPruefung(ctx, student, {
pruefungId: entschuldigtId,
note: ctx.noten.entschuldigt,
datum: movedTo,
}),
)
.then((response) => expectNotenSuccess(response, "die entschuldigte Prüfung verschieben"))
.then(() => readStateViaApi(ctx))
.then((data) => {
const rows = pruefungenOf(data, student.uid);
const entschuldigt = rows.find((p) => p.pruefung_id === entschuldigtId);
const withNote = rows.find((p) => dayOf(p.datum) === withNoteDate);
// the other row first: a silent change of a real Note is the worse error
expect(withNote, "der andere Antritt bleibt").to.exist;
expect(String(withNote.note), "die andere Prüfung behält ihre Note").to.eq(String(ctx.notenScale[0]));
expect(entschuldigt, "die entschuldigte Prüfung bleibt").to.exist;
expect(dayOf(entschuldigt.datum), "die Zeile mit dieser pruefung_id ist verschoben").to.eq(movedTo);
});
});
it("schreibt die LV-Note aus der Note des Antritts", function () {
requireRepeat(this, ctx);
const student = ctx.students[3];
givenBaseline(ctx, student);
addPruefung(ctx, student, { note: ctx.notenScale[1], datum: antrittDate(ctx, 1) })
.then((response) => expectNotenSuccess(response, "eine Prüfung mit Note"))
.then(() => readLvGesamtnoteViaDb(ctx, student))
.then((row) => {
expect(String(row.note), "die LV-Note folgt dem letzten Antritt").to.eq(String(ctx.notenScale[1]));
});
});
// An LV-Note without a Pruefung row is Antritt 1: from a proposal without ERSTANTRITT_BEI_UEBERNAHME,
// or from the time before the Benotungstool. Without that row the count misses it, and the student
// gets one Antritt too many.
it("schreibt Antritt 1 aus einer LV-Note ohne Prüfungszeile nach", function () {
requireRepeat(this, ctx);
const student = ctx.students[4];
givenBaseline(ctx, student, { erstantritt: false });
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, 1) }).then((response) =>
expectNotenSuccess(response, "erster Termin neben der LV-Note"),
);
readStateViaApi(ctx).then((data) => {
const pruefungen = pruefungenOf(data, student.uid);
expect(pruefungen, "Antritt 1 und der neue Termin").to.have.length(2);
expect(String(pruefungen[0].note), "Antritt 1 trägt die LV-Note").to.eq(String(ctx.noten.negativ));
expect(dayOf(pruefungen[0].datum) < antrittDate(ctx, 1), "Antritt 1 liegt vor dem neuen Termin").to.be.true;
expect(
pruefungen.map((p) => p.antritt_nr),
"Antrittsnummern",
).to.deep.eq([1, 2]);
expect(verlaufOf(data, student.uid).antrittCount, "zwei Antritte").to.eq(2);
});
});
// Rule: the LV-Note is the Note of the last Pruefung that counts as an Antritt. Without such a
// Pruefung the implicit Antritt 1 stays, otherwise the LV-Note is "Noch nicht eingetragen".
// The LV-Note is never "entschuldigt".
describe("LV-Note nach einem Termin", () => {
const g2 = () =>
ctx.noten.positiv ??
ctx.noten.bestnote ??
ctx.notenScale.find((n) => String(n) !== String(ctx.noten.negativ));
const expectLvNote = (student, note, message) =>
readLvGesamtnoteViaDb(ctx, student).then((row) => {
expect(String(row.note), message).to.eq(String(note));
return row;
});
const expectAntritte = (student, count) =>
readStateViaApi(ctx).then((data) => {
expect(verlaufOf(data, student.uid).antrittCount, "antrittCount").to.eq(count);
});
it("behält die LV-Note bei einer Datumskorrektur an Termin 1", function () {
requireRepeat(this, ctx);
const student = ctx.students[0];
givenBaseline(ctx, student);
addPruefung(ctx, student, { note: g2(), datum: antrittDate(ctx, 1) }).then((response) =>
expectNotenSuccess(response, "Termin 2"),
);
readStateViaApi(ctx)
.then((data) => {
const t1 = pruefungenOf(data, student.uid)[0];
// the Note stays the same, otherwise validateEdit answers pruefungNoteLocked
return editPruefung(ctx, student, {
pruefungId: t1.pruefung_id,
note: t1.note,
datum: shiftDate(baselineDate(ctx), 1),
});
})
.then((response) => expectNotenSuccess(response, "Datumskorrektur an Termin 1"));
expectLvNote(student, g2(), "die LV-Note bleibt beim Ergebnis von Termin 2");
});
it("behält die LV-Note bei einem neuen entschuldigten Termin", function () {
requireRepeat(this, ctx);
const student = ctx.students[1];
givenBaseline(ctx, student);
addPruefung(ctx, student, { note: ctx.noten.entschuldigt, datum: antrittDate(ctx, 1) }).then((response) =>
expectNotenSuccess(response, "entschuldigter Termin"),
);
expectLvNote(student, ctx.noten.negativ, "die LV-Note bleibt beim Ergebnis von Termin 1");
expectAntritte(student, 1);
});
// no requireRepeat: the test needs only Antritt 1. With two Antritte and without
// ALLOW_CREATE_KOMMPRUEF it would fail
it("setzt 'Noch nicht eingetragen', wenn der erste Termin entschuldigt ist", () => {
const student = ctx.students[2];
resetNotenState(ctx);
addPruefung(ctx, student, { note: ctx.noten.entschuldigt, datum: antrittDate(ctx, 1) }).then((response) =>
expectNotenSuccess(response, "entschuldigter erster Termin"),
);
expectLvNote(student, ctx.noten.nochNichtEingetragen, "die LV-Note ist nie entschuldigt").then((row) => {
expect(row.punkte, "ohne Punkte").to.be.null;
});
readStateViaApi(ctx).then((data) => {
const verlauf = verlaufOf(data, student.uid);
expect(verlauf.antrittCount, "der entschuldigte Termin verbraucht keinen Antritt").to.eq(0);
expect(verlauf.canAdd, "ein weiterer Termin ist möglich").to.be.true;
});
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, 2) }).then((response) =>
expectNotenSuccess(response, "erster zählender Termin"),
);
readStateViaApi(ctx).then((data) => {
const counting = pruefungenOf(data, student.uid).find(
(p) => String(p.note) === String(ctx.noten.negativ),
);
expect(counting.antritt_nr, "der Studierende hat keinen Antritt verloren").to.eq(1);
});
});
it("setzt 'Noch nicht eingetragen', wenn der einzige Termin nachträglich entschuldigt wird", () => {
const student = ctx.students[3];
givenBaseline(ctx, student);
readStateViaApi(ctx)
.then((data) => {
const t1 = pruefungenOf(data, student.uid)[0];
return editPruefung(ctx, student, {
pruefungId: t1.pruefung_id,
note: ctx.noten.entschuldigt,
datum: dayOf(t1.datum),
});
})
.then((response) => expectNotenSuccess(response, "Termin 1 wird entschuldigt"));
expectLvNote(student, ctx.noten.nochNichtEingetragen, "die LV-Note ist nie entschuldigt");
// the old LV-Note does not count as the implicit Antritt 1
expectAntritte(student, 0);
});
it("fällt auf Termin 1 zurück, wenn die Wiederholung nachträglich entschuldigt wird", function () {
requireRepeat(this, ctx);
const student = ctx.students[0];
givenBaseline(ctx, student);
addPruefung(ctx, student, { note: g2(), datum: antrittDate(ctx, 1) }).then((response) =>
expectNotenSuccess(response, "Termin 2"),
);
readStateViaApi(ctx)
.then((data) => {
const t2 = pruefungenOf(data, student.uid)[1];
return editPruefung(ctx, student, {
pruefungId: t2.pruefung_id,
note: ctx.noten.entschuldigt,
datum: dayOf(t2.datum),
});
})
.then((response) => expectNotenSuccess(response, "Termin 2 wird entschuldigt"));
expectLvNote(student, ctx.noten.negativ, "die LV-Note ist wieder das Ergebnis von Termin 1");
expectAntritte(student, 1);
});
it("behält eine LV-Note aus Altdaten als Ergebnis von Antritt 1", function () {
requireRepeat(this, ctx);
const student = ctx.students[1];
givenBaseline(ctx, student, { erstantritt: false });
addPruefung(ctx, student, { note: ctx.noten.entschuldigt, datum: antrittDate(ctx, 1) }).then((response) =>
expectNotenSuccess(response, "entschuldigter Termin neben der Altdaten-LV-Note"),
);
expectLvNote(student, ctx.noten.negativ, "die Altdaten-LV-Note bleibt");
expectAntritte(student, 1);
});
it("behält das letzte Ergebnis bei einem offenen Termin", function () {
requireRepeat(this, ctx);
const student = ctx.students[2];
givenBaseline(ctx, student);
addPruefung(ctx, student, { note: ctx.noten.nochNichtEingetragen, datum: antrittDate(ctx, 1) }).then(
(response) => expectNotenSuccess(response, "offener Termin"),
);
expectLvNote(student, ctx.noten.negativ, "die LV-Note bleibt beim Ergebnis von Termin 1");
});
it("lehnt 'entschuldigt' als übernommene LV-Note ab", () => {
const student = ctx.students[3];
givenBaseline(ctx, student, { erstantritt: false });
notenApi
.saveLvNote(ctx.lvId, ctx.semKurzbz, student.uid, ctx.noten.entschuldigt)
.then((response) => expectNotenError(response, "c4noteNichtInLehre"));
expectLvNote(student, ctx.noten.negativ, "die LV-Note bleibt");
});
});
// CIS_GESAMTNOTE_PRUEFUNG_HEBT_FREIGABE_AUF: the Freigabe state depends on benotungsdatum > freigabedatum.
describe("Freigabe nach einem neuen Termin", () => {
beforeEach(function () {
// a final Freigabe forbids the new Pruefung
requireConfig(this, ctx, "CIS_GESAMTNOTE_FREIGABE_FINAL", false);
requireRepeat(this, ctx);
});
const newPruefungAfterFreigabe = (student) => {
givenBaseline(ctx, student, { freigegeben: true });
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, 1) }).then((response) =>
expectNotenSuccess(response, "Termin nach der Freigabe"),
);
return readLvGesamtnoteViaDb(ctx, student);
};
it("hebt die Freigabe mit einem neuen Termin auf", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNG_HEBT_FREIGABE_AUF", true);
newPruefungAfterFreigabe(ctx.students[5]).then((row) => {
expect(new Date(row.benotungsdatum) > new Date(row.freigabedatum), "benotungsdatum nach freigabedatum")
.to.be.true;
});
});
it("behält die Freigabe bei einem neuen Termin", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNG_HEBT_FREIGABE_AUF", false);
newPruefungAfterFreigabe(ctx.students[5]).then((row) => {
expect(new Date(row.benotungsdatum) > new Date(row.freigabedatum), "benotungsdatum nach freigabedatum")
.to.be.false;
});
});
});
it("legt die LV-Note an, wenn ein Studierender noch keine hat", () => {
// the bulk endpoint runs through the same core and writes the LV-Note before the Pruefung
const student = ctx.students[0];
resetNotenState(ctx);
notenApi
.createPruefungen(ctx.lvId, ctx.semKurzbz, [{ uid: student.uid, lehreinheit_id: student.lehreinheit_id }], {
datum: antrittDate(ctx, 1),
})
.then((response) => {
const data = expectNotenSuccess(response, "createPruefungen ohne LV-Note");
expect(data[student.uid], `Ergebniszeile von ${student.uid}`).to.be.an("object");
expect(data[student.uid].pruefung, "die Prüfung ist geschrieben").to.exist;
})
.then(() => readLvGesamtnoteViaDb(ctx, student))
.then((row) => {
expect(row, "die LV-Note entsteht mit").to.not.be.null;
expect(String(row.note), "trägt die Note der Prüfung").to.eq(String(ctx.noten.nochNichtEingetragen));
});
});
});
@@ -0,0 +1,334 @@
/**
* The configurable rules for the date of a Pruefung.
*
* No rule value is hardcoded. Each switch has one test per branch. A test skips itself when the other
* branch is configured, and a profile in tests/cypress/profiles/noten.js runs it.
*/
import { notenApi, loginAsLektor } from "../../../../support/api/notenApi";
import {
expectBulkRowAccepted,
expectBulkRowError,
expectNotenError,
expectNotenSuccess,
} from "../../../../support/helpers/notenErrors";
import { requireConfig, requireNotenMode, requireRepeat, skipIf } from "../../../../support/helpers/notenConfig";
import {
antrittDate,
baselineDate,
loadNotenContext,
requireDbReset,
resetNotenState,
seedBaseline,
shiftDate,
} from "../../../../support/helpers/notenTestData";
import {
addPruefung,
pruefungenOf,
editPruefung,
givenBaseline,
readStateViaApi,
} from "../../../../support/helpers/notenScenario";
describe("Noten API - konfigurierbare Terminregeln", () => {
let ctx;
before(() => {
loadNotenContext().then((loaded) => {
ctx = loaded;
cy.log(`TERMIN_GLEICHER_TAG = ${ctx.cisConfig.CIS_GESAMTNOTE_PRUEFUNG_GLEICHER_TAG}`);
cy.log(
`NOTE_SPERRE_BEI_SPAETEREM_TERMIN = ${ctx.cisConfig.CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETERER_PRUEFUNG}`,
);
cy.log(`DATUM_ZUKUNFT = ${ctx.cisConfig.CIS_GESAMTNOTE_DATUM_ZUKUNFT}`);
cy.log(`ANTRITT_MIN_ABSTAND_TAGE = ${ctx.cisConfig.CIS_GESAMTNOTE_ANTRITT_MIN_ABSTAND_TAGE}`);
cy.log(`ANTRITT_MAX_ABSTAND_TAGE = ${ctx.cisConfig.CIS_GESAMTNOTE_ANTRITT_MAX_ABSTAND_TAGE}`);
});
});
beforeEach(() => loginAsLektor());
beforeEach(() => requireDbReset());
// The rules compare Y-m-d strings. A different format sorts the Pruefungen wrong.
describe("Prüfungsdatum", () => {
const impossibleDay = () => `${baselineDate(ctx).slice(0, 4)}-02-31`;
[
["einen Kalendertag, den es nicht gibt", impossibleDay],
["das Format d.m.Y", () => antrittDate(ctx, 1).split("-").reverse().join(".")],
["ein leeres Datum", () => ""],
].forEach(([label, datum]) => {
it(`lehnt ${label} ab`, () => {
const student = ctx.students[0];
givenBaseline(ctx, student);
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: datum() }).then((response) =>
expectNotenError(response, "pruefungsdatumUngueltig"),
);
readStateViaApi(ctx).then((data) => {
expect(pruefungenOf(data, student.uid), "nur Antritt 1").to.have.length(1);
});
});
});
it("speichert ein Datum mit Uhrzeit als Kalendertag", function () {
requireRepeat(this, ctx);
const student = ctx.students[1];
const datum = antrittDate(ctx, 1);
givenBaseline(ctx, student);
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: `${datum} 10:00:00` }).then((response) =>
expectNotenSuccess(response, "Datum mit Uhrzeit"),
);
readStateViaApi(ctx).then((data) => {
const newPruefung = pruefungenOf(data, student.uid)[1];
expect(String(newPruefung.datum).slice(0, 10), "der Kalendertag").to.eq(datum);
});
});
it("lehnt im Import nur die Zeile mit dem ungültigen Datum ab", function () {
requireNotenMode(this, ctx);
requireRepeat(this, ctx);
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNGSIMPORT", true);
const valid = ctx.students[0];
const invalid = ctx.students[1];
const bulkRow = (student, datum) => ({
uid: student.uid,
note: ctx.noten.negativ,
punkte: null,
datum,
lehreinheit_id: student.lehreinheit_id,
});
resetNotenState(ctx);
seedBaseline(ctx, valid);
seedBaseline(ctx, invalid);
notenApi
.importPruefungen(ctx.lvId, ctx.semKurzbz, [
bulkRow(valid, antrittDate(ctx, 1)),
bulkRow(invalid, impossibleDay()),
])
.then((response) => {
const data = expectNotenSuccess(response, "importPruefungen");
expectBulkRowAccepted(data, valid.uid);
expectBulkRowError(data, invalid.uid, "pruefungsdatumUngueltig");
});
readStateViaApi(ctx).then((data) => {
expect(pruefungenOf(data, invalid.uid), "nur Antritt 1").to.have.length(1);
});
});
});
// A second Pruefung on the same day: too early, or allowed by the configuration.
describe("CIS_GESAMTNOTE_PRUEFUNG_GLEICHER_TAG", () => {
beforeEach(function () {
requireRepeat(this, ctx);
});
// Antritt 1 is on baselineDate, and the new Pruefung is on exactly that day
const pruefungOnBaselineDay = (student) => {
givenBaseline(ctx, student);
return addPruefung(ctx, student, { note: ctx.notenScale[1], datum: baselineDate(ctx) });
};
it("lehnt einen Termin am selben Tag ab", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNG_GLEICHER_TAG", false);
pruefungOnBaselineDay(ctx.students[0]).then((response) =>
expectNotenError(response, "pruefungDatumBeforeExisting"),
);
});
it("nimmt einen Termin am selben Tag an, wenn der Schalter an ist", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNG_GLEICHER_TAG", true);
pruefungOnBaselineDay(ctx.students[0]).then((response) =>
expectNotenSuccess(response, "Termin am selben Tag"),
);
});
it("lehnt einen Termin vor einem bestehenden immer ab", () => {
const student = ctx.students[1];
givenBaseline(ctx, student);
// one day earlier is too early in both configurations
addPruefung(ctx, student, {
note: ctx.notenScale[1],
datum: shiftDate(baselineDate(ctx), -1),
}).then((response) => expectNotenError(response, "pruefungDatumBeforeExisting"));
});
});
// Does a later Pruefung lock the Note of the earlier one?
describe("CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETERER_PRUEFUNG", () => {
beforeEach(function () {
requireRepeat(this, ctx);
});
/** Antritt 1 and a later Pruefung. Then the request changes only the Note of Antritt 1. */
const changeNoteOfAntritt1 = (student) => {
givenBaseline(ctx, student);
return addPruefung(ctx, student, { note: ctx.notenScale[1], datum: antrittDate(ctx, 1) })
.then((response) => expectNotenSuccess(response, "späterer Termin"))
.then(() => readStateViaApi(ctx))
.then((data) => {
const firstAntritt = pruefungenOf(data, student.uid)[0];
expect(firstAntritt, "Antritt 1").to.exist;
return editPruefung(ctx, student, {
pruefungId: firstAntritt.pruefung_id,
note: ctx.notenScale[1],
datum: firstAntritt.datum,
});
});
};
it("sperrt die Note eines früheren Termins", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETERER_PRUEFUNG", true);
changeNoteOfAntritt1(ctx.students[2]).then((response) => expectNotenError(response, "pruefungNoteLocked"));
});
it("lässt die Note eines früheren Termins ändern, wenn der Schalter aus ist", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETERER_PRUEFUNG", false);
changeNoteOfAntritt1(ctx.students[2]).then((response) =>
expectNotenSuccess(response, "Note trotz späterem Termin"),
);
});
});
// A Benotungsdatum in the future.
describe("CIS_GESAMTNOTE_DATUM_ZUKUNFT", () => {
const proposalWithTomorrow = (student) => {
const tomorrow = shiftDate(new Date().toISOString().slice(0, 10), 1);
givenBaseline(ctx, student, { erstantritt: false });
return notenApi.saveLvNote(ctx.lvId, ctx.semKurzbz, student.uid, ctx.notenScale[0], null, tomorrow);
};
it("lehnt ein künftiges Benotungsdatum ab", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_DATUM_ZUKUNFT", false);
proposalWithTomorrow(ctx.students[3]).then((response) =>
expectNotenError(response, "benotungsdatumInZukunft"),
);
});
it("nimmt ein künftiges Benotungsdatum an, wenn der Schalter an ist", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_DATUM_ZUKUNFT", true);
proposalWithTomorrow(ctx.students[3]).then((response) =>
expectNotenSuccess(response, "künftiges Benotungsdatum"),
);
});
});
// The minimum gap between two Antritte. Without a value the rule is off.
describe("CIS_GESAMTNOTE_ANTRITT_MIN_ABSTAND_TAGE", () => {
let min;
beforeEach(function () {
min = Number(ctx.cisConfig.CIS_GESAMTNOTE_ANTRITT_MIN_ABSTAND_TAGE);
skipIf(
this,
!Number.isFinite(min) || min <= 0,
"Übersprungen: CIS_GESAMTNOTE_ANTRITT_MIN_ABSTAND_TAGE ist nicht gesetzt.",
);
requireRepeat(this, ctx);
});
it("lehnt einen Antritt vor Ablauf der Wartefrist ab", () => {
const student = ctx.students[4];
givenBaseline(ctx, student);
addPruefung(ctx, student, {
note: ctx.notenScale[1],
datum: shiftDate(baselineDate(ctx), min - 1),
}).then((response) => expectNotenError(response, "pruefungAbstandZuKurz"));
});
it("nimmt einen Antritt genau am Ende der Wartefrist an", () => {
const student = ctx.students[5];
givenBaseline(ctx, student);
addPruefung(ctx, student, {
note: ctx.notenScale[1],
datum: shiftDate(baselineDate(ctx), min),
}).then((response) => expectNotenSuccess(response, "Antritt am Ende der Wartefrist"));
});
it("lehnt es ab, einen Antritt in die Wartefrist zu verschieben", () => {
const student = ctx.students[5];
givenBaseline(ctx, student);
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: shiftDate(baselineDate(ctx), min) }).then(
(response) => {
const data = expectNotenSuccess(response, "Antritt am Ende der Wartefrist");
editPruefung(ctx, student, {
pruefungId: data[student.uid].pruefung.pruefung_id,
note: ctx.noten.negativ,
datum: shiftDate(baselineDate(ctx), min - 1),
}).then((edit) => expectNotenError(edit, "pruefungAbstandZuKurz"));
},
);
});
});
// The maximum gap to the next Antritt.
describe("CIS_GESAMTNOTE_ANTRITT_MAX_ABSTAND_TAGE", () => {
let max;
beforeEach(function () {
max = Number(ctx.cisConfig.CIS_GESAMTNOTE_ANTRITT_MAX_ABSTAND_TAGE);
skipIf(
this,
!Number.isFinite(max) || max <= 0,
"Übersprungen: CIS_GESAMTNOTE_ANTRITT_MAX_ABSTAND_TAGE ist nicht gesetzt.",
);
requireRepeat(this, ctx);
});
it("lehnt einen Antritt nach Ablauf der Höchstfrist ab", () => {
const student = ctx.students[6];
givenBaseline(ctx, student);
addPruefung(ctx, student, {
note: ctx.notenScale[1],
datum: shiftDate(baselineDate(ctx), max + 1),
}).then((response) => expectNotenError(response, "pruefungAbstandZuLang"));
});
it("lehnt es ab, einen Antritt hinter die Höchstfrist zu verschieben", () => {
const student = ctx.students[6];
givenBaseline(ctx, student);
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: shiftDate(baselineDate(ctx), max) }).then(
(response) => {
const data = expectNotenSuccess(response, "Antritt am Ende der Höchstfrist");
editPruefung(ctx, student, {
pruefungId: data[student.uid].pruefung.pruefung_id,
note: ctx.noten.negativ,
datum: shiftDate(baselineDate(ctx), max + 1),
}).then((edit) => expectNotenError(edit, "pruefungAbstandZuLang"));
},
);
});
});
});
@@ -0,0 +1,161 @@
/**
* The role matrix: which permission may do which action.
*
* Each account asks getCisConfig for its actions. Each action has two tests: one runs when the matrix
* allows the action, the other when the matrix withholds it. The profile "rollen" withholds the Freigabe
* and the kommissionelle Pruefung from the Assistenz, otherwise the second test never runs.
*/
import { apiGet, apiPost, loginAsLektor } from "../../../../support/api/notenApi";
import { expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
import { requireKommissionellerAntritt, skipIf } from "../../../../support/helpers/notenConfig";
import { antrittDate, loadNotenContext, requireDbReset } from "../../../../support/helpers/notenTestData";
import { addPruefung, givenBaseline } from "../../../../support/helpers/notenScenario";
const accounts = () => [
{
label: "Lektor",
user: Cypress.env("NOTEN_LEKTOR_USER"),
pass: Cypress.env("NOTEN_LEKTOR_PASSWORD"),
},
{
label: "Assistenz",
user: Cypress.env("NOTEN_ASSISTENZ_USER"),
pass: Cypress.env("NOTEN_ASSISTENZ_PASSWORD"),
},
];
// the first statement of each endpoint checks its action; kommpruef is checked at the kommissionell Antritt
const ENDPOINT = {
lvnote: "saveLvNote",
pruefung: "savePruefung",
freigabe: "saveFreigabe",
import: "importLvNoten",
};
/** A call that only reaches the action check. The empty body ends in missingParameters otherwise. */
const callAction = (auth, action, ctx) =>
apiPost(ENDPOINT[action], { lv_id: ctx.lvId, sem_kurzbz: ctx.semKurzbz }, auth);
const errorCodes = (response) => (response.body?.errors ?? []).map((e) => e.code);
describe("Noten API - die Rollenmatrix", () => {
let ctx;
before(() => {
loadNotenContext().then((loaded) => {
ctx = loaded;
});
});
beforeEach(() => loginAsLektor());
accounts().forEach(({ label, user, pass }) => {
describe(label, () => {
const auth = { username: user, password: pass };
let allowed = [];
before(function () {
skipIf(this, !user || !pass, `Übersprungen: kein Konto für "${label}" in tests/cypress/suites/.env.`);
cy.clearAllCookies();
apiGet("getCisConfig", undefined, auth).then((response) => {
// every account of the suite holds a permission of the matrix; seeder group benotungstool_berechtigungen creates it
expect(response.status, `getCisConfig als ${label}`).to.eq(200);
allowed = response.body.data.CIS_GESAMTNOTE_AKTIONEN || [];
cy.log(`${label} darf: ${JSON.stringify(allowed)}`);
});
});
// otherwise the request sends the session cookie of loginAsLektor, and the server prefers the cookie
beforeEach(() => cy.clearAllCookies());
Object.keys(ENDPOINT).forEach((action) => {
it(`führt "${action}" aus, wenn die Matrix es erlaubt`, function () {
skipIf(this, !allowed.includes(action), `Übersprungen: die Matrix entzieht ${label} "${action}".`);
callAction(auth, action, ctx).then((res) => {
expect(errorCodes(res), `${label} darf "${action}"`).to.not.include("aktionNichtErlaubt");
});
});
it(`lehnt "${action}" ab, wenn die Matrix es entzieht`, function () {
skipIf(this, allowed.includes(action), `Übersprungen: die Matrix erlaubt ${label} "${action}".`);
callAction(auth, action, ctx).then((res) => expectNotenError(res, "aktionNichtErlaubt"));
});
});
describe("der kommissionelle Antritt", () => {
const student = () => ctx.students[6];
beforeEach(function () {
requireKommissionellerAntritt(this, ctx);
requireDbReset();
});
/** NOTEN_LEKTOR_USER builds the chain up to the kommissionell Antritt. Returns its date. */
const bringToBeforeKommission = () => {
const fromAntritt = ctx.cisConfig.CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT;
givenBaseline(ctx, student());
for (let nr = 2; nr < fromAntritt; nr += 1) {
addPruefung(ctx, student(), { note: ctx.noten.negativ, datum: antrittDate(ctx, nr - 1) }).then(
(response) => expectNotenSuccess(response, `Antritt ${nr}`),
);
}
return antrittDate(ctx, fromAntritt - 1);
};
const createKommissionell = (datum) => {
const s = student();
// the chain requests can leave a session cookie of NOTEN_LEKTOR_USER
cy.clearAllCookies();
return apiPost(
"savePruefung",
{
lv_id: ctx.lvId,
sem_kurzbz: ctx.semKurzbz,
student_uid: s.uid,
pruefung_id: null,
lehreinheit_id: s.lehreinheit_id,
datum,
note: ctx.noten.negativ,
punkte: null,
},
auth,
);
};
it("legt ihn an, wenn die Matrix es erlaubt", function () {
skipIf(
this,
!allowed.includes("kommpruef"),
`Übersprungen: die Matrix entzieht ${label} "kommpruef".`,
);
createKommissionell(bringToBeforeKommission()).then((response) => {
const { pruefung } = expectNotenSuccess(response, `kommissioneller Antritt als ${label}`)[
student().uid
];
expect(pruefung.pruefungstyp_kurzbz, "der Antritt ist kommissionell").to.eq(
ctx.cisConfig.PRUEFUNG_TYP_KOMMISSIONELL,
);
});
});
it("lehnt ihn ab, wenn die Matrix es entzieht", function () {
skipIf(
this,
allowed.includes("kommpruef"),
`Übersprungen: die Matrix erlaubt ${label} "kommpruef".`,
);
createKommissionell(bringToBeforeKommission()).then((response) =>
expectNotenError(response, "kommPruefNichtErlaubt"),
);
});
});
});
});
});
@@ -0,0 +1,469 @@
/**
* The scope of the write endpoints: a Pruefung belongs to the student and the LV of the request.
*
* An edit changes only a Pruefung of this student in this LV (else pruefungNichtBearbeitbar). The server
* picks the Lehreinheit, and a student outside the LV gets no Note (studentNichtInLv). Every test also
* checks the database.
*
* Seeder group `benotungstool_fixture_erweitert` creates a second Lehreinheit with two Lektoren.
*/
import { apiPost, notenApi, loginAsLektor } from "../../../../support/api/notenApi";
import {
expectBulkRowAccepted,
expectBulkRowError,
expectNotenError,
expectNotenSuccess,
} from "../../../../support/helpers/notenErrors";
import { requireConfig, requireNotenMode, requireRepeat, skipIf } from "../../../../support/helpers/notenConfig";
import { assistenzAuth, requireAssistenz } from "../../../../support/helpers/notenAssistenz";
import {
antrittDate,
baselineDate,
loadNotenContext,
readAuthUid,
readLvGesamtnoteViaDb,
requireDbReset,
resetNotenState,
seedBaseline,
seedPruefung,
shiftDate,
} from "../../../../support/helpers/notenTestData";
import { pruefungenOf, editPruefung, readStateViaApi } from "../../../../support/helpers/notenScenario";
const dayOf = (value) => String(value).slice(0, 10);
describe("Noten API - Scope der Schreibpfade", () => {
let ctx;
let a;
let b;
// null: the instance cannot build the case, and the test skips
let outsider = null;
let foreign = null;
let otherLe = null;
let typeWithoutAntritt = null;
const lvScope = () => ({ lvId: ctx.lvId, semKurzbz: ctx.semKurzbz });
/** A student with exactly one Lehreinheit, and a second Lehreinheit of the same LV. */
const findOtherLe = (lehreinheiten, i = 0) => {
if (lehreinheiten.length < 2 || i >= ctx.students.length) return cy.wrap(null, { log: false });
const student = ctx.students[i];
return cy.task("noten:db:readLehreinheitenOfStudent", { ...lvScope(), studentUid: student.uid }).then((own) => {
if (own.length !== 1) return findOtherLe(lehreinheiten, i + 1);
const other = lehreinheiten.find((le) => String(le) !== String(own[0]));
return { student, ownLe: own[0], otherLe: other };
});
};
/** The first type from PRUEFUNG_TYPEN_OHNE_ANTRITT that tbl_pruefungstyp contains. */
const findTypeWithoutAntritt = (types, i = 0) => {
if (i >= types.length) return cy.wrap(null, { log: false });
return cy
.task("noten:db:hasPruefungstyp", { type: types[i] })
.then((found) => (found ? types[i] : findTypeWithoutAntritt(types, i + 1)));
};
before(() => {
requireDbReset();
loadNotenContext().then((loaded) => {
ctx = loaded;
[a, b] = ctx.students;
cy.task("noten:db:readNonParticipant", lvScope()).then((uid) => {
outsider = uid;
});
cy.task("noten:db:readForeignLehreinheit", lvScope()).then((row) => {
foreign = row;
});
findTypeWithoutAntritt(ctx.cisConfig.PRUEFUNG_TYPEN_OHNE_ANTRITT || []).then((type) => {
typeWithoutAntritt = type;
});
cy.task("noten:db:readLehreinheitenOfLv", lvScope())
.then((lehreinheiten) => findOtherLe(lehreinheiten))
.then((found) => {
otherLe = found;
});
});
});
beforeEach(() => loginAsLektor());
describe("ein Edit trifft nur eine eigene Prüfung", () => {
/** The Pruefung of B that the request for A tries to edit. */
const seedForeignRow = () =>
seedPruefung(ctx, b, { note: ctx.noten.negativ, datum: baselineDate(ctx), type: "Termin1" }).then(
(seeded) => seeded.pruefungId,
);
const expectForeignRowUnchanged = (foreignId) =>
readStateViaApi(ctx).then((data) => {
const storedPruefung = pruefungenOf(data, b.uid).find(
(p) => String(p.pruefung_id) === String(foreignId),
);
expect(storedPruefung, "die Prüfung von B existiert weiter").to.exist;
expect(String(storedPruefung.note), "die Prüfung von B behält ihre Note").to.eq(
String(ctx.noten.negativ),
);
expect(dayOf(storedPruefung.datum), "die Prüfung von B behält ihr Datum").to.eq(baselineDate(ctx));
});
it("lehnt die Prüfung eines anderen Studierenden ab", () => {
const otherNote = ctx.notenScale.find((n) => String(n) !== String(ctx.noten.negativ));
let foreignId;
let lvNoteBefore;
resetNotenState(ctx);
seedBaseline(ctx, a);
seedBaseline(ctx, b, { erstantritt: false });
seedForeignRow().then((id) => {
foreignId = id;
});
readLvGesamtnoteViaDb(ctx, a).then((row) => {
lvNoteBefore = row;
});
cy.then(() =>
editPruefung(ctx, a, {
pruefungId: foreignId,
note: otherNote,
datum: shiftDate(baselineDate(ctx), 3),
}),
).then((response) => expectNotenError(response, "pruefungNichtBearbeitbar"));
cy.then(() => expectForeignRowUnchanged(foreignId));
readLvGesamtnoteViaDb(ctx, a).then((row) => {
expect(String(row.note), "die LV-Note von A bleibt").to.eq(String(lvNoteBefore.note));
expect(String(row.benotungsdatum), "das Benotungsdatum von A bleibt").to.eq(
String(lvNoteBefore.benotungsdatum),
);
});
});
it("lehnt eine fremde Prüfung für einen Studierenden ohne Prüfung ab", () => {
let foreignId;
resetNotenState(ctx);
seedForeignRow().then((id) => {
foreignId = id;
});
cy.then(() =>
editPruefung(ctx, a, {
pruefungId: foreignId,
note: ctx.notenScale[0],
datum: shiftDate(baselineDate(ctx), 3),
}),
).then((response) => expectNotenError(response, "pruefungNichtBearbeitbar"));
readLvGesamtnoteViaDb(ctx, a).then((row) => {
expect(row, "A bekommt keine LV-Note").to.be.null;
});
cy.then(() => expectForeignRowUnchanged(foreignId));
});
// The server locks every Pruefungstyp from PRUEFUNG_TYPEN_OHNE_ANTRITT against edits.
// Today: zusKommPruef, which the StV enters.
it("lehnt eine Prüfung der Studierendenverwaltung ab", function () {
skipIf(
this,
!typeWithoutAntritt,
"Übersprungen: kein Typ aus PRUEFUNG_TYPEN_OHNE_ANTRITT in tbl_pruefungstyp.",
);
const datum = antrittDate(ctx, 1);
let pruefungId;
resetNotenState(ctx);
seedBaseline(ctx, a);
seedPruefung(ctx, a, { note: ctx.noten.negativ, datum, type: typeWithoutAntritt }).then((seeded) => {
pruefungId = seeded.pruefungId;
});
cy.then(() =>
editPruefung(ctx, a, { pruefungId, note: ctx.noten.negativ, datum: shiftDate(datum, 2) }),
).then((response) => expectNotenError(response, "pruefungNichtBearbeitbar"));
readStateViaApi(ctx).then((data) => {
const storedPruefung = pruefungenOf(data, a.uid).find(
(p) => String(p.pruefung_id) === String(pruefungId),
);
expect(storedPruefung, `die Prüfung vom Typ ${typeWithoutAntritt} existiert weiter`).to.exist;
expect(dayOf(storedPruefung.datum), "sie behält ihr Datum").to.eq(datum);
});
});
});
describe("die Lehreinheit einer Prüfung", () => {
const savePruefungFor = (uid, lehreinheitId) =>
notenApi.savePruefung(ctx.lvId, ctx.semKurzbz, uid, {
pruefung_id: null,
lehreinheit_id: lehreinheitId,
datum: antrittDate(ctx, 1),
note: ctx.noten.negativ,
punkte: null,
});
// the server replaces the Lehreinheit without an error message
it("ersetzt eine andere Lehreinheit derselben LV", function () {
skipIf(this, !otherLe, "Übersprungen: die LV hat keine zweite Lehreinheit.");
requireRepeat(this, ctx);
const { student, ownLe } = otherLe;
resetNotenState(ctx);
seedBaseline(ctx, student);
savePruefungFor(student.uid, otherLe.otherLe).then((response) => {
const { pruefung } = expectNotenSuccess(response, "Termin mit einer anderen Lehreinheit")[student.uid];
expect(String(pruefung.lehreinheit_id), "die Lehreinheit des Studierenden").to.eq(String(ownLe));
});
});
it("ersetzt die Lehreinheit einer fremden LV", function () {
skipIf(this, !foreign, "Übersprungen: das Semester hat keine Lehreinheit einer anderen LV.");
requireRepeat(this, ctx);
resetNotenState(ctx);
seedBaseline(ctx, a);
savePruefungFor(a.uid, foreign.lehreinheit_id)
.then((response) => {
// resetNotenState does not reach a row in the foreign LV
const saved = response.body?.data?.[a.uid]?.pruefung;
if (saved && String(saved.lehreinheit_id) === String(foreign.lehreinheit_id)) {
cy.task("noten:db:deletePruefung", { pruefungId: saved.pruefung_id, studentUids: [a.uid] });
}
return cy.wrap(response, { log: false });
})
.then((response) => {
const saved = expectNotenSuccess(response, "Termin mit einer fremden Lehreinheit")[a.uid].pruefung;
return cy
.task("noten:db:readLehreinheitenOfStudent", { ...lvScope(), studentUid: a.uid })
.then((own) => {
expect(own.map(String), "eine Lehreinheit des Studierenden").to.include(
String(saved.lehreinheit_id),
);
});
});
readStateViaApi(ctx).then((data) => {
expect(pruefungenOf(data, a.uid), "Antritt 1 und der neue Termin").to.have.length(2);
});
});
describe("Nicht-Teilnehmer", () => {
beforeEach(function () {
skipIf(this, !outsider, "Übersprungen: kein aktiver Studierender ausserhalb der LV.");
resetNotenState(ctx, [outsider]);
});
// the reset is the check: it deletes what the request wrote and counts it
const expectNothingWritten = () =>
resetNotenState(ctx, [outsider]).then((deleted) => {
expect(deleted.deletedLvGesamtnoten, `LV-Noten von ${outsider}`).to.eq(0);
expect(deleted.deletedPruefungen, `Prüfungen von ${outsider}`).to.eq(0);
});
it("savePruefung legt nichts an", () => {
savePruefungFor(outsider, a.lehreinheit_id).then((response) =>
expectNotenError(response, "studentNichtInLv"),
);
expectNothingWritten();
});
it("createPruefungen lehnt nur die Zeile des Nicht-Teilnehmers ab", () => {
resetNotenState(ctx);
notenApi
.createPruefungen(
ctx.lvId,
ctx.semKurzbz,
[
{ uid: a.uid, lehreinheit_id: a.lehreinheit_id },
{ uid: outsider, lehreinheit_id: a.lehreinheit_id },
],
{ datum: antrittDate(ctx, 1) },
)
.then((response) => {
const data = expectNotenSuccess(response, "createPruefungen");
expectBulkRowError(data, outsider, "studentNichtInLv");
expect(data[a.uid].pruefung, `Prüfung für ${a.uid}`).to.exist;
});
expectNothingWritten();
});
it("importPruefungen lehnt nur die Zeile des Nicht-Teilnehmers ab", function () {
requireNotenMode(this, ctx);
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNGSIMPORT", true);
const bulkRow = (uid) => ({
uid,
note: ctx.noten.negativ,
punkte: null,
datum: antrittDate(ctx, 1),
lehreinheit_id: a.lehreinheit_id,
});
resetNotenState(ctx);
notenApi
.importPruefungen(ctx.lvId, ctx.semKurzbz, [bulkRow(a.uid), bulkRow(outsider)])
.then((response) => {
const data = expectNotenSuccess(response, "importPruefungen");
expectBulkRowError(data, outsider, "studentNichtInLv");
expectBulkRowAccepted(data, a.uid);
});
expectNothingWritten();
});
it("saveLvNote schreibt keine LV-Note", () => {
notenApi
.saveLvNote(ctx.lvId, ctx.semKurzbz, outsider, ctx.noten.negativ)
.then((response) => expectNotenError(response, "studentNichtInLv"));
expectNothingWritten();
});
it("importLvNoten lehnt nur die Zeile des Nicht-Teilnehmers ab", function () {
requireNotenMode(this, ctx);
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENIMPORT", true);
resetNotenState(ctx);
notenApi
.importLvNoten(ctx.lvId, ctx.semKurzbz, [
{ uid: a.uid, note: ctx.notenScale[0], punkte: null },
{ uid: outsider, note: ctx.notenScale[0], punkte: null },
])
.then((response) => {
const data = expectNotenSuccess(response, "importLvNoten");
expectBulkRowError(data, outsider, "studentNichtInLv");
expectBulkRowAccepted(data, a.uid);
});
expectNothingWritten();
});
});
describe("getLektorenForLehreinheit", () => {
it("nennt die Lehrenden einer Lehreinheit der LV", () => {
notenApi.getLektorenForLehreinheit(ctx.lvId, ctx.semKurzbz, a.lehreinheit_id).then((response) => {
expect(expectNotenSuccess(response, "eigene Lehreinheit"), "Lehrende").to.be.an("array");
});
});
it("lehnt die Lehreinheit einer fremden LV ab", function () {
skipIf(this, !foreign, "Übersprungen: das Semester hat keine Lehreinheit einer anderen LV.");
notenApi
.getLektorenForLehreinheit(ctx.lvId, ctx.semKurzbz, foreign.lehreinheit_id)
.then((response) => expectNotenError(response, "wrongParameters"));
});
it("lehnt eine nicht numerische Lehreinheit ab", () => {
notenApi
.getLektorenForLehreinheit(ctx.lvId, ctx.semKurzbz, "abc")
.then((response) => expectNotenError(response, "missingParameters"));
});
});
});
// The Lektor in mitarbeiter_uid of the Pruefung and the LV-Note: a valid selection; else the caller,
// if the caller teaches the Lehreinheit; else the first Lektor of the Lehreinheit by uid.
describe("die benotende Person", () => {
// a student whose Lehreinheit has several Lektoren; NOTEN_LEKTOR_USER is not the first of them
let target = null;
let lektorUid = null;
before(() => {
const search = (i) =>
i >= ctx.students.length
? cy.wrap(null, { log: false })
: cy
.task("noten:db:readLektorenOfLehreinheit", {
lehreinheitId: ctx.students[i].lehreinheit_id,
})
.then((lehrende) =>
lehrende.length > 1 && lehrende.includes(lektorUid) && lehrende[0] !== lektorUid
? { student: ctx.students[i], lehrende }
: search(i + 1),
);
readAuthUid()
.then((uid) => {
lektorUid = uid;
return search(0);
})
.then((found) => {
target = found;
});
});
beforeEach(() => {
expect(
target,
"eine Lehreinheit mit mehreren Lehrenden, darunter der Suite-Benutzer (Seeder-Gruppe benotungstool_fixture_erweitert)",
).to.not.be.null;
requireDbReset();
resetNotenState(ctx);
});
const pruefung = (mitarbeiter_uid) => ({
pruefung_id: null,
lehreinheit_id: target.student.lehreinheit_id,
datum: antrittDate(ctx, 1),
note: ctx.noten.negativ,
punkte: null,
mitarbeiter_uid,
});
const savePruefungWithLektor = (mitarbeiter_uid) =>
notenApi.savePruefung(ctx.lvId, ctx.semKurzbz, target.student.uid, pruefung(mitarbeiter_uid));
const grader = (response) => {
const saved = expectNotenSuccess(response, "Termin")[target.student.uid].pruefung;
return cy.task("noten:db:readPruefungMitarbeiter", {
pruefungId: saved.pruefung_id,
studentUid: target.student.uid,
});
};
it("trägt den Aufrufer ein, wenn er die Lehreinheit unterrichtet", () => {
savePruefungWithLektor()
.then(grader)
.then((uid) => expect(uid, "benotende Person der Prüfung").to.eq(lektorUid));
readLvGesamtnoteViaDb(ctx, target.student).then((row) => {
expect(row.mitarbeiter_uid, "benotende Person der LV-Note").to.eq(lektorUid);
});
});
it("trägt die gewählte Lehrperson ein", () => {
const chosen = target.lehrende.find((uid) => uid !== lektorUid);
savePruefungWithLektor(chosen)
.then(grader)
.then((uid) => expect(uid, "benotende Person der Prüfung").to.eq(chosen));
});
it("übergeht eine Auswahl ausserhalb der Lehreinheit", () => {
savePruefungWithLektor("keine-lehrperson")
.then(grader)
.then((uid) => expect(uid, "benotende Person der Prüfung").to.eq(lektorUid));
});
it("trägt ohne eigene Lehre die erste Lehrperson der Lehreinheit ein", function () {
requireAssistenz(this);
// otherwise the request runs with the session cookie of loginAsLektor
cy.clearAllCookies();
const body = { lv_id: ctx.lvId, sem_kurzbz: ctx.semKurzbz, student_uid: target.student.uid, ...pruefung() };
apiPost("savePruefung", body, assistenzAuth())
.then(grader)
.then((uid) => expect(uid, "benotende Person der Prüfung").to.eq(target.lehrende[0]));
});
});
});
@@ -0,0 +1,121 @@
import { freigabePassword, loginAsLektor } from "../../../../support/api/notenApi";
import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
import { requireConfig } from "../../../../support/helpers/notenConfig";
import {
baselineDate,
loadNotenContext,
readLvGesamtnoteViaDb,
requireDbReset,
resetNotenState,
seedBaseline,
} from "../../../../support/helpers/notenTestData";
/**
* The Freigabe through the dialog, with the password.
*
* The dialog lists exactly the rows that it releases (changedLvNoten) and asks for the LDAP password.
* After that the state column must change from "changed" to "freigegeben" without a reload.
*
* A successful Freigabe sends the Freigabe mail. The dev instances send it to a debug mailbox.
*/
describe("Benotungstool UI - Notenfreigabe", () => {
let ctx;
let bezeichnung;
before(() => {
requireDbReset();
loadNotenContext().then((loaded) => {
ctx = loaded;
bezeichnung = (note) => page.bezeichnungOf(ctx, note);
});
});
beforeEach(() => loginAsLektor());
describe("Dialog", () => {
it("listet die noch nicht freigegebene Note mit ihrer Zielnote", () => {
const student = ctx.students[0];
resetNotenState(ctx);
seedBaseline(ctx, student, { note: ctx.notenScale[0], freigegeben: false, erstantritt: false });
page.visitAndWaitForTable(ctx);
page.expectFreigabeState(student.uid, "changed");
page.openFreigabeModal();
page.expectFreigabeSummaryRow(student.uid, bezeichnung(ctx.notenScale[0]));
});
it("sperrt die Freigabe, wenn keine LV-Note geändert ist", () => {
resetNotenState(ctx);
page.visitAndWaitForTable(ctx);
page.openFreigabeModal();
page.expectFreigabeEmpty();
});
it("lehnt ein falsches Passwort ab und lässt den Status unverändert", function () {
// without CIS_GESAMTNOTE_FREIGABE_PASSWORT every password passes
requireConfig(this, ctx, "CIS_GESAMTNOTE_FREIGABE_PASSWORT", true);
const student = ctx.students[0];
resetNotenState(ctx);
seedBaseline(ctx, student, { note: ctx.notenScale[0], freigegeben: false, erstantritt: false });
page.visitAndWaitForTable(ctx);
page.openFreigabeModal();
page.typeFreigabePassword("definitely-not-the-password");
page.submitFreigabe();
// the page shows the rejection, and the state stays the same
page.expectRejected("@saveFreigabe");
page.expectFreigabeState(student.uid, "changed");
readLvGesamtnoteViaDb(ctx, student).then((rowData) => {
expect(rowData.freigabedatum, "eine abgelehnte Freigabe stempelt nichts").to.be.null;
});
});
});
describe("Freigabe", () => {
it("gibt frei und schaltet die Statusspalte auf freigegeben", () => {
const student = ctx.students[0];
resetNotenState(ctx);
seedBaseline(ctx, student, { note: ctx.notenScale[0], freigegeben: false, erstantritt: false });
page.visitAndWaitForTable(ctx);
page.expectFreigabeState(student.uid, "changed");
page.openFreigabeModal();
page.saveFreigabe(freigabePassword());
page.expectFreigabeState(student.uid, "freigegeben");
page.expectLvNote(student.uid, bezeichnung(ctx.notenScale[0]));
});
it("legt mit der Freigabe den ersten Antritt an", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true);
const student = ctx.students[1];
resetNotenState(ctx);
seedBaseline(ctx, student, { note: ctx.notenScale[0], freigegeben: false, erstantritt: false });
page.visitAndWaitForTable(ctx);
page.openFreigabeModal();
page.saveFreigabe(freigabePassword());
// the Freigabe writes Antritt 1; a new load proves that the server stored it
page.visitAndWaitForTable(ctx);
page.expectPruefung(student.uid, "antritt_1", { note: ctx.notenScale[0], antritt: 1 });
page.expectAntrittCount(student.uid, 1);
// Antritt 1 gets the benotungsdatum of the LV-Note, not an implicit date
page.getCell(student.uid, "antritt_1").should("contain.text", page.toDDMMYYYY(baselineDate(ctx)));
});
});
});
@@ -0,0 +1,234 @@
import { loginAsLektor } from "../../../../support/api/notenApi";
import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
import { requireConfig, requireNotenMode, requireRepeat, skipIf } from "../../../../support/helpers/notenConfig";
import {
antrittDate,
loadNotenContext,
requireDbReset,
resetNotenState,
seedBaseline,
} from "../../../../support/helpers/notenTestData";
/**
* The two imports.
*
* Noten import: "uid<TAB>Note" per line; writes only the LV-Note.
* Pruefungen import: "uid<TAB>dd.MM.yyyy<TAB>Note" per line; also creates the Pruefung.
*
* CIS_GESAMTNOTE_NOTENIMPORT / CIS_GESAMTNOTE_PRUEFUNGSIMPORT turn them on. Without the flag the
* button is missing, and the block skips.
*/
describe("Benotungstool UI - Import", () => {
let ctx;
let bezeichnung;
before(() => {
requireDbReset();
loadNotenContext().then((loaded) => {
ctx = loaded;
bezeichnung = (note) => page.bezeichnungOf(ctx, note);
});
});
beforeEach(() => loginAsLektor());
beforeEach(function () {
// in the Punkte mode both imports expect Punkte instead of a Note
requireNotenMode(this, ctx);
});
/**
* A Note whose Kuerzel from tbl_note.anmerkung works in the import: unique, not empty, and not the
* Note itself. If the configuration does not fit, it returns null, and the test skips.
*/
const kuerzelNote = () => {
if (!ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_NOTENKUERZEL) return null;
const kuerzelOf = (n) =>
String(n.anmerkung ?? "")
.trim()
.toLowerCase();
return (ctx.notenOptions ?? []).find((n) => {
const k = kuerzelOf(n);
if (!n.lehre || k === "" || k === String(n.note).trim()) return false;
const duplicate = ctx.notenOptions.filter((o) => kuerzelOf(o) === k).length > 1;
const isNoteValue = ctx.notenOptions.some((o) => String(o.note).trim() === k);
return !duplicate && !isNoteValue;
});
};
describe("Notenimport", () => {
beforeEach(function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENIMPORT", true);
});
it("schreibt die LV-Note für jede Zeile", () => {
const [a, b] = ctx.students;
resetNotenState(ctx);
page.visitAndWaitForTable(ctx);
page.importLvNoten([
[a.uid, ctx.notenScale[0]],
[b.uid, ctx.notenScale[1]],
]);
page.expectLvNote(a.uid, bezeichnung(ctx.notenScale[0]));
page.expectLvNote(b.uid, bezeichnung(ctx.notenScale[1]));
// entered, but not freigegeben
page.expectFreigabeState(a.uid, "changed");
page.expectFreigabeState(b.uid, "changed");
});
it("legt dabei den ersten Antritt an", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true);
const student = ctx.students[0];
resetNotenState(ctx);
page.visitAndWaitForTable(ctx);
page.importLvNoten([[student.uid, ctx.notenScale[0]]]);
// the LV-Note IS Antritt 1; the import writes it as its own row
page.expectAntrittCount(student.uid, 1);
page.expectPruefung(student.uid, "antritt_1", { note: ctx.notenScale[0], antritt: 1 });
});
it("nimmt das Kürzel aus der Notenliste, sobald die Option das erlaubt", function () {
const kuerzel = kuerzelNote();
skipIf(this, !kuerzel, "Übersprungen: keine Note mit brauchbarem Kürzel in tbl_note.anmerkung.");
const student = ctx.students[1];
resetNotenState(ctx);
page.visitAndWaitForTable(ctx);
page.importLvNoten([[student.uid, kuerzel.anmerkung]]);
page.expectLvNote(student.uid, kuerzel.bezeichnung);
});
});
describe("Prüfungsimport", () => {
beforeEach(function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNGSIMPORT", true);
});
it("legt je Zeile einen datierten Antritt an", function () {
requireRepeat(this, ctx);
const [a, b] = ctx.students;
// the configuration sets the format, not the test
const datum = page.importDate(antrittDate(ctx, 1), ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT);
resetNotenState(ctx);
seedBaseline(ctx, a, { note: ctx.noten.negativ });
seedBaseline(ctx, b, { note: ctx.noten.negativ });
page.visitAndWaitForTable(ctx);
page.importPruefungen([
[a.uid, datum, ctx.notenScale[1]],
[b.uid, datum, ctx.notenScale[1]],
]);
[a, b].forEach((student) => {
page.expectPruefung(student.uid, "antritt_2", { note: ctx.notenScale[1], antritt: 2 });
page.expectAntrittCount(student.uid, 2);
page.expectLvNote(student.uid, bezeichnung(ctx.notenScale[1]));
});
});
it("legt für einen Studenten ohne LV-Note beides an", () => {
const student = ctx.students[2];
resetNotenState(ctx);
page.visitAndWaitForTable(ctx);
page.importPruefungen([
[
student.uid,
page.importDate(antrittDate(ctx, 1), ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT),
ctx.notenScale[0],
],
]);
page.expectPruefung(student.uid, "antritt_1", { note: ctx.notenScale[0], antritt: 1 });
page.expectLvNote(student.uid, bezeichnung(ctx.notenScale[0]));
});
});
// The client checks each line before it sends. It shows a warning for each invalid line and sends
// only the other lines. It does not look for duplicate lines, on purpose.
describe("fehlerhafte Zeilen", () => {
it("warnt im Prüfungsimport je fehlerhafter Zeile und schickt nur die gültigen", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNGSIMPORT", true);
requireRepeat(this, ctx);
const [perUid, perMatrikelnr, wrongDate, wrongNote, wrongColumns] = ctx.students;
const format = ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT;
const datum = page.importDate(antrittDate(ctx, 1), format);
const year = antrittDate(ctx, 1).slice(0, 4);
const impossible = format === "yyyy-MM-dd" ? `${year}-02-31` : `31.02.${year}`;
const note = ctx.noten.negativ;
expect(perMatrikelnr.matrikelnr, "Matrikelnummer des Studierenden").to.be.a("string").and.not.be.empty;
resetNotenState(ctx);
seedBaseline(ctx, perUid, { note: ctx.noten.negativ });
seedBaseline(ctx, perMatrikelnr, { note: ctx.noten.negativ });
page.visitAndWaitForTable(ctx);
page.importPruefungen([
[perUid.uid, datum, note],
[perMatrikelnr.matrikelnr, datum, note],
["zz_unbekannt", datum, note],
[wrongDate.uid, impossible, note],
[wrongNote.uid, datum, "keine-note"],
[wrongColumns.uid, datum],
]);
page.expectWarning("zz_unbekannt");
page.expectWarning(wrongDate.uid);
page.expectWarning(wrongNote.uid);
// a line with the wrong number of columns names only its line number
page.expectWarnings(4);
page.sentUids("@importPruefungen", "pruefungen").then((uids) => {
expect(uids, "nur die gültigen Zeilen, die Matrikelnummer aufgelöst").to.deep.eq([
perUid.uid,
perMatrikelnr.uid,
]);
});
});
it("warnt im Notenimport je fehlerhafter Zeile und schickt nur die gültigen", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENIMPORT", true);
const [valid, wrongNote, wrongColumns] = ctx.students;
resetNotenState(ctx);
page.visitAndWaitForTable(ctx);
page.importLvNoten([
[valid.uid, ctx.notenScale[0]],
["zz_unbekannt", ctx.notenScale[0]],
[wrongNote.uid, "keine-note"],
[wrongColumns.uid, ctx.notenScale[0], "zu viel"],
]);
page.expectWarning("zz_unbekannt");
page.expectWarning(wrongNote.uid);
page.expectWarnings(3);
page.sentUids("@importLvNoten", "lv_noten").then((uids) => {
expect(uids, "nur die gültige Zeile").to.deep.eq([valid.uid]);
});
});
});
});
@@ -0,0 +1,78 @@
import { loginAsLektor } from "../../../../support/api/notenApi";
import { waitForOk } from "../../../../support/helpers/network";
import {
assistenzAuth,
assistenzLvWithLehreinheiten,
requireAssistenz,
} from "../../../../support/helpers/notenAssistenz";
import { loadNotenContext, requireDbReset } from "../../../../support/helpers/notenTestData";
/**
* The route is /Cis/Benotungstool/:sem_kurzbz?/:lv_id?. The semester comes before the LV, so the
* router does not read a URL without an LV as lv_id.
*/
describe("Benotungstool UI - Navigation", () => {
const SEMESTER = "[data-cy='dropdown-semester']";
const TIMEOUT = 60_000;
/** The text that appears in the closed dropdown once the component has selected a semester. */
const selectedSemester = () =>
cy
.get(`${SEMESTER} .p-dropdown-label`, { timeout: TIMEOUT })
.should(($label) => expect($label.text().trim(), "vorausgewähltes Semester").to.not.be.empty)
.then(($label) => $label.text().trim());
beforeEach(() => loginAsLektor());
it("behält ein Semester ohne LV nach einem Reload", () => {
cy.intercept({ method: "GET", url: "**/api/frontend/v1/Noten/getBenotungstoolContext*" }).as("context");
cy.visit("/cis.php/Cis/Benotungstool");
selectedSemester()
.then((preselected) => {
cy.get(SEMESTER).click();
return cy
.get(".p-dropdown-panel .p-dropdown-item")
.filter((i, el) => el.innerText.trim() !== preselected)
.first();
})
.then(($item) => {
const semKurzbz = $item.text().trim();
cy.wrap($item).click();
waitForOk("@context");
cy.location("pathname").should("match", new RegExp(`/Benotungstool/${semKurzbz}$`));
cy.reload();
selectedSemester().should("eq", semKurzbz);
cy.get(".p-toast-message-error").should("not.exist");
});
});
// the list used to contain only the LVs that the caller teaches
it("zeigt einer Assistenz die Lehreinheiten der LV", function () {
requireAssistenz(this);
const { username, password } = assistenzAuth();
requireDbReset();
loadNotenContext().then((ctx) => {
cy.clearAllCookies();
assistenzLvWithLehreinheiten(ctx.semKurzbz).then((target) => {
expect(target, "eine LV der Assistenz mit Lehreinheiten").to.not.be.null;
cy.login(username, password);
cy.intercept({ method: "GET", url: "**/api/frontend/v1/Noten/getLehreinheitenForLv*" }).as(
"lehreinheiten",
);
cy.visit(`/cis.php/Cis/Benotungstool/${target.semKurzbz}/${target.lvId}`);
waitForOk("@lehreinheiten");
cy.get("[data-cy='dropdown-lehreinheit']", { timeout: TIMEOUT }).click();
cy.get(".p-dropdown-panel .p-dropdown-item").should("have.length", target.lehreinheiten.length);
});
});
});
});
@@ -0,0 +1,171 @@
import { loginAsLektor } from "../../../../support/api/notenApi";
import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
import { requireConfig, requireNotenMode } from "../../../../support/helpers/notenConfig";
import {
antrittDate,
baselineDate,
loadNotenContext,
readLvGesamtnoteViaDb,
requireDbReset,
resetNotenState,
seedBaseline,
seedPruefung,
} from "../../../../support/helpers/notenTestData";
/**
* Enter a proposal and apply it as the LV-Note.
*
* api/noten/lvnote.cy.js checks the rules. This spec checks that the table shows the server state
* without a reload: the LV-Note, the Freigabe state, and that the apply button goes away.
*/
describe("Benotungstool UI - Notenvorschlag", () => {
let ctx;
let bezeichnung;
before(() => {
requireDbReset();
loadNotenContext().then((loaded) => {
ctx = loaded;
bezeichnung = (note) => page.bezeichnungOf(ctx, note);
});
});
beforeEach(() => loginAsLektor());
beforeEach(function () {
// in the Punkte mode the proposal column is locked: the Note comes from the Punkte
requireNotenMode(this, ctx);
});
it("trägt einen Vorschlag über den Zelleneditor ein", () => {
const student = ctx.students[0];
resetNotenState(ctx);
page.visitAndWaitForTable(ctx);
// without a Note the row is empty: no benotungsdatum -> open
page.expectFreigabeState(student.uid, "open");
page.setProposal(student.uid, bezeichnung(ctx.notenScale[0]));
page.expectProposal(student.uid, bezeichnung(ctx.notenScale[0]));
// the proposal alone writes no LV-Note
page.expectFreigabeState(student.uid, "open");
});
it("übernimmt den Vorschlag als LV-Note und zeigt den Status changed", () => {
const student = ctx.students[0];
resetNotenState(ctx);
page.visitAndWaitForTable(ctx);
page.setProposal(student.uid, bezeichnung(ctx.notenScale[0]));
page.applyProposal(student.uid);
page.expectLvNote(student.uid, bezeichnung(ctx.notenScale[0]));
page.expectFreigabeState(student.uid, "changed");
});
// The selected date is the date of Antritt 1, not the benotungsdatum. The benotungsdatum stays the
// time of the entry, otherwise a changed Note would look freigegeben.
it("schreibt das im Dialog gewählte Datum in den ersten Antritt", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true);
const student = ctx.students[2];
const datum = baselineDate(ctx);
resetNotenState(ctx);
page.visitAndWaitForTable(ctx);
page.setProposal(student.uid, bezeichnung(ctx.notenScale[0]));
page.applyProposal(student.uid, { datum: page.toDDMMYYYY(datum) });
page.expectLvNote(student.uid, bezeichnung(ctx.notenScale[0]));
page.getCell(student.uid, "antritt_1").should("contain.text", page.toDDMMYYYY(datum));
readLvGesamtnoteViaDb(ctx, student).then((rowData) => {
expect(String(rowData.benotungsdatum).slice(0, 10), "das benotungsdatum bleibt heute").to.eq(
new Date().toISOString().slice(0, 10),
);
});
});
// A single repeat without Antritt 1, as the StV can leave it. After a repeat the Note belongs to the
// Pruefung: the proposal column is locked, so the row has neither an editor nor a button. The control
// row shows that the button appears without a repeat.
it("zeigt bei einer einzelnen Wiederholung keinen Übernehmen-Button", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_VORSCHLAG_NACH_WIEDERHOLUNG", false);
const wiederholung = ctx.students[3];
const control = ctx.students[4];
// "Sehr Gut" first: setProposal uses contains, and "Gut" would match "Sehr Gut"
const vorschlag = ctx.notenScale[0];
const pruefungsnote = ctx.notenScale.find(
(n) => ![vorschlag, ctx.noten.negativ].map(String).includes(String(n)),
);
resetNotenState(ctx);
seedBaseline(ctx, wiederholung, { erstantritt: false });
seedBaseline(ctx, control);
seedPruefung(ctx, wiederholung, { note: pruefungsnote, datum: antrittDate(ctx, 1), type: "Termin2" });
page.visitAndWaitForTable(ctx);
page.setProposal(control.uid, bezeichnung(vorschlag));
page.getApplyProposalButton(control.uid).should("exist");
page.expectProposalLocked(wiederholung.uid);
page.getApplyProposalButton(wiederholung.uid).should("not.exist");
});
// With the switch on the server leaves the LV-Note open after a repeat, and the page follows it.
it("bietet nach einer Wiederholung den Übernehmen-Button an, wenn der Schalter an ist", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_VORSCHLAG_NACH_WIEDERHOLUNG", true);
const student = ctx.students[3];
const vorschlag = ctx.notenScale[0];
const pruefungsnote = ctx.notenScale.find(
(n) => ![vorschlag, ctx.noten.negativ].map(String).includes(String(n)),
);
resetNotenState(ctx);
seedBaseline(ctx, student, { erstantritt: false });
seedPruefung(ctx, student, { note: pruefungsnote, datum: antrittDate(ctx, 1), type: "Termin2" });
page.visitAndWaitForTable(ctx);
page.setProposal(student.uid, bezeichnung(vorschlag));
page.getApplyProposalButton(student.uid).should("exist");
});
// The LV-Note is never 'entschuldigt', so the editor does not offer that Note.
it("bietet 'entschuldigt' nicht als Notenvorschlag an", () => {
const student = ctx.students[1];
resetNotenState(ctx);
page.visitAndWaitForTable(ctx);
page.getCell(student.uid, "proposed_note").click();
// exact match: "unentschuldigt" contains "entschuldigt"
cy.get(".tabulator-edit-list-item")
.should("have.length.greaterThan", 0)
.then((items) => {
const labels = [...items].map((item) => item.innerText.trim());
expect(labels, "Optionen des Editors").to.include(bezeichnung(ctx.notenScale[0]));
expect(labels, "Optionen des Editors").to.not.include(bezeichnung(ctx.noten.entschuldigt));
});
});
it("blendet den Übernehmen-Button aus, sobald Vorschlag und LV-Note übereinstimmen", () => {
const student = ctx.students[0];
resetNotenState(ctx);
page.visitAndWaitForTable(ctx);
page.setProposal(student.uid, bezeichnung(ctx.notenScale[0]));
page.applyProposal(student.uid);
page.getApplyProposalButton(student.uid).should("not.exist");
});
});
@@ -0,0 +1,302 @@
import { loginAsLektor } from "../../../../support/api/notenApi";
import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
import { waitForOk } from "../../../../support/helpers/network";
import {
requireKommissionellerAntritt,
requireConfig,
requireNotenMode,
requireRepeat,
skipIf,
} from "../../../../support/helpers/notenConfig";
import { expectNotenSuccess } from "../../../../support/helpers/notenErrors";
import { addPruefung, givenBaseline } from "../../../../support/helpers/notenScenario";
import {
antrittDate,
loadNotenContext,
readLvGesamtnoteViaDb,
requireDbReset,
resetNotenState,
seedBaseline,
} from "../../../../support/helpers/notenTestData";
/**
* Add and edit Pruefungen in both ways: the dialog of a table cell and the "Neue Prüfung" dialog.
* Both use the same core on the server (see api/noten/pruefung.cy.js). The spec checks that the cell
* is correct without a reload.
*
* The specs use the column layout "antritt" (the "Termine" button), because its column names are fixed:
* antritt_1, antritt_2, ... In the date mode a column is named after the date of the Pruefung.
*/
describe("Benotungstool UI - Prüfungen", () => {
let ctx;
let bezeichnung;
before(() => {
requireDbReset();
loadNotenContext().then((loaded) => {
ctx = loaded;
bezeichnung = (note) => page.bezeichnungOf(ctx, note);
expect(ctx.maxAntritte, "braucht Platz für mindestens eine Wiederholung").to.be.greaterThan(1);
});
});
beforeEach(() => loginAsLektor());
beforeEach(function () {
// in the Punkte mode the Note field is locked: the Note comes from the Notenschluessel
requireNotenMode(this, ctx);
});
it("legt aus der Zelle eine Wiederholung an und zählt sie als Antritt 2", function () {
requireRepeat(this, ctx);
const student = ctx.students[0];
resetNotenState(ctx);
seedBaseline(ctx, student, { note: ctx.noten.negativ });
page.visitAndWaitForTable(ctx);
page.addPruefungInCell(student.uid, "antritt_2", {
note: bezeichnung(ctx.notenScale[1]),
datum: page.toDDMMYYYY(antrittDate(ctx, 1)),
});
page.expectPruefung(student.uid, "antritt_2", { note: ctx.notenScale[1], antritt: 2 });
page.expectAntrittCount(student.uid, 2);
// the LV-Note follows the newest Antritt
page.expectLvNote(student.uid, bezeichnung(ctx.notenScale[1]));
// the cell shows the Freigabe state that the server wrote; api/noten/pruefung.cy.js checks the rule
readLvGesamtnoteViaDb(ctx, student).then((row) => {
const changed = new Date(row.benotungsdatum) > new Date(row.freigabedatum);
page.expectFreigabeState(student.uid, changed ? "changed" : "freigegeben");
});
});
it("nennt nach einer positiven Note den Grund statt der Schaltfläche", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENVERBESSERUNG", false);
skipIf(
this,
!ctx.noten.positiv,
"Übersprungen: keine positive Note, die die Kette nur ohne Notenverbesserung schliesst.",
);
const student = ctx.students[0];
givenBaseline(ctx, student, { note: ctx.noten.positiv });
page.visitAndWaitForTable(ctx);
page.getBestandenHint(student.uid, "antritt_2").should("exist").and("have.attr", "title").and("not.be.empty");
page.getPruefungAddButton(student.uid, "antritt_2").should("not.exist");
});
it("legt einen Antritt für einen Studenten ohne LV-Note an und weist darauf hin", () => {
const student = ctx.students[2];
resetNotenState(ctx);
page.visitAndWaitForTable(ctx);
page.getPruefungAddButton(student.uid, "antritt_1").click();
page.getPruefungModal().should("be.visible");
cy.get("[data-cy='pruefung-without-lvnote']").should("be.visible");
cy.get("[data-cy='pruefung-submit']").click();
page.getPruefungModal().should("not.be.visible");
// without a selected Note the server writes "Noch nicht eingetragen", which uses no Antritt
page.expectPruefung(student.uid, "antritt_1", { note: ctx.noten.nochNichtEingetragen });
page.expectLvNote(student.uid, bezeichnung(ctx.noten.nochNichtEingetragen));
page.expectAntrittCount(student.uid, 0);
});
it("korrigiert nur das Datum eines bestehenden Antritts", function () {
requireRepeat(this, ctx);
const student = ctx.students[0];
const newDate = antrittDate(ctx, 2);
resetNotenState(ctx);
seedBaseline(ctx, student, { note: ctx.noten.negativ });
page.visitAndWaitForTable(ctx);
page.addPruefungInCell(student.uid, "antritt_2", {
note: bezeichnung(ctx.notenScale[1]),
datum: page.toDDMMYYYY(antrittDate(ctx, 1)),
});
page.editPruefungInCell(student.uid, "antritt_2", { datum: page.toDDMMYYYY(newDate) });
page.expectPruefung(student.uid, "antritt_2", { note: ctx.notenScale[1], antritt: 2 });
page.getCell(student.uid, "antritt_2").should("contain.text", page.toDDMMYYYY(newDate));
});
it("sperrt die Note, sobald ein späterer Antritt existiert", function () {
requireRepeat(this, ctx);
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETERER_PRUEFUNG", true);
const student = ctx.students[0];
resetNotenState(ctx);
seedBaseline(ctx, student, { note: ctx.noten.negativ });
page.visitAndWaitForTable(ctx);
// entschuldigt first: it uses no Antritt, so the third column stays reachable also with
// maxAntritte = 2
page.addPruefungInCell(student.uid, "antritt_2", {
note: bezeichnung(ctx.noten.entschuldigt),
datum: page.toDDMMYYYY(antrittDate(ctx, 1)),
});
page.addPruefungInCell(student.uid, "antritt_3", {
note: bezeichnung(ctx.notenScale[1]),
datum: page.toDDMMYYYY(antrittDate(ctx, 2)),
});
page.openPruefungModalForEdit(student.uid, "antritt_2");
cy.get("[data-cy='pruefung-note-locked']").should("be.visible");
cy.get("[data-cy='pruefung-note']").should("have.class", "p-disabled");
});
it("legt über die Sammelanlage für mehrere Studierende denselben Termin an", function () {
requireRepeat(this, ctx);
const [a, b] = ctx.students;
resetNotenState(ctx);
seedBaseline(ctx, a, { note: ctx.noten.negativ });
seedBaseline(ctx, b, { note: ctx.noten.negativ });
page.visitAndWaitForTable(ctx);
page.createPruefungen({
uids: [a.uid, b.uid],
note: bezeichnung(ctx.notenScale[1]),
datum: page.toDDMMYYYY(antrittDate(ctx, 1)),
});
[a, b].forEach((student) => {
page.expectPruefung(student.uid, "antritt_2", { note: ctx.notenScale[1], antritt: 2 });
page.expectAntrittCount(student.uid, 2);
page.expectLvNote(student.uid, bezeichnung(ctx.notenScale[1]));
});
});
// The table holds the selection; the multiselect of the dialog changes it through the table.
it("gleicht die Auswahl zwischen Tabelle und Sammeldialog ab", function () {
requireRepeat(this, ctx);
const [a, b] = ctx.students;
resetNotenState(ctx);
seedBaseline(ctx, a, { note: ctx.noten.negativ });
seedBaseline(ctx, b, { note: ctx.noten.negativ });
page.visitAndWaitForTable(ctx);
page.getRowCheckbox(a.uid).click();
page.openCreatePruefungen();
page.expectStudentsInDialog([a.uid]);
// b in, a out
page.toggleStudentsInDialog([b.uid, a.uid]);
page.expectStudentsInDialog([b.uid]);
page.expectRowSelected(b.uid, true);
page.expectRowSelected(a.uid, false);
});
// The "Neue Prüfung" dialog checks a Pruefung on the same day like the server does,
// by CIS_GESAMTNOTE_PRUEFUNG_GLEICHER_TAG.
describe("Termin am selben Tag im Sammeldialog", () => {
beforeEach(function () {
requireRepeat(this, ctx);
});
// 'entschuldigt' uses no Antritt, so one more Pruefung stays possible
const withPruefungOn = (student, datum) => {
resetNotenState(ctx);
seedBaseline(ctx, student, { note: ctx.noten.negativ });
addPruefung(ctx, student, { note: ctx.noten.entschuldigt, datum }).then((response) =>
expectNotenSuccess(response, "bestehender Termin"),
);
page.visitAndWaitForTable(ctx);
};
const submitNewPruefung = (student, datum) =>
page.submitCreatePruefungen({ uids: [student.uid], datum: page.toDDMMYYYY(datum) });
it("schickt einen Termin am selben Tag ab, wenn der Schalter an ist", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNG_GLEICHER_TAG", true);
const student = ctx.students[0];
const datum = antrittDate(ctx, 1);
withPruefungOn(student, datum);
submitNewPruefung(student, datum);
waitForOk("@createPruefungen");
cy.get(".p-toast-message-warn").should("not.exist");
});
it("warnt bei einem Termin am selben Tag und schickt nichts ab", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNG_GLEICHER_TAG", false);
const student = ctx.students[0];
const datum = antrittDate(ctx, 1);
withPruefungOn(student, datum);
submitNewPruefung(student, datum);
cy.get(".p-toast-message-warn").should("contain.text", student.uid);
page.getPruefungModal().should("not.be.visible");
cy.get("@createPruefungen.all").should("have.length", 0);
});
});
it("legt den letzten Antritt als kommissionelle Prüfung an", function () {
requireKommissionellerAntritt(this, ctx);
skipIf(
this,
ctx.cisConfig.CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT !== ctx.maxAntritte,
"Übersprungen: der letzte Antritt ist nicht kommissionell.",
);
const student = ctx.students[1];
resetNotenState(ctx);
seedBaseline(ctx, student, { note: ctx.noten.negativ });
page.visitAndWaitForTable(ctx);
// the baseline gives Antritt 1; the normal columns go up to the second-to-last Antritt
for (let i = 1; i < ctx.maxAntritte - 1; i += 1) {
page.addPruefungInCell(student.uid, `antritt_${i + 1}`, {
note: bezeichnung(ctx.noten.negativ),
datum: page.toDDMMYYYY(antrittDate(ctx, i)),
});
}
// By the Pruefungsordnung the last Antritt is kommissionell. It gets no column of its own,
// but the next Pruefung column. The K in the cell marks it.
const lastAntrittColumn = `antritt_${ctx.maxAntritte}`;
page.getPruefungAddButton(student.uid, lastAntrittColumn).should("exist");
page.addPruefungInCell(student.uid, lastAntrittColumn, {
note: bezeichnung(ctx.noten.negativ),
datum: page.toDDMMYYYY(antrittDate(ctx, ctx.maxAntritte)),
});
page.expectAntrittCount(student.uid, ctx.maxAntritte);
// the badge shows the Antritt number and the "K" of the kommissionelle Pruefung
page.expectPruefung(student.uid, lastAntrittColumn, {
note: ctx.noten.negativ,
antritt: `${ctx.maxAntritte}-K`,
});
page.expectNoAntrittColumn(ctx.maxAntritte + 1);
});
});
@@ -0,0 +1,220 @@
import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
import { requireConfig, requirePunkteMode, requireRepeat } from "../../../../support/helpers/notenConfig";
import { pruefungenOf, readStateViaApi } from "../../../../support/helpers/notenScenario";
import {
antrittDate,
loadNotenContext,
readLvGesamtnoteViaDb,
requireDbReset,
resetNotenState,
seedBaseline,
seedPruefung,
} from "../../../../support/helpers/notenTestData";
import { notenApi, loginAsLektor } from "../../../../support/api/notenApi";
/**
* The Punkte mode (CIS_GESAMTNOTE_PUNKTE) on the page.
*
* The Punkte column and the Punkte fields of both dialogs exist only with this flag. The Note then
* comes from the Notenschluessel; nobody selects it. No other spec covers these fields, so the whole
* mode has its own file.
*
* The spec never hardcodes the Notenschluessel: it gets the expected Note from getNoteByPunkte at
* runtime, so it works with the Notenschluessel of each instance.
*/
describe("Benotungstool UI - Punktemodus", () => {
let ctx;
// Punkte values; the test gets their Noten at runtime
const TOP = 100;
const MIDDLE = 70;
let noteTop;
let noteMiddle;
before(() => {
requireDbReset();
loadNotenContext().then((loaded) => {
ctx = loaded;
if (!ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) return;
notenApi.getNoteByPunkte(ctx.lvId, ctx.semKurzbz, TOP).then((r) => {
noteTop = r.body.data;
expect(noteTop, `${TOP} Punkte müssen eine Note ergeben`).to.not.be.null;
});
notenApi.getNoteByPunkte(ctx.lvId, ctx.semKurzbz, MIDDLE).then((r) => {
noteMiddle = r.body.data;
expect(noteMiddle, `${MIDDLE} Punkte müssen eine Note ergeben`).to.not.be.null;
expect(String(noteMiddle), "die beiden Punktewerte müssen verschiedene Noten liefern").to.not.eq(
String(noteTop),
);
});
});
});
beforeEach(() => loginAsLektor());
beforeEach(function () {
requirePunkteMode(this, ctx);
});
describe("Punktespalte in der Tabelle", () => {
it("zeigt die Punktespalte und sperrt dafür die Notenauswahl", () => {
const student = ctx.students[0];
resetNotenState(ctx);
page.visitAndWaitForTable(ctx);
page.getPunkteCell(student.uid).should("exist");
// the Note comes from the Punkte; nobody may select it directly
page.expectProposalLocked(student.uid);
});
it("schreibt Note und Punkte, wenn der Vorschlag übernommen wird", () => {
const student = ctx.students[0];
resetNotenState(ctx);
page.visitAndWaitForTable(ctx);
page.setPunkteInCell(student.uid, MIDDLE);
page.applyProposal(student.uid);
page.expectLvNote(student.uid, page.bezeichnungOf(ctx, noteMiddle));
page.expectFreigabeState(student.uid, "changed");
readLvGesamtnoteViaDb(ctx, student).then((row) => {
expect(String(row.note), "abgeleitete Note").to.eq(String(noteMiddle));
expect(Number(row.punkte), "die Punkte werden mitgeschrieben").to.eq(MIDDLE);
});
});
// Antritt 1 and the LV-Note are the same result, so the column stays open for it.
// Only a repeat takes Note and Punkte away from the LV-Note.
it("sperrt die Punktespalte ab der ersten Wiederholung", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_VORSCHLAG_NACH_WIEDERHOLUNG", false);
const student = ctx.students[1];
resetNotenState(ctx);
seedBaseline(ctx, student, { note: ctx.notenScale[0], freigegeben: true });
seedPruefung(ctx, student, {
note: ctx.notenScale[0],
datum: antrittDate(ctx, 1),
type: "Termin2",
});
page.visitAndWaitForTable(ctx);
page.expectPunkteCellLocked(student.uid);
});
});
describe("Prüfungsdialog", () => {
it("bietet ein Punktefeld statt der Notenauswahl", () => {
const student = ctx.students[1];
resetNotenState(ctx);
seedBaseline(ctx, student, { note: ctx.noten.negativ, freigegeben: true });
page.visitAndWaitForTable(ctx);
page.getPruefungAddButton(student.uid, "antritt_2").click();
page.getPruefungModal().should("be.visible");
cy.get("[data-cy='pruefung-punkte']").should("be.visible");
page.expectNoteFieldLocked("pruefung-note");
});
it("legt den Termin mit der aus den Punkten abgeleiteten Note an", () => {
const student = ctx.students[1];
resetNotenState(ctx);
seedBaseline(ctx, student, { note: ctx.noten.negativ, freigegeben: true });
page.visitAndWaitForTable(ctx);
page.getPruefungAddButton(student.uid, "antritt_2").click();
page.getPruefungModal().should("be.visible");
page.setDate("pruefung-datum", page.toDDMMYYYY(antrittDate(ctx, 1)));
page.setPruefungPunkte(TOP);
cy.get("[data-cy='pruefung-submit']").click();
cy.wait("@savePruefung").its("response.statusCode").should("eq", 200);
page.getPruefungModal().should("not.be.visible");
page.expectPruefung(student.uid, "antritt_2", { note: noteTop, antritt: 2 });
page.expectLvNote(student.uid, page.bezeichnungOf(ctx, noteTop));
});
});
describe("Sammelanlage", () => {
// the "Neue Prüfung" dialog sends the Punkte; the server derives the Note from them
it("legt den Termin mit der aus den Punkten abgeleiteten Note an", function () {
requireRepeat(this, ctx);
const [a, b] = [ctx.students[2], ctx.students[3]];
resetNotenState(ctx);
seedBaseline(ctx, a, { note: ctx.noten.negativ, freigegeben: true });
seedBaseline(ctx, b, { note: ctx.noten.negativ, freigegeben: true });
page.visitAndWaitForTable(ctx);
page.createPruefungen({
uids: [a.uid, b.uid],
punkte: MIDDLE,
datum: page.toDDMMYYYY(antrittDate(ctx, 1)),
});
[a, b].forEach((student) =>
page.expectPruefung(student.uid, "antritt_2", { note: noteMiddle, antritt: 2 }),
);
});
});
// The Excel list writes decimals with a comma or with a dot. Both give the same number.
describe("Import mit Punkten", () => {
const PUNKTE = 89.5;
[
["Dezimalkomma", "89,5"],
["Dezimalpunkt", "89.5"],
].forEach(([notation, input]) => {
it(`liest Punkte mit ${notation} im Prüfungsimport`, function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNGSIMPORT", true);
requireRepeat(this, ctx);
const student = ctx.students[4];
const datum = page.importDate(antrittDate(ctx, 1), ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT);
resetNotenState(ctx);
seedBaseline(ctx, student, { note: ctx.noten.negativ, freigegeben: true });
page.visitAndWaitForTable(ctx);
page.importPruefungen([[student.uid, datum, input]]);
cy.get("@importPruefungen").its("request.body.pruefungen.0.punkte").should("eq", PUNKTE);
readStateViaApi(ctx).then((data) => {
const newPruefung = pruefungenOf(data, student.uid)[1];
expect(newPruefung, "der importierte Termin").to.exist;
expect(Number(newPruefung.punkte), "die Punkte des Termins").to.eq(PUNKTE);
});
});
it(`liest Punkte mit ${notation} im Notenimport`, function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENIMPORT", true);
const student = ctx.students[5];
resetNotenState(ctx);
page.visitAndWaitForTable(ctx);
page.importLvNoten([[student.uid, input]]);
cy.get("@importLvNoten").its("request.body.lv_noten.0.punkte").should("eq", PUNKTE);
readLvGesamtnoteViaDb(ctx, student).then((row) => {
expect(row, "die importierte LV-Note").to.not.be.null;
expect(Number(row.punkte), "die Punkte der LV-Note").to.eq(PUNKTE);
});
});
});
});
});
@@ -0,0 +1,145 @@
import { loginAsLektor } from "../../../../support/api/notenApi";
import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
import { requireConfig, requireNotenMode, requireRepeat } from "../../../../support/helpers/notenConfig";
import {
antrittDate,
loadNotenContext,
requireDbReset,
resetNotenState,
seedBaseline,
seedPruefung,
} from "../../../../support/helpers/notenTestData";
/**
* The server rejects a save, and the page shows it.
*
* Each test loads the table and then changes the database. So the table is out of date, as if
* someone entered data in a second window. The client checks the old state and sends the request;
* only the server rejects it. The test reads the message from the response and expects exactly this
* message in a toast. So it needs no phrase and runs in every profile.
*/
describe("Benotungstool UI - Ablehnung durch den Server", () => {
let ctx;
let bezeichnung;
before(() => {
requireDbReset();
loadNotenContext().then((loaded) => {
ctx = loaded;
bezeichnung = (note) => page.bezeichnungOf(ctx, note);
});
});
beforeEach(() => loginAsLektor());
beforeEach(function () {
// in the Punkte mode the Note fields are locked: the Note comes from the Punkte
requireNotenMode(this, ctx);
});
/** An entschuldigt Pruefung after the date that the test enters. It uses no Antritt. */
const laterPruefungInBackground = (student) =>
seedPruefung(ctx, student, { note: ctx.noten.entschuldigt, datum: antrittDate(ctx, 3), type: "Termin2" });
it("zeigt die Ablehnung eines Termins aus dem Zelldialog", function () {
requireRepeat(this, ctx);
const student = ctx.students[0];
resetNotenState(ctx);
seedBaseline(ctx, student, { note: ctx.noten.negativ, freigegeben: true });
page.visitAndWaitForTable(ctx);
laterPruefungInBackground(student);
page.submitPruefungInCell(student.uid, "antritt_2", {
note: bezeichnung(ctx.noten.negativ),
datum: page.toDDMMYYYY(antrittDate(ctx, 1)),
});
page.expectRejected("@savePruefung");
page.expectNoPruefung(student.uid, "antritt_2");
});
it("zeigt die Ablehnung einer Übernahme und behält die LV-Note", function () {
// the repeat in the background locks the LV-Note only while this switch is off
requireConfig(this, ctx, "CIS_GESAMTNOTE_VORSCHLAG_NACH_WIEDERHOLUNG", false);
const student = ctx.students[1];
resetNotenState(ctx);
seedBaseline(ctx, student, { note: ctx.noten.negativ, freigegeben: false, erstantritt: false });
page.visitAndWaitForTable(ctx);
// a Pruefung written in the background blocks the change; the table does not know it
seedPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, 1), type: "Termin2" });
// "Sehr Gut" first: setProposal uses contains, and "Gut" would match "Sehr Gut"
page.setProposal(student.uid, bezeichnung(ctx.notenScale[0]));
page.submitApplyProposal(student.uid);
page.expectRejected("@saveLvNote");
page.expectLvNote(student.uid, bezeichnung(ctx.noten.negativ));
});
it("zeigt die abgelehnte Zeile der Sammelanlage und meldet keinen Erfolg", function () {
requireRepeat(this, ctx);
const student = ctx.students[2];
resetNotenState(ctx);
seedBaseline(ctx, student, { note: ctx.noten.negativ, freigegeben: true });
page.visitAndWaitForTable(ctx);
laterPruefungInBackground(student);
page.submitCreatePruefungen({
uids: [student.uid],
note: bezeichnung(ctx.noten.negativ),
datum: page.toDDMMYYYY(antrittDate(ctx, 1)),
});
page.expectRowRejected("@createPruefungen", student.uid);
page.expectNoSuccess();
page.expectNoPruefung(student.uid, "antritt_2");
});
it("zeigt die abgelehnte Zeile des Prüfungsimports und meldet keinen Erfolg", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNGSIMPORT", true);
requireRepeat(this, ctx);
const student = ctx.students[3];
resetNotenState(ctx);
seedBaseline(ctx, student, { note: ctx.noten.negativ, freigegeben: true });
page.visitAndWaitForTable(ctx);
laterPruefungInBackground(student);
page.submitPruefungImport([
[
student.uid,
page.importDate(antrittDate(ctx, 1), ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT),
ctx.noten.negativ,
],
]);
page.expectRowRejected("@importPruefungen", student.uid);
page.expectNoSuccess();
page.expectNoPruefung(student.uid, "antritt_2");
});
it("zeigt die abgelehnte Zeile des Notenimports und meldet keinen Erfolg", function () {
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENIMPORT", true);
const student = ctx.students[4];
resetNotenState(ctx);
seedBaseline(ctx, student, { note: ctx.noten.negativ, freigegeben: false, erstantritt: false });
page.visitAndWaitForTable(ctx);
seedPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, 1), type: "Termin2" });
page.submitLvNotenImport([[student.uid, ctx.notenScale[0]]]);
page.expectRowRejected("@importLvNoten", student.uid);
page.expectNoSuccess();
page.expectLvNote(student.uid, bezeichnung(ctx.noten.negativ));
});
});
+25
View File
@@ -0,0 +1,25 @@
# Local workstation: SSH tunnel to the database and configuration profiles via SFTP.
# Copy to tests/cypress/local/.env. The suite itself never reads this file.
# ---- SSH tunnel (withTunnel.js) --------------------- ---------------------------------------------
# pg_hba.conf only allows the application server. NOTEN_DB_HOST in suites/.env remains the ACTUAL database.
# - The SSH host is usually NOT the web hostname
# - A key outside the OpenSSH default names is never automatically tried; specify it here,
# or leave NOTEN_SSH_KEY empty for the SSH agent
NOTEN_SSH_HOST=
NOTEN_SSH_PORT=22
NOTEN_SSH_USER=
NOTEN_SSH_KEY=
NOTEN_SSH_PASSPHRASE=
# ---- Configuration profiles (notenProfiles.js) ------------------------------------------------ ---
# Absolute path of the instance on its host.
NOTEN_REMOTE_ROOT=
# Only if the instance is on a different host than the tunnel. With NOTEN_PROFILE_SSH_HOST, every
# SSH value in the profiles comes from NOTEN_PROFILE_SSH_*; an empty key uses the SSH agent.
NOTEN_PROFILE_SSH_HOST=
NOTEN_PROFILE_SSH_USER=
NOTEN_PROFILE_SSH_KEY=
# Only for an instance with a self-signed certificate: otherwise, the Node side will reject it.
# NODE_TLS_REJECT_UNAUTHORIZED=0
+80
View File
@@ -0,0 +1,80 @@
const js = require("@eslint/js");
const globals = require("globals");
const pluginCypress = require("eslint-plugin-cypress");
const prettier = require("eslint-config-prettier");
/**
* Lint for the Cypress suite only. The rest of the repo is older than this setup.
*
* The no-restricted-syntax rules check two conventions from tests/cypress/suites/readme_noten.txt.
* Paths start at the repo root: `npm run lint` in local/ changes to it first.
*/
module.exports = [
{ ignores: ["tests/cypress/e2e/fhcomplete/**", "tests/cypress/e2e/*.cy.js"] },
// --- Node side: tasks, tools, profiles, local tools, configuration (CommonJS) ---
{
files: ["tests/cypress/{tasks,tools,profiles,suites,local}/**/*.js", "cypress.config.js"],
languageOptions: {
ecmaVersion: 2023,
sourceType: "commonjs",
globals: { ...globals.node },
},
rules: {
...js.configs.recommended.rules,
"no-unused-vars": ["warn", { argsIgnorePattern: "^_", caughtErrors: "none" }],
},
},
// --- browser side: specs and support (ES modules in the Cypress runner) ---
{
files: ["tests/cypress/e2e/**/*.js", "tests/cypress/support/**/*.js"],
languageOptions: {
ecmaVersion: 2023,
sourceType: "module",
globals: { ...globals.browser, ...globals.mocha, cy: "readonly", Cypress: "readonly", expect: "readonly" },
},
plugins: { cypress: pluginCypress },
rules: {
...js.configs.recommended.rules,
...pluginCypress.configs.recommended.rules,
"no-unused-vars": ["warn", { argsIgnorePattern: "^_", caughtErrors: "none" }],
// warn, not error: four .clear().type() calls in the page object work, and three of them
// run only in the Punkte profile, so a change there cannot be tested here
"cypress/unsafe-to-chain-command": "warn",
},
},
// --- the conventions of the suite ---
{
files: ["tests/cypress/e2e/specs/**/*.cy.js"],
rules: {
"no-restricted-syntax": [
"error",
{
// skipIf and the require* helpers are the only way to skip
selector: "CallExpression[callee.property.name='skip'][callee.object.type='ThisExpression']",
message:
"Kein this.skip() im Spec. Nimm skipIf(this, condition, reason) oder einen require*-Helfer aus support/helpers/notenConfig.js.",
},
{
// a stub of the Noten API removes exactly the server rules that the suite checks
selector:
"CallExpression[callee.object.name='cy'][callee.property.name='intercept'] > ObjectExpression:has(Property[key.name='body'])",
message:
"Kein Stub der Noten-API. cy.intercept nur zum Aliasen und Warten, siehe tests/cypress/suites/readme_noten.txt.",
},
],
},
},
// notenConfig.js has the only this.skip() call
{
files: ["tests/cypress/support/helpers/notenConfig.js"],
rules: { "no-restricted-syntax": "off" },
},
// must be last: turns off all style rules that Prettier sets
prettier,
];
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env node
/**
* Switches a c3p0 instance to a profile of the noten suite, over SFTP.
*
* node tests/cypress/local/notenProfiles.js status Profiles and the active profile
* node tests/cypress/local/notenProfiles.js apply <profile> switch, for example before `cypress open`
* node tests/cypress/local/notenProfiles.js restore restore the originals
* node tests/cypress/local/notenProfiles.js run [api|ui|all] [a,b] one run per profile, followed by the report
*
* The profiles are located in tests/cypress/profiles/noten.js. The `run` command opens the SSH tunnel itself.
*/
const path = require("path");
["local", "suites", "."].forEach((dir) => require("dotenv").config({ path: path.join(__dirname, "..", dir, ".env") }));
const profileSwitch = require("./profileSwitch");
const { openDbTunnel } = require("./withTunnel");
const { profiles } = require("../profiles/noten");
const SCOPES = {
api: { spec: "tests/cypress/e2e/specs/{unit,api}/**/*.cy.js" },
// Electron crashes on the Benotungstool page
ui: { spec: "tests/cypress/e2e/specs/ui/**/*.cy.js", browser: "chrome" },
all: { spec: "tests/cypress/e2e/specs/**/*.cy.js", browser: "chrome" },
};
const NOT_RUN = ["pending", "skipped"];
// api/noten/freigabe.cy.js and ui/noten/freigabe.cy.js share a file name, so the key keeps the folder
const specName = (run) => path.relative("tests/cypress/e2e/specs", run.spec.relative).split(path.sep).join("/");
const testTitle = (run, test) => `${specName(run)} › ${test.title.join(" › ")}`;
const column = (value, width) => String(value).padStart(width);
/** The numbers and the red tests of each profile, and the tests that run in no profile. */
const report = (names, results) => {
const tests = new Map();
const completed = [];
let failed = false;
console.log(`\n${"=".repeat(78)}\n${"Profil".padEnd(20)} Tests grün rot pending\n${"-".repeat(78)}`);
names.forEach((name) => {
const result = results[name];
if (!result || !Array.isArray(result.runs)) {
failed = true;
console.log(`${name.padEnd(20)} Lauf gescheitert: ${result ? result.message : "kein Ergebnis"}`);
return;
}
completed.push(name);
const counts = { passed: 0, failed: 0, pending: 0, skipped: 0 };
result.runs.forEach((run) =>
run.tests.forEach((test) => {
counts[test.state] = (counts[test.state] || 0) + 1;
const key = testTitle(run, test);
if (!tests.has(key)) tests.set(key, {});
tests.get(key)[name] = test;
}),
);
if (counts.failed) failed = true;
const total = counts.passed + counts.failed + counts.pending + counts.skipped;
console.log(
`${name.padEnd(20)} ${column(total, 5)} ${column(counts.passed, 6)} ${column(counts.failed, 6)} ${column(counts.pending + counts.skipped, 7)}`,
);
});
completed.forEach((name) => {
const failures = [...tests.entries()].filter(
([, byProfile]) => byProfile[name] && byProfile[name].state === "failed",
);
if (!failures.length) return;
console.log(`\nRot in "${name}":`);
failures.forEach(([key, byProfile]) =>
console.log(` - ${key}\n ${String(byProfile[name].displayError || "").split("\n")[0]}`),
);
});
const neverRun = [...tests.keys()].filter((key) =>
completed.every((name) => !tests.get(key)[name] || NOT_RUN.includes(tests.get(key)[name].state)),
);
console.log(`\nIn keinem Profil gelaufen (${neverRun.length}):`);
neverRun.forEach((key) => console.log(` - ${key}`));
console.log("=".repeat(78));
return failed ? 1 : 0;
};
const runProfiles = async (scope = "api", selection = null) => {
const options = SCOPES[scope];
if (!options) {
console.error(`Unbekannter Umfang "${scope}". Erlaubt: ${Object.keys(SCOPES).join(", ")}`);
return 2;
}
const names = selection
? selection
.split(",")
.map((n) => n.trim())
.filter(Boolean)
: Object.keys(profiles);
names.forEach((name) => profileSwitch.profileData(name));
const cypress = require("cypress");
const results = {};
const closeTunnel = await openDbTunnel();
try {
for (const name of names) {
console.log(`\n### Profil "${name}": ${profiles[name].description}\n`);
try {
await profileSwitch.apply(name);
results[name] = await cypress.run(options);
} catch (error) {
// a failed profile switch ends only this run; the report names the reason
results[name] = { message: error.message };
}
}
} finally {
await profileSwitch.restore();
await closeTunnel();
}
return report(names, results);
};
const main = async () => {
const [command, first, second] = process.argv.slice(2);
if (command === "run") return runProfiles(first, second);
if (command === "apply" && first) {
await profileSwitch.apply(first);
return 0;
}
if (command === "restore") {
if (!(await profileSwitch.restore())) console.log("Kein Profil war aktiv.");
return 0;
}
if (command === "status") {
const state = await profileSwitch.status();
console.log(state && state.profile ? `\nAktiv auf ${state.root}: "${state.profile}"` : "\nKein Profil aktiv.");
console.log("\nProfile:");
Object.entries(profiles).forEach(([name, p]) => console.log(` ${name.padEnd(18)} ${p.description}`));
console.log("");
return 0;
}
console.error(
"\n node tests/cypress/local/notenProfiles.js status | apply <profile> | restore | run [api|ui|all] [profile,profile]\n",
);
return 2;
};
main()
.then((code) => process.exit(code))
.catch((error) => {
console.error(`\n${error.message}\n`);
process.exit(1);
});
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"private": true,
"description": "Eigener Arbeitsplatz: SSH-Tunnel, Profile, Lint und Format.",
"scripts": {
"lint": "cd ../../.. && eslint -c tests/cypress/local/eslint.config.js tests/cypress cypress.config.js",
"format": "cd ../../.. && prettier --config tests/cypress/local/package.json --write \"tests/cypress/**/*.js\" \"!tests/cypress/e2e/fhcomplete/**\" \"!tests/cypress/e2e/*.cy.js\""
},
"prettier": {
"useTabs": true,
"tabWidth": 4,
"printWidth": 120,
"endOfLine": "auto"
},
"dependencies": {
"ssh2": "^1.17.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"eslint": "^10.10.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-cypress": "^7.0.2",
"globals": "^17.12.0",
"prettier": "^3.9.7"
}
}
+341
View File
@@ -0,0 +1,341 @@
/**
* Switches the test instance to a configuration profile of the noten suite.
*
* A profile changes application/config/noten.php and the define() flags in config/global.config.inc.php.
* The originals go to the home directory of the SSH user, outside the web root, because
* global.config.inc.php contains credentials.
*
* state.json keeps the MD5 hash of each written file. If a file has a different hash, someone deployed
* it again. Then that file is the new original, and the old backup is void.
*/
const crypto = require("crypto");
const path = require("path");
const { resolveAuth } = require("./sshTunnel");
const { base, profiles } = require("../profiles/noten");
// The instance can run on a different host than the database tunnel. If NOTEN_PROFILE_SSH_HOST is set,
// all SSH values come from NOTEN_PROFILE_SSH_*; without a key, the SSH agent is used.
const envValue = (key) => {
const ownHost = key.startsWith("SSH_") && Boolean(process.env.NOTEN_PROFILE_SSH_HOST);
return process.env[`NOTEN_${ownHost ? "PROFILE_" : ""}${key}`];
};
const FILES = {
config: "application/config/noten.php",
flags: "config/global.config.inc.php",
};
// relative to the home directory; that's where every SFTP session starts
const BACKUP_DIR = ".cypress-noten-profile";
const STATE_FILE = `${BACKUP_DIR}/state.json`;
const NAME_PATTERN = /^[a-z0-9-]+$/;
const KEY_PATTERN = /^[A-Z0-9_]+$/;
const md5 = (content) => crypto.createHash("md5").update(content).digest("hex");
const sftpCall = (fn) => new Promise((resolve, reject) => fn((err, value) => (err ? reject(err) : resolve(value))));
const sshConfigured = () => Boolean(envValue("SSH_HOST") && envValue("SSH_USER"));
// --- SFTP ---
const connect = () =>
new Promise((resolve, reject) => {
const { Client } = require("ssh2");
let auth;
try {
auth = resolveAuth({
keyPath: envValue("SSH_KEY"),
passphrase: envValue("SSH_PASSPHRASE"),
agent: envValue("SSH_AGENT"),
});
} catch (error) {
reject(error);
return;
}
const client = new Client();
client
.on("ready", () => client.sftp((err, sftp) => (err ? reject(err) : resolve({ client, sftp }))))
.on("error", (err) =>
reject(new Error(`SSH zu ${envValue("SSH_USER")}@${envValue("SSH_HOST")} gescheitert: ${err.message}`)),
)
.connect({
host: envValue("SSH_HOST"),
port: Number(envValue("SSH_PORT") || 22),
username: envValue("SSH_USER"),
privateKey: auth.privateKey,
passphrase: auth.passphrase,
agent: auth.agent,
readyTimeout: 15000,
});
});
const withSftp = async (fn) => {
const { client, sftp } = await connect();
try {
return await fn(sftp);
} finally {
client.end();
}
};
const exists = (sftp, file) => new Promise((resolve) => sftp.stat(file, (err) => resolve(!err)));
const read = (sftp, file) => sftpCall((cb) => sftp.readFile(file, cb));
const writeRaw = (sftp, file, content, mode) => sftpCall((cb) => sftp.writeFile(file, content, { mode }, cb));
/** Writes to a temporary file first, so no request reads a half-written configuration. */
const write = async (sftp, file, content) => {
// .php: a web server runs the file instead of sending its credentials
const temp = `${file}.cypress-temp.php`;
await writeRaw(sftp, temp, content, 0o644);
await sftpCall((cb) => sftp.ext_openssh_rename(temp, file, cb));
};
const backupOf = (remotePath) => `${BACKUP_DIR}/${path.posix.basename(remotePath)}`;
const readState = async (sftp) =>
(await exists(sftp, STATE_FILE)) ? JSON.parse((await read(sftp, STATE_FILE)).toString("utf8")) : null;
const writeState = (sftp, state) => writeRaw(sftp, STATE_FILE, JSON.stringify(state, null, 2), 0o600);
// --- Changing the files ---
const phpString = (text) => `'${String(text).replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
const phpValue = (value) => {
if (value === null) return "null";
if (typeof value === "boolean" || typeof value === "number") return String(value);
if (typeof value === "string") return phpString(value);
throw new Error(`Ein define()-Schalter nimmt nur einen einfachen Wert, nicht ${JSON.stringify(value)}.`);
};
// ASCII only: this block does not change the file's encoding
const asciiJson = (value) =>
JSON.stringify(value).replace(/[-￿]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
/** Appends the profile keys to noten.php. The later assignment wins. */
const withConfig = (original, name, config) => {
const text = original.toString("latin1");
const block =
`\n// cypress-profil "${name}": nur auf der Testinstanz, die Suite stellt das Original wieder her\n` +
`$config = array_replace($config, json_decode(${phpString(asciiJson(config))}, true));\n`;
const end = text.search(/\?>\s*$/);
return Buffer.from(end === -1 ? text + block : text.slice(0, end) + block + text.slice(end), "latin1");
};
/** Replaces the `define()` line of each flag. A second `define()` would print a notice on the page. */
const withFlags = (original, flags) => {
let text = original.toString("latin1");
Object.entries(flags).forEach(([flag, value]) => {
const pattern = new RegExp(`^([ \\t]*)define\\(\\s*['"]${flag}['"]\\s*,[^;]*;`, "m");
if (!pattern.test(text)) {
throw new Error(
`${FILES.flags} definiert ${flag} nicht. Ein Profil ändert nur einen bestehenden Schalter.`,
);
}
text = text.replace(pattern, (match, indent) => `${indent}define('${flag}', ${phpValue(value)});`);
});
return Buffer.from(text, "latin1");
};
// --- Profile ---
/** The profile plus the base. `expect` lists getCisConfig values that a flag changes only indirectly. */
const profileData = (name) => {
if (!NAME_PATTERN.test(String(name)) || !Object.prototype.hasOwnProperty.call(profiles, name)) {
throw new Error(`Unbekanntes Profil "${name}". Vorhanden: ${Object.keys(profiles).join(", ")}`);
}
const data = {
config: { ...base.config, ...profiles[name].config },
flags: { ...base.flags, ...profiles[name].flags },
expect: { ...profiles[name].expect },
};
[...Object.keys(data.config), ...Object.keys(data.flags)].forEach((key) => {
if (!KEY_PATTERN.test(key)) throw new Error(`Ungültiger Schlüssel "${key}" in Profil "${name}".`);
});
return data;
};
/** Restores a file that a profile wrote. A file deployed again since then stays. */
const restoreFile = async (sftp, state, fileKey) => {
const entry = state.files[fileKey];
if (!entry) return;
const remotePath = `${state.root}/${FILES[fileKey]}`;
const backup = backupOf(remotePath);
if (await exists(sftp, backup)) {
if (md5(await read(sftp, remotePath)) === entry.md5) {
await write(sftp, remotePath, await read(sftp, backup));
} else {
console.warn(`[profile] ${remotePath} ist seit dem Profil neu ausgerollt und bleibt stehen.`);
}
await sftpCall((cb) => sftp.unlink(backup, cb));
}
delete state.files[fileKey];
await writeState(sftp, state);
};
/** Writes the profile version of a file and keeps the original as a backup. */
const applyFile = async (sftp, state, fileKey, transform) => {
const remotePath = `${state.root}/${FILES[fileKey]}`;
const backup = backupOf(remotePath);
const current = await read(sftp, remotePath);
const entry = state.files[fileKey];
const fromProfile = Boolean(entry) && md5(current) === entry.md5;
let original = current;
if (fromProfile) {
if (!(await exists(sftp, backup))) {
throw new Error(`Die Sicherung ${backup} fehlt. Das Original von ${remotePath} ist unbekannt.`);
}
original = await read(sftp, backup);
} else {
if (entry)
console.warn(`[profile] ${remotePath} ist seit dem letzten Profil neu ausgerollt und gilt als Original.`);
await writeRaw(sftp, backup, current, 0o600);
}
const updated = transform(original);
// the state first: if the write fails, the next run takes the file as the original
state.files[fileKey] = { md5: md5(updated) };
await writeState(sftp, state);
await write(sftp, remotePath, updated);
};
const matches = (actual, expected) =>
typeof expected === "boolean" ? Boolean(actual) === expected : JSON.stringify(actual) === JSON.stringify(expected);
/** The getCisConfig data, or null with a reason for a temporary error (network, HTTP 5xx). */
const readCisConfig = async (url, auth) => {
let response;
try {
response = await fetch(url, { headers: { Authorization: auth } });
} catch (error) {
return { actual: null, reason: error.message };
}
const text = await response.text();
try {
const actual = JSON.parse(text).data;
if (actual) return { actual };
} catch (e) {
// no JSON response
}
// below 500 a PHP message comes before the JSON: the file is broken, and waiting does not help
if (response.status < 500) {
throw new Error(
`getCisConfig antwortet nach dem Profilwechsel mit HTTP ${response.status}: ${text.slice(0, 300)}`,
);
}
return { actual: null, reason: `getCisConfig antwortet mit HTTP ${response.status}` };
};
/** Waits until getCisConfig returns the profile. PHP-FPM reads a changed file only after opcache.revalidate_freq. */
const verifyInstance = async (data) => {
const expected = { ...data.config, ...data.flags, ...data.expect };
const baseUrl = String(process.env.BASE_URL).replace(/\/+$/, "");
const url = `${baseUrl}/index.ci.php/api/frontend/v1/Noten/getCisConfig`;
const auth = `Basic ${Buffer.from(`${process.env.NOTEN_LEKTOR_USER}:${process.env.NOTEN_LEKTOR_PASSWORD}`).toString("base64")}`;
const deadline = Date.now() + 60000;
for (;;) {
const { actual, reason } = await readCisConfig(url, auth);
let pending = reason;
if (actual) {
const checkable = Object.keys(expected).filter((key) => key in actual);
if (!checkable.length) {
console.warn("[profile] getCisConfig liefert keinen Schlüssel des Profils. Der Wechsel ist ungeprüft.");
return;
}
const deviating = checkable.filter((key) => !matches(actual[key], expected[key]));
if (!deviating.length) return;
const lines = deviating.map(
(k) => ` ${k}: ${JSON.stringify(actual[k])} statt ${JSON.stringify(expected[k])}`,
);
pending = `die Instanz zeigt das Profil nicht:\n${lines.join("\n")}`;
}
if (Date.now() > deadline) throw new Error(`Profilwechsel nach 60 Sekunden ungeprüft, ${pending}`);
await new Promise((resolve) => setTimeout(resolve, 2000));
}
};
// --- public ---
/** Puts the original files back. Returns the name of the profile that was active, or null. */
const restore = async () => {
if (!sshConfigured()) return null;
return withSftp(async (sftp) => {
const state = await readState(sftp);
if (!state || !Object.keys(state.files).length) return null;
const previous = state.profile;
for (const fileKey of Object.keys(FILES)) await restoreFile(sftp, state, fileKey);
state.profile = null;
await writeState(sftp, state);
console.log(`[profile] "${previous}" ist zurückgenommen.`);
return previous;
});
};
/** Switches the instance to a profile. Without a name it restores the originals. */
const apply = async (name) => {
if (!name) return restore();
const data = profileData(name);
const root = envValue("REMOTE_ROOT");
if (!sshConfigured() || !root) {
throw new Error(
`Profil "${name}" braucht NOTEN_SSH_HOST, NOTEN_SSH_USER und NOTEN_REMOTE_ROOT in tests/cypress/local/.env.`,
);
}
await withSftp(async (sftp) => {
if (!(await exists(sftp, BACKUP_DIR))) await sftpCall((cb) => sftp.mkdir(BACKUP_DIR, { mode: 0o700 }, cb));
const state = (await readState(sftp)) || { files: {} };
if (state.root && state.root !== root && Object.keys(state.files).length) {
throw new Error(
`Auf ${state.root} ist noch "${state.profile}" aktiv. Zuerst: node tests/cypress/local/notenProfiles.js restore`,
);
}
state.root = root;
state.profile = name;
if (Object.keys(data.config).length)
await applyFile(sftp, state, "config", (o) => withConfig(o, name, data.config));
else await restoreFile(sftp, state, "config");
if (Object.keys(data.flags).length) await applyFile(sftp, state, "flags", (o) => withFlags(o, data.flags));
else await restoreFile(sftp, state, "flags");
await writeState(sftp, state);
});
await verifyInstance(data);
console.log(`[profile] "${name}" ist aktiv.`);
return name;
};
const status = () => (sshConfigured() ? withSftp(readState) : Promise.resolve(null));
module.exports = { apply, restore, status, profileData };
+137
View File
@@ -0,0 +1,137 @@
LOCAL WORKSTATION - SSH TUNNEL AND CONFIGURATION PROFILES
=========================================================
These tools are for local workstations only. No file of the suite refers to this folder. The team does
not need it to run the tests.
The tools do two things:
- They open an SSH tunnel to the test database. pg_hba.conf accepts only the application server, so
a workstation cannot connect directly.
- They switch the test instance to a configuration profile, run the suite, and put the original
configuration back.
1. Files
-----------------
withTunnel.js Opens the tunnel, starts a command, and closes the tunnel when the command ends.
sshTunnel.js The tunnel itself (package ssh2). withTunnel.js and profileSwitch.js use it.
notenProfiles.js Command line for the profiles: status, apply, restore, run.
profileSwitch.js Writes the profile over SFTP, keeps the backups, checks the instance.
eslint.config.js Lint rules of the suite. Two rules check conventions from suites/readme_noten.txt.
package.json ssh2, ESLint and Prettier. Cypress, dotenv and pg come from the repository root.
.env SSH access and the path of the instance (gitignored).
The profiles are in tests/cypress/profiles/noten.js. That file is part of the suite.
2. Preparation
---------------
Install the packages in this folder:
cd tests/cypress/local
npm install
Create the credentials file:
tests/cypress/local/.env.example -> tests/cypress/local/.env
The tools also read tests/cypress/.env (BASE_URL) and tests/cypress/suites/.env (NOTEN_LEKTOR_USER,
NOTEN_DB_*). Fill out these two files first. suites/readme_noten.txt explains them.
- NOTEN_DB_HOST in suites/.env stays the real database host, as the SSH host sees it. Do not write
127.0.0.1. The tunnel changes the value only for the command that it starts.
- NOTEN_SSH_HOST is the SSH host. It is usually not the web hostname.
- Without NOTEN_SSH_KEY, the tools use only the SSH agent. They do not read ~/.ssh themselves.
- Use the agent if possible: run ssh-add and keep NOTEN_SSH_KEY empty. On Windows, the service
"OpenSSH Authentication Agent" must run.
- NOTEN_SSH_PASSPHRASE keeps the passphrase in plain text. Use it only if the agent is not possible.
- NOTEN_REMOTE_ROOT is the absolute path of the instance on its host. Only the profiles need it.
- Set NOTEN_PROFILE_SSH_* only when the instance runs on a different host than the database tunnel.
- The tools need Node 18 or later.
3. SSH tunnel
--------------
Run the commands from the repository root. Put withTunnel.js in front of each command that needs
the database:
node tests/cypress/local/withTunnel.js npm run noten:check
node tests/cypress/local/withTunnel.js npm run noten:api
node tests/cypress/local/withTunnel.js npm run noten:ui
node tests/cypress/local/withTunnel.js npx cypress open
withTunnel.js opens the tunnel on a free local port. Then it sets NOTEN_DB_HOST=127.0.0.1 and
NOTEN_DB_PORT=<port> for the command. The .env files do not change. When the command ends, the tunnel
closes. Ctrl+C stops the command first and then the tunnel.
The same result without this code: open the tunnel in a second terminal, and write fixed values in
suites/.env.
ssh -N -L 15432:<database host>:5432 <user>@<ssh host>
NOTEN_DB_HOST=127.0.0.1
NOTEN_DB_PORT=15432
4. Configuration profiles
--------------------------
The suite tests the configuration it finds. Some rules have a second branch, for example two
Antritte instead of three. A profile switches the instance to such a branch.
node tests/cypress/local/notenProfiles.js status Active profile and all profiles.
node tests/cypress/local/notenProfiles.js apply <profile> Switch the instance to a profile.
node tests/cypress/local/notenProfiles.js restore Put the original files back.
node tests/cypress/local/notenProfiles.js run [api|ui|all] [a,b] One run for each profile.
5. Lint and format
-------------------
Run the commands in this folder. The paths in eslint.config.js start at the repository root, so
both scripts change to the root first.
npm run lint ESLint for tests/cypress and cypress.config.js. Add "-- --fix" to fix.
npm run format Prettier for tests/cypress: tabs, 120 columns. It writes the files.
6. Debugging
------------------------
"SSH to <user>@<host> ... failed" or "SSH zu <user>@<host> gescheitert"
The SSH host is often not the web hostname. Without NOTEN_SSH_KEY.
Run ssh-add, or write the key path in NOTEN_SSH_KEY.
"SSH key does not exist"
The path in NOTEN_SSH_KEY is wrong. A path can start with ~.
The tunnel is up, but the database connection closes immediately
The SSH host cannot reach NOTEN_DB_HOST. If Postgres runs on the SSH host itself, write localhost.
"Profilwechsel nach 60 Sekunden ungeprüft"
The instance at BASE_URL does not show the profile. The message lists each key with the actual and
the expected value. Make sure that NOTEN_REMOTE_ROOT is the path of the instance at BASE_URL.
"getCisConfig antwortet nach dem Profilwechsel mit HTTP ..."
HTTP 401: NOTEN_LEKTOR_USER or NOTEN_LEKTOR_PASSWORD is wrong. Other codes: the changed file contains a PHP
error. Run restore, then examine the values of the profile.
"Auf <path> ist noch "<profile>" aktiv"
A profile is still active on a different NOTEN_REMOTE_ROOT. Run restore. It uses the path in
state.json.
"Die Sicherung ... fehlt"
The backup in ~/.cypress-noten-profile/ is missing, so the original file is unknown. Deploy the
file again. The tool then takes it as the original.
"... definiert <FLAG> nicht"
The profile sets a flag that config/global.config.inc.php does not define. Add the define() line
on the instance, or remove the flag from the profile.
Ctrl+C during notenProfiles.js run
The instance stays on the current profile. Run restore.
Self-signed certificate on the instance
Set NODE_TLS_REJECT_UNAUTHORIZED=0 in local/.env.
+139
View File
@@ -0,0 +1,139 @@
/**
* Local port forward over SSH, opened inside the node process.
*
* Knows nothing about the environment: the caller passes host, user and credential.
*/
const fs = require("fs");
const net = require("net");
let server = null;
let sshClient = null;
let localPort = null;
const resolveAgent = (agent) => {
if (agent && agent !== "true") return agent;
if (process.env.SSH_AUTH_SOCK) return process.env.SSH_AUTH_SOCK;
if (process.platform === "win32") return "\\\\.\\pipe\\openssh-ssh-agent";
return null;
};
/**
* Key file or agent, either is enough. A configured but missing key path is an error rather than a
* silent fallback to the agent, since that is nearly always a typo.
*/
const resolveAuth = ({ keyPath, passphrase, agent }) => {
if (keyPath) {
const resolved = keyPath.startsWith("~")
? keyPath.replace(/^~/, process.env.HOME || process.env.USERPROFILE || "")
: keyPath;
if (!fs.existsSync(resolved)) {
throw new Error(`SSH key does not exist: ${resolved}. Fix the path, or clear it to use an agent.`);
}
return {
privateKey: fs.readFileSync(resolved),
passphrase: passphrase || undefined,
description: `key ${resolved}`,
};
}
const resolvedAgent = resolveAgent(agent);
if (resolvedAgent) return { agent: resolvedAgent, description: `agent ${resolvedAgent}` };
throw new Error("No SSH credential: set a key path, or ssh-add your key and leave it empty.");
};
/** Idempotent - repeated calls reuse the existing tunnel. */
const openTunnel = (cfg) =>
new Promise((resolve, reject) => {
if (localPort) return resolve(localPort);
if (!cfg.sshHost || !cfg.sshUser) return reject(new Error("Tunnel needs an SSH host and user."));
const { Client } = require("ssh2");
let auth;
try {
auth = resolveAuth(cfg);
} catch (error) {
return reject(error);
}
sshClient = new Client();
sshClient
.on("ready", () => {
server = net.createServer((socket) => {
// an error before the channel is up must not crash the process
socket.on("error", () => socket.destroy());
sshClient.forwardOut(
socket.remoteAddress || "127.0.0.1",
socket.remotePort || 0,
cfg.dbHost,
cfg.dbPort,
(err, stream) => {
if (err) return socket.destroy();
if (socket.destroyed) return stream.destroy();
// pipe forwards only a clean end; an aborted side must close the other, or the channel keeps its Postgres connection
socket.on("close", () => stream.destroy());
stream.on("error", () => socket.destroy()).on("close", () => socket.destroy());
socket.pipe(stream).pipe(socket);
},
);
});
server.on("error", reject);
// port 0: let the OS pick, so a leftover process cannot collide
server.listen(0, "127.0.0.1", () => {
localPort = server.address().port;
console.log(
`[db] SSH tunnel up: 127.0.0.1:${localPort} -> ${cfg.dbHost}:${cfg.dbPort} ` +
`via ${cfg.sshUser}@${cfg.sshHost} (${auth.description})`,
);
resolve(localPort);
});
})
.on("error", (err) =>
reject(
new Error(
`SSH to ${cfg.sshUser}@${cfg.sshHost} using ${auth.description} failed: ${err.message}. ` +
"The SSH host is often not the web hostname, and a non-default key filename is never " +
"tried automatically.",
),
),
)
.connect({
host: cfg.sshHost,
port: cfg.sshPort || 22,
username: cfg.sshUser,
privateKey: auth.privateKey,
passphrase: auth.passphrase,
agent: auth.agent,
readyTimeout: 15000,
});
});
const closeTunnel = async () => {
// the SSH connection first: its closing channels close the local sockets, which server.close waits for
if (sshClient) {
sshClient.end();
sshClient = null;
}
if (server) {
await new Promise((resolve) => server.close(resolve));
server = null;
}
localPort = null;
return null;
};
const ensureTunnel = async (cfg) => {
try {
return { tunnelled: true, localPort: await openTunnel(cfg) };
} catch (error) {
return { tunnelled: false, reason: error.message };
}
};
module.exports = { ensureTunnel, closeTunnel, resolveAuth };
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env node
/**
* Runs a command using the database behind an SSH tunnel. For your own workstation only:
* No part of the suite references this folder.
*
* node tests/cypress/local/withTunnel.js npm run noten:api
* node tests/cypress/local/withTunnel.js npm run noten:check
* node tests/cypress/local/withTunnel.js npx cypress open
*
* The tunnel sets NOTEN_DB_HOST and NOTEN_DB_PORT of the command to its local end.
*/
const path = require("path");
const { spawn } = require("child_process");
const { ensureTunnel, closeTunnel } = require("./sshTunnel");
["local", "suites"].forEach((dir) => require("dotenv").config({ path: path.join(__dirname, "..", dir, ".env") }));
const env = (key) => process.env[`NOTEN_${key}`];
/** Opens the tunnel and redirects the DB values in process.env to it. Provides the function to close it. */
const openDbTunnel = async () => {
const tunnel = await ensureTunnel({
sshHost: env("SSH_HOST"),
sshPort: Number(env("SSH_PORT") || 22),
sshUser: env("SSH_USER"),
keyPath: env("SSH_KEY"),
passphrase: env("SSH_PASSPHRASE"),
agent: env("SSH_AGENT"),
dbHost: env("DB_HOST"),
dbPort: Number(env("DB_PORT") || 5432),
});
if (!tunnel.tunnelled) throw new Error(`SSH tunnel failed: ${tunnel.reason}`);
process.env.NOTEN_DB_HOST = "127.0.0.1";
process.env.NOTEN_DB_PORT = String(tunnel.localPort);
return closeTunnel;
};
// cmd.exe runs npm and npx only as command-line tools
const commandLine = (parts) => parts.map((p) => (/[\s,;=&|<>^]/.test(p) ? `"${p}"` : p)).join(" ");
const main = async () => {
const parts = process.argv.slice(2);
if (!parts.length) {
console.error("\n node tests/cypress/local/withTunnel.js <command> [arguments]\n");
return 2;
}
const close = await openDbTunnel();
// Ctrl+C also reaches the command; the tunnel stays open until the command ends
process.on("SIGINT", () => {});
return new Promise((resolve) => {
const child = spawn(commandLine(parts), { stdio: "inherit", shell: true });
const finish = async (code) => {
await close();
resolve(code);
};
child.on("error", (error) => {
console.error(`\n${error.message}\n`);
finish(1);
});
child.on("exit", (code) => finish(code === null ? 1 : code));
});
};
if (require.main === module) {
main()
.then((code) => process.exit(code))
.catch((error) => {
console.error(`\n${error.message}\n`);
process.exit(1);
});
}
module.exports = { openDbTunnel };
+144
View File
@@ -0,0 +1,144 @@
/**
* The configurations that the noten suite must pass.
*
* A profile lists only the differences to the instance. `config` changes application/config/noten.php,
* `flags` changes the define() flags in config/global.config.inc.php. After a switch, each key that
* getCisConfig returns under the same name must have the profile value. `expect` lists values that a
* flag changes only indirectly, or that getCisConfig returns in a resolved form.
*
* A profile enables branches that no other profile checks. A run over all profiles lists, at the
* end, the tests that do not run in any profile.
*/
module.exports = {
// applies to every profile
base: {},
profiles: {
default: {
description: "die Konfiguration der Instanz",
},
shipped: {
description: "die Schalter aus global.config-default.inc.php: TERMIN3 an",
flags: { CIS_GESAMTNOTE_PRUEFUNG_TERMIN3: true },
// 1 + TERMIN2 + TERMIN3 + KOMMPRUEF, while CIS_GESAMTNOTE_MAX_ANTRITTE is null
expect: { CIS_GESAMTNOTE_MAX_ANTRITTE: 4 },
},
"no-erstantritt": {
description: "kein Pfad legt Antritt 1 an, die Freigabe verlangt kein Passwort",
config: {
CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME: false,
CIS_GESAMTNOTE_FREIGABE_PASSWORT: false,
},
},
final: {
description: "eine freigegebene Note ist endgültig",
config: { CIS_GESAMTNOTE_FREIGABE_FINAL: true },
},
"two-antritte": {
description: "zwei Antritte, die kommissionelle Prüfung trägt die Studierendenverwaltung ein",
config: {
CIS_GESAMTNOTE_MAX_ANTRITTE: 2,
CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF: false,
},
},
"kommpruef-stv": {
description: "drei Antritte, die kommissionelle Prüfung trägt die Studierendenverwaltung ein",
config: { CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF: false },
},
"no-kommission": {
description: "kein Antritt ist kommissionell",
config: { CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT: 0 },
// getCisConfig resolves 0 to null
expect: { CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT: null },
},
rollen: {
description: "die Assistenz darf weder freigeben noch die kommissionelle Prüfung anlegen",
config: {
CIS_GESAMTNOTE_ROLLENMATRIX: {
"lehre/benotungstool": ["lvnote", "pruefung", "kommpruef", "freigabe", "import"],
"lehre/benotungstool_assistenz": ["lvnote", "pruefung", "import"],
},
},
},
// The specs put Antritte 30 days apart, or 60 days with an entschuldigt Pruefung in between.
// Both limits allow these gaps.
"antritt-gap": {
description: "Wartefrist 14 Tage und Höchstfrist 400 Tage zwischen zwei Antritten",
config: {
CIS_GESAMTNOTE_ANTRITT_MIN_ABSTAND_TAGE: 14,
CIS_GESAMTNOTE_ANTRITT_MAX_ABSTAND_TAGE: 400,
},
},
notenverbesserung: {
description: "Antritt nach einer positiven Note, die bessere Note bleibt LV-Note",
config: {
CIS_GESAMTNOTE_NOTENVERBESSERUNG: true,
CIS_GESAMTNOTE_VERBESSERUNG_BESSERE_GEWINNT: true,
},
},
// without CIS_GESAMTNOTE_ANTRITT_MIN_ABSTAND_TAGE: on the same day the gap is 0 days
"same-day": {
description: "ein neuer Termin darf am Tag eines bestehenden liegen",
config: { CIS_GESAMTNOTE_PRUEFUNG_GLEICHER_TAG: true },
},
"frist-exception": {
description: "die Lehrenden dürfen nach der Eingabefrist eintragen",
config: { CIS_GESAMTNOTE_FRIST_AUSNAHME: ["lehre/benotungstool"] },
expect: { CIS_GESAMTNOTE_FRIST_AUSNAHME_GILT: true },
},
"frist-date-only": {
description: "nur das Prüfungsdatum ist an die Frist gebunden, die Eingabe nicht",
config: { CIS_GESAMTNOTE_FRIST_EINGABE: false },
},
open: {
description:
"frühere Noten änderbar, künftiges Benotungsdatum, keine Datumsfrist, ein Termin hebt die Freigabe nicht auf, LV-Note nach einer Wiederholung änderbar",
config: {
CIS_GESAMTNOTE_VORSCHLAG_NACH_WIEDERHOLUNG: true,
CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETERER_PRUEFUNG: false,
CIS_GESAMTNOTE_DATUM_ZUKUNFT: true,
CIS_GESAMTNOTE_FRIST_PRUEFUNGSDATUM: false,
CIS_GESAMTNOTE_PRUEFUNG_HEBT_FREIGABE_AUF: false,
},
},
notenimport: {
description:
"nur der Notenimport: ohne Datum, mit dem Kürzel aus tbl_note.anmerkung, der Prüfungsimport ist aus",
config: {
CIS_GESAMTNOTE_NOTENIMPORT: true,
CIS_GESAMTNOTE_IMPORT_NOTENKUERZEL: true,
CIS_GESAMTNOTE_PRUEFUNGSIMPORT: false,
},
},
punkte: {
description: "Punktemodus",
flags: { CIS_GESAMTNOTE_PUNKTE: true },
},
// without VERBESSERUNG_BESSERE_GEWINNT: a worse Note of the repeat becomes the LV-Note
"punkte-notenverbesserung": {
description: "Punktemodus mit Notenverbesserung und Notenimport",
flags: { CIS_GESAMTNOTE_PUNKTE: true },
config: {
CIS_GESAMTNOTE_NOTENVERBESSERUNG: true,
CIS_GESAMTNOTE_NOTENIMPORT: true,
},
},
},
};
+32
View File
@@ -0,0 +1,32 @@
# Keys of the noten suite. Copy to tests/cypress/suites/.env (not in Git) and fill in the passwords.
# This file IS in Git: never put a real password here. A value from the environment wins over the file.
# WARNING: the suite deletes and writes grades. Use a test database only.
# The default values are the test data of seeder TST60873 (npm run noten:check -- seed).
# ---- required: the Lektor the suite works as ---------------------------------------------------
NOTEN_LEKTOR_USER=demolektor1
NOTEN_LEKTOR_PASSWORD=
# ---- required: the database of the instance at BASE_URL ----------------------------------------
# The suite resets its test data directly in the database. Without it every spec fails.
NOTEN_DB_HOST=
NOTEN_DB_PORT=5432
NOTEN_DB_NAME=
NOTEN_DB_USER=
NOTEN_DB_PASSWORD=
NOTEN_DB_SSL=false
NOTEN_DB_ALLOW_WRITES=true
# ---- optional: empty -> the tests that need the value skip -------------------------------------
# The test LV. Empty -> the first LV of NOTEN_LEKTOR_USER. Empty semester -> the current one.
NOTEN_LV_ID=5221
NOTEN_SEM_KURZBZ=
# Role tests: an account with only the permission lehre/benotungstool_assistenz.
NOTEN_ASSISTENZ_USER=demoassistenz
NOTEN_ASSISTENZ_PASSWORD=
# Freigabe tests: the LDAP password of NOTEN_LEKTOR_USER. Empty -> NOTEN_LEKTOR_PASSWORD.
# A Freigabe sends a mail. Use an instance that sends all mail to a debug mailbox (MAIL_DEBUG).
NOTEN_FREIGABE_PASSWORD=
+63
View File
@@ -0,0 +1,63 @@
/**
* Connects the noten suite to cypress.config.js: its cy.task functions, its run hooks and
* the Cypress.env() values of its specs. The keys come from tests/cypress/suites/.env.
*/
const path = require("path");
require("dotenv").config({ path: path.join(__dirname, ".env") });
const { registerNotenDbTasks } = require("../tasks/notenDb");
const { closeDb } = require("../tasks/db");
// cy.request sends one request after the other; the concurrency test needs two at the same time.
// Basic auth without a session cookie: PHP runs two requests of one session one after the other
// (session lock), and the test would never reach the database lock.
const postParallel = ({ path: apiPath, bodies }) => {
const base = String(process.env.BASE_URL).replace(/\/+$/, "");
const auth =
"Basic " +
Buffer.from(`${process.env.NOTEN_LEKTOR_USER}:${process.env.NOTEN_LEKTOR_PASSWORD}`).toString("base64");
return Promise.all(
bodies.map((body) =>
fetch(`${base}/index.ci.php/api/frontend/v1/Noten/${apiPath}`, {
method: "POST",
headers: { Authorization: auth, "Content-Type": "application/json" },
body: JSON.stringify(body),
}).then(async (r) => {
const text = await r.text();
try {
return { status: r.status, body: JSON.parse(text) };
} catch (e) {
return { status: r.status, body: null, text: text.slice(0, 500) };
}
}),
),
);
};
// the keys of suites/.env.example that the specs read; the DB keys stay in Node
const env = () => ({
NOTEN_LEKTOR_USER: process.env.NOTEN_LEKTOR_USER || null,
NOTEN_LEKTOR_PASSWORD: process.env.NOTEN_LEKTOR_PASSWORD || null,
NOTEN_SEM_KURZBZ: process.env.NOTEN_SEM_KURZBZ || null,
NOTEN_LV_ID: process.env.NOTEN_LV_ID || null,
NOTEN_ASSISTENZ_USER: process.env.NOTEN_ASSISTENZ_USER || null,
NOTEN_ASSISTENZ_PASSWORD: process.env.NOTEN_ASSISTENZ_PASSWORD || null,
NOTEN_FREIGABE_PASSWORD: process.env.NOTEN_FREIGABE_PASSWORD || null,
});
module.exports = {
setupNodeEvents: (on, config) => {
registerNotenDbTasks(on);
on("task", { "noten:http:postParallel": postParallel });
// the pool closes after the run, so the database keeps no idle connections of it
on("after:run", () => closeDb());
// a value from --env wins over the suite's .env
config.env = { ...env(), ...config.env };
return config;
},
};
+190
View File
@@ -0,0 +1,190 @@
NOTEN TEST SUITE
================
Tests for the Benotungstool page and its backend, the Noten API.
WARNING: the suite deletes and writes grades. Run it only against a test instance with a test database.
1. Start
--------
One time:
npm install
copy tests/cypress/.env.example -> tests/cypress/.env BASE_URL and one login
copy tests/cypress/suites/.env.example -> tests/cypress/suites/.env passwords and database
npm run noten:check -- seed Seeds the test data: demolektor1, LV 5221, demoassistenz.
NOTEN_LEKTOR_USER must be a Lektor who teaches an LV. An admin teaches nothing, so the suite finds no LV.
Run:
npm run noten:check Checks the test data in the database. Run it first when something fails.
npm run noten:api API tests.
npm run noten:ui UI tests in Chrome.
npx cypress open Cypress with a window, to write a test.
Put more Cypress options after "--". A second --spec replaces the default:
npm run noten:api -- --spec tests/cypress/e2e/specs/api/noten/frist.cy.js
npm run noten:ui -- --headed --no-exit
Pending tests are normal. A test skips when the instance has a different configuration than the
test needs. The skip message names the key.
A profile in profiles/noten.js lists the keys for the other branch. To run such a test, set the keys
of the profile in application/config/noten.php (`config`) or config/global.config.inc.php (`flags`)
on the test instance, run the suite, and put the old values back.
2. Domain model
---------------
Note A grade from lehre.tbl_note. Tests never use a fixed id: they use ctx.noten.
LV-Note The grade of one student in one LV (campus.tbl_lvgesamtnote).
Pruefung One exam with a date (lehre.tbl_pruefung).
Antritt A Pruefung that uses one of the allowed attempts. "entschuldigt", "Noch nicht
eingetragen" and "Nicht beurteilt" use no Antritt.
Verlauf The rule state that the server derives for each student: pruefungen, antrittCount,
maxAntritte, canAdd, bestanden, terminal. The client and the tests only read it.
Freigabe The Lektor releases the LV-Notes. This makes them binding and sends a mail.
Frist The last day to enter a Note (Noteneintragungsfrist).
Zeugnisnote The final grade. Only the StV writes it. An Anrechnung blocks every Pruefung.
Punkte With CIS_GESAMTNOTE_PUNKTE the Lektor enters Punkte; the Notenschluessel gives the Note.
Lektor Teaches the LV and enters the Notes.
Assistenz Enters Notes for a Studiengang. CIS_GESAMTNOTE_ROLLENMATRIX decides what it may do.
The Antritt chain of one student:
Antritt 1 the first Note
Antritt 2, 3 ... a repeat, only after a NEGATIVE Note
kommissionelle Pruefung the last Antritt; it always closes the chain
A Note from NOTEN_ABSCHLIESSEND always closes the chain ("bestanden"). Any other positive Note
closes it only while CIS_GESAMTNOTE_NOTENVERBESSERUNG is off.
The server holds all rules: PruefungsverlaufLib the Antritt chain, the controller Noten.php access,
Frist and Freigabe. The instance configuration decides every rule value (CIS_GESAMTNOTE_*).
Tests read it from getCisConfig as ctx.cisConfig.
3. Architecture
---------------
cypress.config.js Global: BASE_URL and one login for all suites. Loads suites/noten.js.
tests/cypress/
suites/noten.js The suite: its cy.task list and its Cypress.env values.
suites/.env NOTEN_* keys: accounts and database (not in Git).
e2e/specs/api/noten/<topic>.cy.js Calls the Noten API and checks the response.
e2e/specs/ui/noten/<topic>.cy.js Clicks through the Benotungstool page.
support/api/notenApi.js One function per endpoint, and the login.
support/pages/benotungstool.po.js All selectors and clicks of the page.
support/helpers/ ctx, test data, skips, assertions.
tasks/ Database access. Runs in Node, not in the browser.
tools/dbCheck.js Checks the test data without a browser.
profiles/noten.js The configurations that the suite must pass.
local/ Personal tools (SSH tunnel, lint). The suite never needs them.
The Noten API (application/controllers/api/frontend/v1/Noten.php):
read getCisConfig, getNoten, getBenotungstoolContext, getLvForStudiengang,
getLehreinheitenForLv, getLektorenForLehreinheit, getStudentenNoten, getNoteByPunkte
write saveLvNote, importLvNoten, savePruefung, createPruefungen, importPruefungen, saveFreigabe
getStudentenNoten { students: [row], domain }. A row has zeugnisnote, lv_note, lv_punkte,
freigabedatum, benotungsdatum, teilnoten, proposed_note and verlauf.
each write { <uid>: { lvgesamtnote, verlauf, pruefung } }
a rejected row { <uid>: { error: { code, message } } } (HTTP 200: createPruefungen and the imports)
a stopped request HTTP 500, errors: [{ code, message }]
the code the phrase key of the message, for example "maxAntritteReached"
Every test has four steps:
1. before loadNotenContext() gives ctx: semester, LV, students, Noten, configuration.
2. beforeEach loginAsLektor() and requireDbReset(). The test starts with givenBaseline(ctx, student).
3. act notenApi.* in an API test, benotungstoolPage.* in a UI test.
4. check readStateViaApi(ctx): what the server reports.
readLvGesamtnoteViaDb(ctx, student): what the database stores.
The helpers:
notenApi.js notenApi.* as NOTEN_LEKTOR_USER; apiGet / apiPost(path, params, auth) as another
account; loginAsLektor, lektorAuth, freigabePassword
notenTestData.js loadNotenContext, requireDbReset, resetNotenState, seedBaseline, seedPruefung,
seedZeugnisnote, readLvGesamtnoteViaDb, readAuthUid, antrittDate
notenScenario.js givenBaseline, addPruefung, editPruefung, readStateViaApi,
studentOf, verlaufOf, pruefungenOf, antritteOf
notenConfig.js skipIf and the require* skips
notenErrors.js expectNotenSuccess, expectNotenError, expectBulkRowAccepted, expectBulkRowError
notenAssistenz.js the Assistenz account
4. Rules for a new test
-----------------------
- Put the test into the spec of its topic. Create a new spec only for a new topic.
- Write the test title in German and describe the behavior: "lehnt eine zweite Prüfung ohne Note ab".
- Write the message of an expect in German too. Only the comments are English.
- Use no fixed ids and no fixed dates. Use ctx.noten, ctx.notenScale, ctx.students, antrittDate(ctx, n).
- Pass the student object (ctx.students[i]) to a helper, not the uid. Use at most ctx.students[6].
- Call the API through notenApi, or apiGet / apiPost for another account. No own cy.request.
- Start each test with givenBaseline, or with resetNotenState alone if the test needs a student
without a Note. To seed several students after one reset, use resetNotenState and then seedBaseline
for each student.
- Never depend on an earlier test.
- Check an error by its code: expectNotenError(response, "maxAntritteReached"). Never compare a text.
- Skip only with skipIf(this, condition, reason) or a require* helper.
- Write a test that skips as function () {}, not as () => {}. An arrow function has no "this".
- Test the branch that the instance has, and skip the other branch.
- Do not stub the Noten API. Use cy.intercept only to wait for a request.
- UI: select elements only by data-cy. After a save, wait for the request (waitForOk), not for the DOM.
- UI specs run in Chrome. Electron crashes on the Benotungstool page.
- A JavaScript error of the page fails the test. Only a rejected API request does not: the page
already shows it as a toast (support/commands.js).
- Write comments in English: one line, and only when the code does not show the reason.
5. Naming
---------
- Domain words stay German, in the code too: Note, LV-Note, Pruefung, Antritt, Verlauf, Freigabe,
Frist, Zeugnisnote, Punkte, Lektor, Assistenz, Lehreinheit, Studiengang, Notenschluessel,
Teilnote, Anrechnung, entschuldigt, kommissionell. All other words are English.
- Two German words of the page are no domain words. The code uses the English word:
Notenvorschlag -> proposal the Note that the table proposes; applyProposal writes it as the LV-Note
Wiederholung -> repeat a Pruefung after Antritt 1 (verlauf.hasRepeat, requireRepeat)
- In code and comments, do not translate a domain word, and do not use a second word for it: "course grade" is LV-Note,
"attempt" is Antritt, "exam" and "Termin" are Pruefung.
- One thing has one name in every layer:
suites/.env JavaScript API field
NOTEN_LV_ID ctx.lvId lv_id
NOTEN_SEM_KURZBZ ctx.semKurzbz sem_kurzbz
NOTEN_LEKTOR_USER lektorAuth()
An endpoint has the same name in Noten.php, public/js/api/factory/noten.js, notenApi.js and the
intercept alias of the page object (@savePruefung).
- "noten" is the name of the suite: NOTEN_* keys, noten*.js files, cy.task("noten:..."), npm run noten:*.
"benotungstool" names only the page: benotungstool.po.js.
- A function name starts with a verb: read, seed, add, edit, expect, require.
- JavaScript names use camelCase. API fields keep the server spelling (student_uid, lv_id).
6. Debugging
------------
401 in all tests
The web instance and suites/.env use different databases. Compare DB_NAME in
config/system.config.inc.php with NOTEN_DB_NAME.
"Fixture-Reset nicht verfügbar"
The database connection is missing. Check NOTEN_DB_* in suites/.env. The pg_hba.conf of the
database must allow the machine that runs Cypress.
A message contains "<< PHRASE"
The phrase is missing in the database. Run system/phrasesupdate.php and phrasesync.php on the server.
phrasesupdate.php only INSERTS a phrase. A changed text therefore needs a new key, which is why
some keys end with a number: notenimportHinweistextv6.
"LV ... hat N Studierende, die Suite braucht 7"
The test LV has too few students. Run: npm run noten:check -- seed
The next run does not start
A Cypress process still runs. Stop it: Stop-Process -Name Cypress -Force
+91
View File
@@ -0,0 +1,91 @@
/**
* The Noten API. The same names and parameters as public/js/api/factory/noten.js.
*
* notenApi.* calls an endpoint as NOTEN_LEKTOR_USER. apiGet / apiPost call it as another account.
*/
const NOTEN_API = "/index.ci.php/api/frontend/v1/Noten";
/** The Lektor the suite works as. No fallback to USER_NAME: that user belongs to every suite. */
export const lektorAuth = () => {
const username = Cypress.env("NOTEN_LEKTOR_USER");
const password = Cypress.env("NOTEN_LEKTOR_PASSWORD");
if (!username || !password) {
throw new Error("NOTEN_LEKTOR_USER / NOTEN_LEKTOR_PASSWORD missing in tests/cypress/suites/.env");
}
return { username, password };
};
/** The LDAP password for the Freigabe. NOTEN_FREIGABE_PASSWORD, else the password of NOTEN_LEKTOR_USER. */
export const freigabePassword = () => Cypress.env("NOTEN_FREIGABE_PASSWORD") || lektorAuth().password;
/**
* Logs in as NOTEN_LEKTOR_USER. Call it in beforeEach: cy.session logs in once per run and restores
* the session before each test. Without a session cookie the server checks every request against LDAP,
* and many LDAP binds can block all PHP-FPM workers of the instance.
*/
export const loginAsLektor = () => {
const { username, password } = lektorAuth();
cy.login(username, password);
};
/** auth: the account (default NOTEN_LEKTOR_USER), or null for no credentials. A session cookie wins over auth. */
export const apiGet = (path, qs, auth = lektorAuth()) =>
cy.request({ method: "GET", url: `${NOTEN_API}/${path}`, qs, ...(auth ? { auth } : {}), failOnStatusCode: false });
export const apiPost = (path, body, auth = lektorAuth()) =>
cy.request({
method: "POST",
url: `${NOTEN_API}/${path}`,
body,
...(auth ? { auth } : {}),
failOnStatusCode: false,
});
export const notenApi = {
getCisConfig: () => apiGet("getCisConfig"),
getNoten: () => apiGet("getNoten"),
getBenotungstoolContext: (sem_kurzbz, lv_id = null) => apiGet("getBenotungstoolContext", { sem_kurzbz, lv_id }),
getLvForStudiengang: (studiengang_kz, sem_kurzbz) => apiGet("getLvForStudiengang", { studiengang_kz, sem_kurzbz }),
getLehreinheitenForLv: (lv_id, sem_kurzbz) => apiGet("getLehreinheitenForLv", { lv_id, sem_kurzbz }),
getLektorenForLehreinheit: (lv_id, sem_kurzbz, lehreinheit_id) =>
apiGet("getLektorenForLehreinheit", { lv_id, sem_kurzbz, lehreinheit_id }),
/** data -> { students, domain }. Read it with notenScenario.js. */
getStudentenNoten: (lv_id, sem_kurzbz) => apiGet("getStudentenNoten", { lv_id, sem_kurzbz }),
getNoteByPunkte: (lv_id, sem_kurzbz, punkte) => apiPost("getNoteByPunkte", { lv_id, sem_kurzbz, punkte }),
// Each write answers data[uid] = { lvgesamtnote, verlauf, pruefung }. A bulk write answers HTTP 200
// also for a rejected row: data[uid] = { error: { code, message } }.
saveLvNote: (lv_id, sem_kurzbz, student_uid, note, punkte = null, datum = null) =>
apiPost("saveLvNote", { lv_id, sem_kurzbz, student_uid, note, punkte, datum }),
/** lv_noten: [{ uid, note, punkte }] */
importLvNoten: (lv_id, sem_kurzbz, lv_noten) => apiPost("importLvNoten", { lv_id, sem_kurzbz, lv_noten }),
/** pruefung: { pruefung_id (null = a new Pruefung), lehreinheit_id, datum, note, punkte, mitarbeiter_uid } */
savePruefung: (lv_id, sem_kurzbz, student_uid, pruefung) =>
apiPost("savePruefung", { lv_id, sem_kurzbz, student_uid, ...pruefung }),
/** students: [{ uid, lehreinheit_id }]; pruefung: { datum, note, punkte, mitarbeiter_uid } */
createPruefungen: (lv_id, sem_kurzbz, students, pruefung) =>
apiPost("createPruefungen", { lv_id, sem_kurzbz, students, ...pruefung }),
/** pruefungen: [{ uid, lehreinheit_id, datum, note, punkte }] */
importPruefungen: (lv_id, sem_kurzbz, pruefungen) => apiPost("importPruefungen", { lv_id, sem_kurzbz, pruefungen }),
/**
* The Freigabe needs the LDAP password. The getCisConfig call first creates a session. Without it,
* the Basic-auth login loads ldap.php with require_once, and the password check in the same request
* finds no LDAP configuration and answers "Incorrect password".
*/
saveFreigabe: (lv_id, sem_kurzbz, password, uids) =>
apiGet("getCisConfig").then(() => apiPost("saveFreigabe", { lv_id, sem_kurzbz, password, uids })),
};
+45
View File
@@ -0,0 +1,45 @@
// Custom commands: https://on.cypress.io/custom-commands
/** Visits a page and fails if any request returned >= 400. */
Cypress.Commands.add("checkPageResources", (url) => {
const failedRequests = [];
cy.intercept("**", (req) => {
req.on("response", (res) => {
if (res.statusCode >= 400) {
failedRequests.push({ url: req.url, status: res.statusCode });
}
});
});
cy.env(["adminusername", "adminpassword"]).then(({ adminusername, adminpassword }) => {
cy.visit(url, { auth: { username: adminusername, password: adminpassword } });
});
cy.then(() => {
if (failedRequests.length > 0) {
const message = failedRequests.map((r) => `${r.status} - ${r.url}`).join("\n");
throw new Error(`Broken resources detected:\n${message}`);
}
});
});
/**
* Session login. cy.session keeps it across specs, so a test does not log in again.
* Without arguments it logs in as USER_NAME.
*/
Cypress.Commands.add("login", (username, password) => {
const { adminusername, adminpassword } = Cypress.env();
const auth = { username: username || adminusername, password: password || adminpassword };
const probe = () =>
cy.request({ url: "/index.ci.php/api/frontend/v1/AuthInfo/getAuthUID", auth }).its("status").should("eq", 200);
cy.session(["login", auth.username], probe, {
cacheAcrossSpecs: true,
validate: probe,
});
});
// The FHC API layer shows a server error as a toast and then rejects the promise; the page does not
// catch it. That is no crash. Every other error of the page fails the test.
Cypress.on("uncaught:exception", (err) => (err.name === "AxiosError" ? false : undefined));
+2
View File
@@ -0,0 +1,2 @@
// Loaded automatically before every spec.
import "./commands";
+1
View File
@@ -0,0 +1 @@
export const waitForOk = (alias) => cy.wait(alias).its("response.statusCode").should("eq", 200);
@@ -0,0 +1,101 @@
/**
* The Assistenz account: NOTEN_ASSISTENZ_USER / NOTEN_ASSISTENZ_PASSWORD.
*
* getByStgs returns a Studiengang only for a semester with a Studienplan. So the helpers search
* backwards from the semester of the suite. Call cy.clearAllCookies() before the first request,
* otherwise the request runs with the session cookie of NOTEN_LEKTOR_USER.
*/
import { apiGet } from "../api/notenApi";
import { expectNotenSuccess } from "./notenErrors";
import { skipIf } from "./notenConfig";
const SEMESTER_SEARCH_DEPTH = 6;
export const assistenzConfigured = () =>
Boolean(Cypress.env("NOTEN_ASSISTENZ_USER") && Cypress.env("NOTEN_ASSISTENZ_PASSWORD"));
/** Skips if no Assistenz account is configured. */
export const requireAssistenz = (testContext) =>
skipIf(
testContext,
!assistenzConfigured(),
"Übersprungen: NOTEN_ASSISTENZ_USER / NOTEN_ASSISTENZ_PASSWORD fehlen.",
);
export const assistenzAuth = () => ({
username: Cypress.env("NOTEN_ASSISTENZ_USER"),
password: Cypress.env("NOTEN_ASSISTENZ_PASSWORD"),
});
/** WS2026 -> [WS2026, SS2026, WS2025, ...] */
const semestersBackwards = (semKurzbz) => {
const semesterList = [semKurzbz];
let type = semKurzbz.slice(0, 2);
let year = Number(semKurzbz.slice(2, 6));
while (semesterList.length < SEMESTER_SEARCH_DEPTH) {
if (type === "WS") {
type = "SS";
} else {
type = "WS";
year -= 1;
}
semesterList.push(`${type}${year}`);
}
return semesterList;
};
/** -> { semKurzbz, data } for the newest semester in which the Assistenz sees a Studiengang, else null. */
export const assistenzContext = (startSemKurzbz) => {
const semesterList = semestersBackwards(startSemKurzbz);
const trySemester = (i) =>
i >= semesterList.length
? cy.wrap(null, { log: false })
: apiGet("getBenotungstoolContext", { sem_kurzbz: semesterList[i] }, assistenzAuth()).then((response) => {
const data = expectNotenSuccess(response, `Kontext der Assistenz in ${semesterList[i]}`);
return data.studiengaenge.length ? { semKurzbz: semesterList[i], data } : trySemester(i + 1);
});
return trySemester(0);
};
/** -> { semKurzbz, lvId, lehreinheiten } for an LV of the Assistenz with Lehreinheiten in the database, else null. */
export const assistenzLvWithLehreinheiten = (startSemKurzbz) =>
assistenzContext(startSemKurzbz).then((result) => {
if (!result) return null;
const { semKurzbz } = result;
const studiengaenge = result.data.studiengaenge;
const firstLvWithLehreinheiten = (lvs, j) =>
j >= lvs.length
? cy.wrap(null, { log: false })
: cy
.task("noten:db:readLehreinheitenOfLv", { lvId: lvs[j].lehrveranstaltung_id, semKurzbz })
.then((ids) =>
ids.length
? {
semKurzbz,
lvId: lvs[j].lehrveranstaltung_id,
lehreinheiten: ids.map(String).sort(),
}
: firstLvWithLehreinheiten(lvs, j + 1),
);
const tryStudiengang = (i) =>
i >= studiengaenge.length
? cy.wrap(null, { log: false })
: apiGet(
"getLvForStudiengang",
{ studiengang_kz: studiengaenge[i].studiengang_kz, sem_kurzbz: semKurzbz },
assistenzAuth(),
)
.then((response) =>
firstLvWithLehreinheiten(expectNotenSuccess(response, "LVs des Studiengangs"), 0),
)
.then((target) => target || tryStudiengang(i + 1));
return tryStudiengang(0);
});
@@ -0,0 +1,66 @@
/**
* Skip helpers for tests that depend on the configuration.
*
* The configuration of the instance decides which rule branch is active, and a test run cannot switch
* it. So a test checks the active branch and skips the other one. A profile in profiles/noten.js
* switches the instance to the other branch (readme_noten.txt, section 1).
*/
/**
* The only way to skip a test. `condition` true -> skip; `reason` says why.
* Call it in it(function () {...}) or beforeEach(function () {...}), never in an arrow function:
* an arrow function has no Mocha `this`, so `this.skip()` is not available.
*
* Each `require*` helper below names one domain precondition and calls `skipIf`.
*/
export const skipIf = (testContext, condition, reason) => {
if (!condition) return;
Cypress.log({ name: "skip", message: reason });
testContext.skip();
};
const punkteMode = (ctx) => Boolean(ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE);
/** Skips if the Punkte mode is off. Call it as the first line in beforeEach(function () {...}). */
export const requirePunkteMode = (testContext, ctx) =>
skipIf(testContext, !punkteMode(ctx), "Übersprungen: CIS_GESAMTNOTE_PUNKTE ist aus.");
/** Skips if the Punkte mode is on. */
export const requireNotenMode = (testContext, ctx) =>
skipIf(testContext, punkteMode(ctx), "Übersprungen: CIS_GESAMTNOTE_PUNKTE ist aktiv.");
/** Skips if the getCisConfig key does not have the value that the test needs. */
export const requireConfig = (testContext, ctx, key, value) =>
skipIf(
testContext,
ctx.cisConfig[key] !== value,
`Übersprungen: ${key} ist ${JSON.stringify(ctx.cisConfig[key])}, der Test braucht ${JSON.stringify(value)}.`,
);
/**
* Skips if the Benotungstool cannot create a repeat (Antritt 2). Without CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF
* the chain ends for the Benotungstool before the kommissionelle Pruefung.
*/
export const requireRepeat = (testContext, ctx) => {
const fromAntritt = ctx.cisConfig.CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT;
const creatable =
ctx.cisConfig.CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF === false && fromAntritt !== null
? Math.min(ctx.maxAntritte, fromAntritt - 1)
: ctx.maxAntritte;
skipIf(testContext, creatable < 2, `Übersprungen: das Werkzeug legt nur ${creatable} Antritt an.`);
};
/**
* Skips if the Benotungstool cannot create the kommissionelle Pruefung: the chain has none, or
* CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF forbids it.
*/
export const requireKommissionellerAntritt = (testContext, ctx) => {
const fromAntritt = ctx.cisConfig.CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT;
const creatable = ctx.cisConfig.CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF;
const possible = creatable && fromAntritt !== null && fromAntritt >= 2 && fromAntritt <= ctx.maxAntritte;
skipIf(
testContext,
!possible,
`Übersprungen: kein anlegbarer kommissioneller Antritt (ab ${JSON.stringify(fromAntritt)}, anlegen ${creatable}).`,
);
};
@@ -0,0 +1,47 @@
/**
* Assertions for the answers of the Noten API.
*
* Each error has a code: the phrase key of its message. The helpers compare the code. A message with
* "<< PHRASE" means that the phrase is missing in the database: run phrasesync.php on the instance.
*/
const expectTranslated = (message, code) =>
expect(message, `Meldung von "${code}"`)
.to.be.a("string")
.and.not.match(/<<\s*PHRASE/i);
/** The request stops with this error: HTTP 500 and the code in errors[]. */
export const expectNotenError = (response, code) => {
expect(response.status, `HTTP-Status für den erwarteten Fehler "${code}"`).to.eq(500);
expect(response.body).to.have.nested.property("meta.status", "error");
const error = (response.body.errors ?? []).find((e) => e.code === code);
expect(error, `Fehler "${code}", erhalten: ${JSON.stringify(response.body.errors)}`).to.exist;
expectTranslated(error.message, code);
};
export const expectNotenSuccess = (response, context = "Anfrage") => {
// carry the server's message into the assertion, or a 500 says only "expected 200"
const errors = ((response.body && response.body.errors) || []).map((e) => e.message).join(" | ");
expect(response.status, `HTTP-Status für ${context}${errors ? ` -- ${errors}` : ""}`).to.eq(200);
expect(response.body, context).to.have.nested.property("meta.status", "success");
return response.body.data;
};
/** An auth failure stops the request before any error is added, so the body has only meta. */
export const expectAuthError = (response) => {
expect(response.status, "HTTP-Status für die abgewiesene Anmeldung").to.eq(401);
expect(response.body).to.have.nested.property("meta.status", "error");
};
/** A bulk write rejects one row: data[uid] = { error: { code, message } }. */
export const expectBulkRowError = (data, uid, code) => {
expect(data).to.have.property(uid);
expect(data[uid].error?.code, `Zeile "${uid}", erhalten: ${JSON.stringify(data[uid])}`).to.eq(code);
expectTranslated(data[uid].error.message, code);
};
export const expectBulkRowAccepted = (data, uid) => {
expect(data).to.have.property(uid);
expect(data[uid].error, `Zeile "${uid}" akzeptiert, erhalten: ${JSON.stringify(data[uid].error)}`).to.be.undefined;
};
@@ -0,0 +1,55 @@
/** Add or change a Pruefung through the API, then read what the server reports. */
import { notenApi } from "../api/notenApi";
import { expectNotenSuccess } from "./notenErrors";
import { resetNotenState, seedBaseline } from "./notenTestData";
/**
* A new Pruefung: the server runs validateAdd.
*
* punkte stays null: in the Punkte mode any value (also 0) makes the server derive the Note from the
* Notenschluessel, and that Note replaces the Note under test.
*/
export const addPruefung = (ctx, student, { note, datum }) =>
notenApi.savePruefung(ctx.lvId, ctx.semKurzbz, student.uid, {
pruefung_id: null,
lehreinheit_id: student.lehreinheit_id,
datum,
note,
punkte: null,
});
/** Changes a Pruefung: the server runs validateEdit. */
export const editPruefung = (ctx, student, { pruefungId, note, datum }) =>
notenApi.savePruefung(ctx.lvId, ctx.semKurzbz, student.uid, {
pruefung_id: pruefungId,
lehreinheit_id: student.lehreinheit_id,
datum,
note,
punkte: null,
});
/** Clears the rows of the suite and seeds the baseline of Antritt 1 for one student. */
export const givenBaseline = (ctx, student, options = {}) =>
resetNotenState(ctx).then(() => seedBaseline(ctx, student, options));
/**
* Reads getStudentenNoten: what the server REPORTS. Read the result with the functions below.
* The counterpart is readLvGesamtnoteViaDb: what the database STORES.
*/
export const readStateViaApi = (ctx) =>
notenApi
.getStudentenNoten(ctx.lvId, ctx.semKurzbz)
.then((response) => expectNotenSuccess(response, "getStudentenNoten"));
/** The row of one student: zeugnisnote, lv_note, lv_punkte, benotungsdatum, freigabedatum, verlauf. */
export const studentOf = (data, uid) => data.students.find((s) => s.uid === uid);
/** The rule state that the server derives: antrittCount, maxAntritte, canAdd, terminal, bestanden, angerechnet. */
export const verlaufOf = (data, uid) => studentOf(data, uid)?.verlauf;
/** All Pruefungen of one student, in Verlauf order. Check position, count and terminal, never the Pruefungstyp. */
export const pruefungenOf = (data, uid) => verlaufOf(data, uid)?.pruefungen ?? [];
/** Only the Pruefungen that use an Antritt. "entschuldigt" and "Noch nicht eingetragen" do not. */
export const antritteOf = (data, uid) => pruefungenOf(data, uid).filter((p) => p.is_antritt);
@@ -0,0 +1,356 @@
/**
* Finds the test data at runtime (ctx), and resets and seeds it in the database.
*
* No fixed ids: the semester, the LV, the Noten and the rule values differ per installation, so the
* suite reads them through the API. NOTEN_SEM_KURZBZ / NOTEN_LV_ID pin the semester and the LV.
* The Noten API has no delete endpoint on purpose, so reset and seed use cy.task (tasks/notenDb.js).
*/
import { notenApi, lektorAuth } from "../api/notenApi";
import { expectNotenSuccess } from "./notenErrors";
const BEZ_ENTSCHULDIGT = "entschuldigt";
const BEZ_NOCH_NICHT = "Noch nicht eingetragen";
// the highest index a spec uses is ctx.students[6]
const MIN_STUDENTS = 7;
let cachedCtx = null;
// --- dates ---
export const pad2 = (n) => String(n).padStart(2, "0");
export const toDateString = (date) => `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
export const shiftDate = (dateString, days) => {
const [y, m, d] = dateString.split("-").map(Number);
const date = new Date(Date.UTC(y, m - 1, d));
date.setUTCDate(date.getUTCDate() + days);
return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())}`;
};
/** The Frist the server derives (Noten::computeFrist). ssConfig/wsConfig: NOTENEINTRAGUNGSFRIST_SS/WS. */
export const expectedFristString = (semKurzbz, ssConfig, wsConfig) => {
const type = semKurzbz.slice(0, 2).toUpperCase();
const year = Number(semKurzbz.slice(2, 6));
const cfg = type === "SS" ? ssConfig : wsConfig;
return `${pad2(cfg.day)}.${pad2(cfg.month)}.${type === "SS" ? year : year + 1}`;
};
/** ssConfig/wsConfig: NOTENEINTRAGUNGSFRIST_SS/WS from getCisConfig. */
export const fristHasPassed = (semKurzbz, ssConfig, wsConfig) => {
const type = semKurzbz.slice(0, 2).toUpperCase();
const year = Number(semKurzbz.slice(2, 6));
if (!["SS", "WS"].includes(type) || !year) return false;
const cfg = type === "SS" ? ssConfig : wsConfig;
const frist = new Date(type === "SS" ? year : year + 1, cfg.month - 1, cfg.day, 23, 59, 59);
return new Date() > frist;
};
/**
* A semester and LV where the Lektor teaches AND the Frist has passed.
*
* Both are necessary: assertLvAccess runs BEFORE the Frist check, so in a semester without teaching
* the request fails there and never reaches the Frist. Seeder group benotungstool_fixture_erweitert
* provides the Sommersemester.
*
* @param {string} type "SS" or "WS"
* @returns {Cypress.Chainable<{semKurzbz: string, lvId: number}|null>}
*/
export const teachingSemesterWithExpiredFrist = (type = "SS", ssConfig, wsConfig) => {
const year = new Date().getFullYear();
const candidates = [];
for (let y = year; y >= year - 6; y -= 1) {
const semKurzbz = `${type}${y}`;
if (fristHasPassed(semKurzbz, ssConfig, wsConfig)) candidates.push(semKurzbz);
}
const trySemester = (i) => {
if (i >= candidates.length) return cy.wrap(null, { log: false });
return notenApi.getBenotungstoolContext(candidates[i]).then((response) => {
const lvs = (response.body && response.body.data && response.body.data.lehrveranstaltungen) || [];
if (lvs.length > 0) {
return { semKurzbz: candidates[i], lvId: lvs[0].lehrveranstaltung_id };
}
return trySemester(i + 1);
});
};
return trySemester(0);
};
// --- discovery ---
const resolveSemester = () => {
const configured = Cypress.env("NOTEN_SEM_KURZBZ");
if (configured) return cy.wrap(configured, { log: false });
return cy
.request({
method: "GET",
url: "/index.ci.php/api/frontend/v1/organisation/Studiensemester/getAll",
qs: { order: "DESC" },
auth: lektorAuth(),
failOnStatusCode: false,
})
.then((response) => {
const semesters = expectNotenSuccess(response, "Studiensemester/getAll");
const today = toDateString(new Date());
const active = semesters
.filter((s) => (s.start || "").slice(0, 10) && (s.start || "").slice(0, 10) <= today)
.sort((a, b) => (b.start || "").localeCompare(a.start || ""))[0];
expect(active, `ein Studiensemester, das am ${today} oder davor beginnt`).to.exist;
return active.studiensemester_kurzbz;
});
};
const resolveLehrveranstaltung = (semKurzbz) => {
const configured = Cypress.env("NOTEN_LV_ID");
if (configured) return cy.wrap(Number(configured), { log: false });
return notenApi.getBenotungstoolContext(semKurzbz).then((response) => {
const lvs = expectNotenSuccess(response, "getBenotungstoolContext").lehrveranstaltungen || [];
expect(
lvs.length,
`Keine Lehrveranstaltung in ${semKurzbz}: der angemeldete Benutzer unterrichtet hier nichts ` +
"(ein Admin meistens nirgends). Setze NOTEN_LV_ID.",
).to.be.greaterThan(0);
return lvs[0].lehrveranstaltung_id;
});
};
/** -> { semKurzbz, lvId, cisConfig, maxAntritte, noten, notenScale, notenOptions, students, studentUids } */
export const loadNotenContext = () => {
if (cachedCtx) return cy.wrap(cachedCtx, { log: false });
const ctx = {};
return resolveSemester()
.then((semKurzbz) => {
ctx.semKurzbz = semKurzbz;
return resolveLehrveranstaltung(semKurzbz);
})
.then((lvId) => {
ctx.lvId = lvId;
return notenApi.getCisConfig();
})
.then((response) => {
ctx.cisConfig = expectNotenSuccess(response, "getCisConfig");
ctx.maxAntritte = ctx.cisConfig.CIS_GESAMTNOTE_MAX_ANTRITTE;
return notenApi.getNoten();
})
.then((response) => {
const noten = expectNotenSuccess(response, "getNoten");
const byBezeichnung = (bez) => noten.find((n) => n.bezeichnung === bez);
const entschuldigt = byBezeichnung(BEZ_ENTSCHULDIGT);
const nochNicht = byBezeichnung(BEZ_NOCH_NICHT);
expect(entschuldigt, `tbl_note braucht die Bezeichnung "${BEZ_ENTSCHULDIGT}"`).to.exist;
expect(nochNicht, `tbl_note braucht die Bezeichnung "${BEZ_NOCH_NICHT}"`).to.exist;
// Only the 1..5 scale: the Lehre Noten also contain 0 ("Teilnote"), which is no result of an assessment.
const specialPks = [entschuldigt.note, nochNicht.note];
const usable = noten.filter((n) => n.lehre && !specialPks.includes(n.note));
const ordinary = usable
.map((n) => n.note)
.filter((note) => Number(note) >= 1 && Number(note) <= 5)
.sort((a, b) => Number(a) - Number(b));
ctx.notenScale =
ordinary.length > 1
? ordinary
: usable
.map((n) => n.note)
.filter((n) => Number(n) !== 0)
.sort((a, b) => Number(a) - Number(b));
expect(
ctx.notenScale.length,
"die Skala braucht zwei Noten, sonst ist keine Änderung prüfbar",
).to.be.greaterThan(1);
// an administrative Note that the editor list leaves out ("intern angerechnet" / "nicht zugelassen")
const notLehre = noten.find((n) => n.lehre === false);
// as a Zeugnisnote, this Note locks the LV-Note, in the client and in the server
const notUeberschreibbar = noten.find((n) => n.lkt_ueberschreibbar === false);
// an Anrechnung blocks every Pruefung of the LV; the Zeugnisnote decides
const angerechnet = byBezeichnung("angerechnet");
const internAngerechnet = byBezeichnung("intern angerechnet");
// A test picks a Note by meaning, not by index: a positive Note closes the Antritt chain,
// so a repeat after "Sehr Gut" is not a test case but an impossible flow.
const abschliessend = (ctx.cisConfig.NOTEN_ABSCHLIESSEND || []).map(String);
const inScale = (n) => Number(n.note) >= 1 && Number(n.note) <= 5;
const negativNoten = usable.filter((n) => !n.positiv && inScale(n)).map((n) => n.note);
const positivNoten = usable.filter((n) => n.positiv && inScale(n)).map((n) => n.note);
const verbesserbar = positivNoten.filter((n) => !abschliessend.includes(String(n)));
expect(negativNoten.length, "ohne negative Note ist keine Antrittskette möglich").to.be.greaterThan(0);
ctx.noten = {
// the only Note that allows a repeat
negativ: negativNoten[0],
// positive but not final: allows a repeat only with CIS_GESAMTNOTE_NOTENVERBESSERUNG
positiv: verbesserbar.length ? verbesserbar[0] : null,
// always closes the chain (NOTEN_ABSCHLIESSEND)
bestnote: positivNoten.find((n) => abschliessend.includes(String(n))) || null,
entschuldigt: entschuldigt.note,
nochNichtEingetragen: nochNicht.note,
notLehre: notLehre ? notLehre.note : null,
notUeberschreibbar: notUeberschreibbar ? notUeberschreibbar.note : null,
angerechnet: angerechnet ? angerechnet.note : null,
internAngerechnet: internAngerechnet ? internAngerechnet.note : null,
};
// the UI specs select and read a Note by its Bezeichnung, not by its id
ctx.notenOptions = noten;
return notenApi.getStudentenNoten(ctx.lvId, ctx.semKurzbz);
})
.then((response) => {
const data = expectNotenSuccess(response, `getStudentenNoten(${ctx.lvId})`);
const students = data.students;
expect(
students.length,
`LV ${ctx.lvId} hat ${students.length} Studierende, die Suite braucht ${MIN_STUDENTS}. Starte: npm run noten:check`,
).to.be.at.least(MIN_STUDENTS);
students.forEach((s) => expect(s.lehreinheit_id, `lehreinheit_id von ${s.uid}`).to.exist);
ctx.students = students;
ctx.studentUids = students.map((s) => s.uid);
cachedCtx = ctx;
return ctx;
});
};
// --- database ---
let dbConnection = null;
/** Fails the test with the reason if the suite cannot reach the database. Call it in before or beforeEach. */
export const requireDbReset = () => {
const status = dbConnection
? cy.wrap(dbConnection, { log: false })
: cy.task("noten:db:checkConnection", null, { log: false }).then((result) => (dbConnection = result));
return status.then((result) => {
expect(
result.available,
`Fixture-Reset nicht verfügbar: ${result.reason}\n\n` +
"Die Suite braucht eine Datenbankverbindung: NOTEN_DB_* in tests/cypress/suites/.env.",
).to.be.true;
});
};
let cachedAuthUid = null;
/**
* The uid of NOTEN_LEKTOR_USER as the database stores it, for mitarbeiter_uid / freigabevon_uid.
* Not Cypress.env("NOTEN_LEKTOR_USER"): LDAP accepts "Demolektor1", but tbl_benutzer.uid is
* "demolektor1", and the foreign keys are case sensitive.
*/
export const readAuthUid = () => {
if (cachedAuthUid) return cy.wrap(cachedAuthUid, { log: false });
return cy
.request({
method: "GET",
url: "/index.ci.php/api/frontend/v1/AuthInfo/getAuthUID",
auth: lektorAuth(),
failOnStatusCode: false,
})
.then((response) => {
const uid = response.body?.data?.uid;
expect(uid, "uid von AuthInfo/getAuthUID").to.be.a("string").and.not.be.empty;
cachedAuthUid = uid;
return uid;
});
};
/** Runs a seed task in the test LV, with NOTEN_LEKTOR_USER as mitarbeiter_uid. */
const seedTask = (task, ctx, studentUid, payload) =>
readAuthUid().then((uid) =>
cy.task(task, { lvId: ctx.lvId, semKurzbz: ctx.semKurzbz, studentUid, mitarbeiterUid: uid, ...payload }),
);
/** Deletes the LV-Noten, Pruefungen and Zeugnisnoten of the students in the test LV. */
export const resetNotenState = (ctx, studentUids) =>
cy.task("noten:db:reset", {
lvId: ctx.lvId,
semKurzbz: ctx.semKurzbz,
studentUids: studentUids || ctx.studentUids,
});
/**
* The baseline for Antritt 1: an LV-Note PLUS its Pruefung row, the same as a Freigabe creates.
* Without that row the LV-Note counts only while no Pruefung exists, and the next added Pruefung
* would replace Antritt 1 instead of becoming Antritt 2.
*
* Options: note, punkte, benotungsdatum, freigegeben, freigabedatum, erstantritt.
* `erstantritt: false` leaves out the Pruefung row: the LV-Note then has no Antritt 1 of its own,
* which is the state of an old Note and of a Note before its Freigabe.
*/
export const seedBaseline = (ctx, student, options = {}) => {
expect(student, "seedBaseline braucht das Studierenden-Objekt aus ctx.students, nicht die uid").to.be.an("object");
// A NEGATIVE Note by default: only a negative Note allows another Antritt.
// Pass ctx.noten.bestnote to close the chain.
const note = options.note !== undefined ? options.note : ctx.noten.negativ;
// With CIS_GESAMTNOTE_FREIGABE_FINAL a freigegeben Note is final. So the baseline leaves the Note
// open by default, and a test can still add Pruefungen.
const freigegeben =
options.freigegeben !== undefined ? options.freigegeben : ctx.cisConfig.CIS_GESAMTNOTE_FREIGABE_FINAL !== true;
const erstantritt = options.erstantritt !== undefined ? options.erstantritt : true;
return seedTask("noten:db:seedLvGesamtnote", ctx, student.uid, {
note,
punkte: options.punkte !== undefined ? options.punkte : null,
benotungsdatum: options.benotungsdatum || baselineBenotungsdatum(ctx),
freigegeben,
freigabedatum: options.freigabedatum || null,
}).then((seeded) => {
if (!erstantritt) return cy.wrap(seeded, { log: false });
return seedTask("noten:db:seedPruefung", ctx, student.uid, {
lehreinheitId: student.lehreinheit_id,
note,
datum: baselineDate(ctx),
type: "Termin1", // legacy Pruefungstyp of Antritt 1; no rule reads it
}).then(() => seeded);
});
};
/** -> { pruefungId, note, datum, type }. For Pruefung states that the API cannot build. */
export const seedPruefung = (ctx, student, { note, datum, type }) =>
seedTask("noten:db:seedPruefung", ctx, student.uid, {
lehreinheitId: student.lehreinheit_id,
note,
datum,
type,
});
/** Sets the Zeugnisnote. Only the StV enters it; the Benotungstool never writes it. */
export const seedZeugnisnote = (ctx, student, note) => seedTask("noten:db:seedZeugnisnote", ctx, student.uid, { note });
/**
* Reads the LV-Note from the database: what is STORED, without the freigabedatum filter
* of getLvGesamtNoten. The counterpart is readStateViaApi.
*/
export const readLvGesamtnoteViaDb = (ctx, student) =>
cy.task("noten:db:readLvGesamtnote", { lvId: ctx.lvId, semKurzbz: ctx.semKurzbz, studentUid: student.uid });
/** The anchor date. Every Antritt date comes after it, so the order is known. */
export const baselineBenotungsdatum = (ctx) => `${Number(ctx.semKurzbz.slice(2, 6))}-01-10 08:00:00`;
export const baselineDate = (ctx) => baselineBenotungsdatum(ctx).slice(0, 10);
/** antrittDate(ctx, 1) < antrittDate(ctx, 2) < ..., all after the baseline, 30 days apart. */
export const antrittDate = (ctx, index) => shiftDate(baselineDate(ctx), 30 * index);
@@ -0,0 +1,406 @@
import { waitForOk } from "../helpers/network";
/**
* Page object for the Benotungstool page.
*
* Selectors are the data-cy attributes from Benotungstool.js. The table is a Tabulator table: a row has
* data-cy='student-row-<uid>', a cell has the attribute tabulator-field, and the Freigabe state is the
* data-state attribute of the cell content, not the icon.
*
* After each write action a method waits for its request (waitForOk), not for the DOM. Otherwise a spec
* checks the state before the response. The submit* methods do not wait: a test uses them to check
* that the server rejects the input.
*/
const TABLE_TIMEOUT = 60_000;
const API = "**/api/frontend/v1/Noten";
/**
* Exact text match for option lists. The Bezeichnungen contain each other:
* contains("Gut") finds "Sehr Gut" first, and contains("Genügend") finds "Nicht Genügend" first.
*/
const exactText = (text) => new RegExp(`^\\s*${String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`);
class BenotungstoolPage {
selectors = {
table: "[data-cy='benotungstool-table']",
row: "[data-cy^='student-row-']",
};
setupIntercepts = () => {
cy.intercept({ method: "GET", url: `${API}/getStudentenNoten*` }).as("getStudentenNoten");
cy.intercept({ method: "GET", url: `${API}/getCisConfig*` }).as("getCisConfig");
cy.intercept({ method: "POST", url: `${API}/saveLvNote` }).as("saveLvNote");
cy.intercept({ method: "POST", url: `${API}/importLvNoten` }).as("importLvNoten");
cy.intercept({ method: "POST", url: `${API}/savePruefung` }).as("savePruefung");
cy.intercept({ method: "POST", url: `${API}/createPruefungen` }).as("createPruefungen");
cy.intercept({ method: "POST", url: `${API}/importPruefungen` }).as("importPruefungen");
cy.intercept({ method: "POST", url: `${API}/saveFreigabe` }).as("saveFreigabe");
cy.intercept({ method: "POST", url: `${API}/getNoteByPunkte` }).as("getNoteByPunkte");
};
/**
* Opens the page by deep link to semester and LV, without the four dropdowns.
* The column layout goes into localStorage in onBeforeLoad: the component reads it at start,
* and before the first visit localStorage belongs to about:blank.
*/
visit = (ctx, { columns = "antritt" } = {}) =>
cy.visit(`/cis.php/Cis/Benotungstool/${ctx.semKurzbz}/${ctx.lvId}`, {
onBeforeLoad(win) {
win.localStorage.setItem("notenToolPruefungsspalten", JSON.stringify(columns));
win.localStorage.removeItem("notenToolStickyCols");
},
});
/** Needs the session of loginAsLektor() from the beforeEach of the spec. */
visitAndWaitForTable = (ctx, options) => {
this.setupIntercepts();
this.visit(ctx, options);
waitForOk("@getStudentenNoten");
this.getTable().should("be.visible");
this.getRows().should("have.length.greaterThan", 0);
};
// --- Elements --------------------------------------------------------------------------------
getTable = () => cy.get(this.selectors.table, { timeout: TABLE_TIMEOUT });
getRows = () => cy.get(this.selectors.row, { timeout: TABLE_TIMEOUT });
getRow = (uid) => cy.get(`[data-cy='student-row-${uid}']`, { timeout: TABLE_TIMEOUT });
getCell = (uid, field) => this.getRow(uid).find(`[tabulator-field='${field}']`);
getFreigabeState = (uid) => this.getCell(uid, "freigabe_state").find("[data-cy='freigabe-state']");
getPruefungCell = (uid, column) => this.getCell(uid, column).find("[data-cy='pruefung-cell']");
getApplyProposalButton = (uid) => this.getCell(uid, "apply_proposal").find("[data-cy='btn-apply-proposal']");
getPruefungAddButton = (uid, column) => this.getCell(uid, column).find("[data-cy='btn-pruefung-add']");
getPruefungEditButton = (uid, column) => this.getCell(uid, column).find("[data-cy='btn-pruefung-edit']");
getBestandenHint = (uid, column) => this.getCell(uid, column).find("[data-cy='pruefung-bestanden']");
getRowCheckbox = (uid) => this.getCell(uid, "selectCol").find("input");
getPruefungModal = () => cy.get("[data-cy='modal-pruefung']");
getFreigabeModal = () => cy.get("[data-cy='modal-freigabe']");
getApplyProposalModal = () => cy.get("[data-cy='modal-apply-proposal']");
getLvNotenImportModal = () => cy.get("[data-cy='modal-lvnoten-import']");
getPruefungImportModal = () => cy.get("[data-cy='modal-pruefung-import']");
getFreigabeSummaryRow = (uid) => cy.get(`[data-cy='freigabe-row-${uid}']`);
// --- Assertions ------------------------------------------------------------------------------
/** open | changed | freigegeben */
expectFreigabeState = (uid, state) => this.getFreigabeState(uid).should("have.attr", "data-state", state);
expectLvNote = (uid, bezeichnung) => this.getCell(uid, "lv_note").should("contain.text", bezeichnung);
expectProposal = (uid, bezeichnung) => this.getCell(uid, "proposed_note").should("contain.text", bezeichnung);
/** The column shows verlauf.antrittCount, a 0 included. */
expectAntrittCount = (uid, count) =>
this.getCell(uid, "verlauf.antrittCount").invoke("text").invoke("trim").should("eq", String(count));
expectPruefung = (uid, column, { note, antritt } = {}) => {
// query again for each assertion, so Cypress retries after a re-render of the cell
if (note !== undefined) {
this.getPruefungCell(uid, column).should("have.attr", "data-note", String(note));
}
if (antritt !== undefined) {
this.getPruefungCell(uid, column).should("have.attr", "data-antritt", String(antritt));
}
};
expectNoPruefung = (uid, column) => this.getCell(uid, column).find("[data-cy='pruefung-cell']").should("not.exist");
expectNoAntrittColumn = (nr) => cy.get(`[tabulator-field='antritt_${nr}']`).should("not.exist");
/** The table holds the selection: the row class and the checkbox in the first column. */
expectRowSelected = (uid, selected) => {
this.getRow(uid).should(selected ? "have.class" : "not.have.class", "tabulator-selected");
this.getRowCheckbox(uid).should(selected ? "be.checked" : "not.be.checked");
};
/**
* The server rejects the request, and a toast shows its message. The message comes from the
* response, so the test needs no phrase.
*/
expectRejected = (alias) =>
cy.wait(alias).then(({ response }) => {
expect(response.statusCode, `${alias} lehnt ab`).to.not.eq(200);
const errorMessage = (response.body?.errors ?? [])[0]?.message;
expect(errorMessage, `Meldung von ${alias}`).to.be.a("string").and.not.be.empty;
cy.get(".p-toast-message", { timeout: TABLE_TIMEOUT }).should("contain.text", errorMessage);
});
/** A bulk endpoint rejects one row: HTTP 200, the message is in data[uid].error and in the toast. */
expectRowRejected = (alias, uid) =>
cy.wait(alias).then(({ response }) => {
expect(response.statusCode, `${alias} antwortet`).to.eq(200);
const errorMessage = response.body?.data?.[uid]?.error?.message;
expect(errorMessage, `Ablehnung der Zeile ${uid}`).to.be.a("string").and.not.be.empty;
cy.get(".p-toast-message", { timeout: TABLE_TIMEOUT }).should("contain.text", errorMessage);
});
/** After a fully rejected action the page shows no success toast. */
expectNoSuccess = () => cy.get(".p-toast-message-success").should("not.exist");
/** A warning from the client. Warnings stay open until closed. */
expectWarning = (text) => cy.get(".p-toast-message-warn", { timeout: TABLE_TIMEOUT }).should("contain.text", text);
expectWarnings = (minCount) =>
cy.get(".p-toast-message-warn", { timeout: TABLE_TIMEOUT }).should("have.length.at.least", minCount);
/** The uids that the last bulk request sent. bodyField: "lv_noten" or "pruefungen". */
sentUids = (alias, bodyField) =>
cy
.get(alias)
.its(`request.body.${bodyField}`)
.then((sentRows) => sentRows.map((z) => z.uid));
// --- Proposal --------------------------------------------------------------------------------
/** Opens the list editor of the proposal column and selects the Bezeichnung. */
setProposal = (uid, bezeichnung) => {
this.getCell(uid, "proposed_note").click();
cy.get(".tabulator-edit-list-item").contains(bezeichnung).click();
};
/** The apply button first asks for the Benotungsdatum. Without `datum` the dialog keeps its default. */
submitApplyProposal = (uid, { datum } = {}) => {
this.closeToasts();
this.getApplyProposalButton(uid).click();
this.getApplyProposalModal().should("be.visible");
if (datum) this.setDate("apply-proposal-datum", datum);
cy.get("[data-cy='apply-proposal-submit']").click();
};
applyProposal = (uid, options) => {
this.submitApplyProposal(uid, options);
waitForOk("@saveLvNote");
this.getApplyProposalModal().should("not.be.visible");
};
// --- Pruefungen ------------------------------------------------------------------------------
/** PrimeVue attaches its panels to `body`, so do not search inside the modal. */
selectDropdownOption = (dataCy, label) => {
cy.get(`[data-cy='${dataCy}']`).click();
cy.contains(".p-dropdown-panel .p-dropdown-item", exactText(label)).click();
};
setDate = (dataCy, ddmmyyyy) => cy.get(`[data-cy='${dataCy}'] input`).first().clear().type(`${ddmmyyyy}{enter}`);
/**
* After each save the page shows a success toast that stays open. It lies above the modal
* (z-index 100001) and covers its buttons, so the test closes it with its close button.
*/
closeToasts = () =>
cy
.get("body")
.then(($body) => {
const buttons = $body.find(".p-toast-message .p-toast-icon-close");
if (buttons.length) cy.wrap(buttons).click({ multiple: true });
})
.then(() => cy.get(".p-toast-message").should("not.exist"));
/** The dialog of a table cell: a new Pruefung for ONE student. */
submitPruefungInCell = (uid, column, { note, datum } = {}) => {
this.closeToasts();
this.getPruefungAddButton(uid, column).click();
this.getPruefungModal().should("be.visible");
if (datum) this.setDate("pruefung-datum", datum);
if (note) this.selectDropdownOption("pruefung-note", note);
cy.get("[data-cy='pruefung-submit']").click();
};
addPruefungInCell = (uid, column, options) => {
this.submitPruefungInCell(uid, column, options);
waitForOk("@savePruefung");
this.getPruefungModal().should("not.be.visible");
};
/** Edits an existing Pruefung. Without `note` the Note stays, for example for a date correction. */
editPruefungInCell = (uid, column, { note, datum } = {}) => {
this.closeToasts();
this.getPruefungEditButton(uid, column).click();
this.getPruefungModal().should("be.visible");
if (datum) this.setDate("pruefung-datum", datum);
if (note) this.selectDropdownOption("pruefung-note", note);
cy.get("[data-cy='pruefung-submit']").click();
waitForOk("@savePruefung");
this.getPruefungModal().should("not.be.visible");
};
openPruefungModalForEdit = (uid, column) => {
this.closeToasts();
this.getPruefungEditButton(uid, column).click();
this.getPruefungModal().should("be.visible");
};
/** Opens the Pruefung dialog for the selection ("new Pruefung"). */
openCreatePruefungen = () => {
this.closeToasts();
cy.get("[data-cy='btn-new-pruefung']").click();
this.getPruefungModal().should("be.visible");
};
/** Clicks the students in the multiselect of the dialog. A click selects or deselects the row in the table. */
toggleStudentsInDialog = (uids) => {
cy.get("[data-cy='pruefung-students']").click();
// the label is "uid – Nachname Vorname – Antritte: n"
uids.forEach((uid) => cy.contains(".p-multiselect-panel .p-multiselect-item", uid).click());
// Do NOT press Escape: it also closes the Bootstrap modal. The open panel can cover the trigger,
// so use the close button of the panel.
cy.get(".p-multiselect-panel .p-multiselect-close").click();
cy.get(".p-multiselect-panel").should("not.exist");
};
/** The multiselect shows the selected students by their label, which starts with the uid. */
expectStudentsInDialog = (uids) =>
uids.forEach((uid) => cy.get("[data-cy='pruefung-students']").should("contain.text", uid));
/** Sends the Pruefung dialog for several students ("new Pruefung"). Does not wait for the response. */
submitCreatePruefungen = ({ uids, note, punkte, datum }) => {
this.openCreatePruefungen();
if (datum) this.setDate("pruefung-datum", datum);
if (note) this.selectDropdownOption("pruefung-note", note);
if (punkte !== undefined) this.setPruefungPunkte(punkte);
this.toggleStudentsInDialog(uids);
cy.get("[data-cy='pruefung-submit']").click();
};
/** Adds a Pruefung for several students. */
createPruefungen = (options) => {
this.submitCreatePruefungen(options);
waitForOk("@createPruefungen");
this.getPruefungModal().should("not.be.visible");
};
// --- Freigabe --------------------------------------------------------------------------------
openFreigabeModal = () => {
cy.get("[data-cy='btn-freigabe']").click();
this.getFreigabeModal().should("be.visible");
};
expectFreigabeSummaryRow = (uid, lvNoteBezeichnung) =>
this.getFreigabeSummaryRow(uid)
.find("[data-cy='freigabe-row-lvnote']")
.should("contain.text", lvNoteBezeichnung);
/** Without a changed LV-Note the dialog shows a hint, and its button stays disabled. */
expectFreigabeEmpty = () => {
cy.get("[data-cy='freigabe-summary-empty']").should("be.visible");
cy.get("[data-cy='freigabe-submit']").should("be.disabled");
};
typeFreigabePassword = (password) => cy.get("[data-cy='freigabe-password'] input").type(password, { log: false });
submitFreigabe = () => cy.get("[data-cy='freigabe-submit']").click();
saveFreigabe = (password) => {
this.typeFreigabePassword(password);
this.submitFreigabe();
waitForOk("@saveFreigabe");
this.getFreigabeModal().should("not.be.visible");
};
// --- Import ----------------------------------------------------------------------------------
/** rows: [[uid, note], ...] -> "uid<TAB>note" per row */
submitLvNotenImport = (rows) => {
this.closeToasts();
cy.get("[data-cy='btn-lvnoten-import']").click();
this.getLvNotenImportModal().should("be.visible");
cy.get("[data-cy='lvnoten-import-text']").type(rows.map((r) => r.join("\t")).join("\n"));
cy.get("[data-cy='lvnoten-import-submit']").click();
};
importLvNoten = (rows) => {
this.submitLvNotenImport(rows);
waitForOk("@importLvNoten");
this.getLvNotenImportModal().should("not.be.visible");
};
/** rows: [[uid, "dd.MM.yyyy", note], ...] */
submitPruefungImport = (rows) => {
this.closeToasts();
cy.get("[data-cy='btn-pruefung-import']").click();
this.getPruefungImportModal().should("be.visible");
cy.get("[data-cy='pruefung-import-text']").type(rows.map((r) => r.join("\t")).join("\n"));
cy.get("[data-cy='pruefung-import-submit']").click();
};
importPruefungen = (rows) => {
this.submitPruefungImport(rows);
waitForOk("@importPruefungen");
this.getPruefungImportModal().should("not.be.visible");
};
// --- Punkte mode ------------------------------------------------------------------------------
// The Punkte column and the Punkte field of the Pruefung dialog exist only with CIS_GESAMTNOTE_PUNKTE.
// The Note then comes from the Notenschluessel; nobody selects it.
getPunkteCell = (uid) => this.getCell(uid, "proposed_punkte");
/**
* Types Punkte into the cell. The client then gets the Note through getNoteByPunkte (debounced).
*
* Uses cy.focused(): the liveNumberEditor focuses its input in onRendered, and the click formats
* the row again (cellClick -> undoSelection), so a second query through the cell can find nothing.
*/
setPunkteInCell = (uid, punkte) => {
this.getPunkteCell(uid).click();
// Enter commits the value (success() in the editor). The render after getNoteByPunkte
// closes the editor, so a blur after that would find nothing.
cy.focused().should("have.attr", "type", "number").clear().type(`${punkte}{enter}`);
waitForOk("@getNoteByPunkte");
};
expectPunkte = (uid, punkte) => this.getPunkteCell(uid).should("contain.text", String(punkte));
/** After a repeat the Punkte column is locked: the Note belongs to the Pruefung. */
expectPunkteCellLocked = (uid) => {
this.getPunkteCell(uid).click();
this.getPunkteCell(uid).find("input").should("not.exist");
};
/** In the Punkte mode the proposal column is locked: the Note comes from the Punkte. */
expectProposalLocked = (uid) => {
this.getCell(uid, "proposed_note").click();
cy.get(".tabulator-edit-list").should("not.exist");
};
expectNoteFieldLocked = (dataCy) => cy.get(`[data-cy='${dataCy}']`).should("have.class", "p-disabled");
/** The Punkte field of the Pruefung dialog. It triggers the Note lookup. */
setPruefungPunkte = (punkte) => {
cy.get("[data-cy='pruefung-punkte'] input").clear().type(String(punkte));
waitForOk("@getNoteByPunkte");
};
// --- Helpers ---------------------------------------------------------------------------------
/** The Bezeichnung of a Note id, as the dropdowns and cells show it. */
bezeichnungOf = (ctx, note) => (ctx.notenOptions ?? []).find((n) => String(n.note) === String(note))?.bezeichnung;
toDDMMYYYY = (isoDate) => {
const [y, m, d] = isoDate.split("-");
return `${d}.${m}.${y}`;
};
/** Date in the configured import format; kept apart from toDDMMYYYY, which the dialog uses. */
importDate = (isoDate, format) => (format === "yyyy-MM-dd" ? isoDate : this.toDDMMYYYY(isoDate));
}
export const benotungstoolPage = new BenotungstoolPage();
+127
View File
@@ -0,0 +1,127 @@
/**
* The Postgres connection for the cy.task functions: pool, write guard, teardown.
* What the suite deletes or seeds is in notenDb.js. Settings: NOTEN_DB_* in tests/cypress/suites/.env.
*/
const env = (key) => process.env[`NOTEN_DB_${key}`];
const dbConfigured = () => Boolean(env("HOST") && env("NAME") && env("USER"));
const writesAllowed = () => String(env("ALLOW_WRITES")).toLowerCase() === "true";
let pool = null;
const getPool = () => {
if (pool) return pool;
// lazy require so the suite loads without pg when no DB task is used
const { Pool } = require("pg");
pool = new Pool({
host: env("HOST"),
port: Number(env("PORT") || 5432),
database: env("NAME"),
user: env("USER"),
password: env("PASSWORD"),
max: 4,
// tells these connections apart from the web application in pg_stat_activity
application_name: "cypress",
connectionTimeoutMillis: 15000,
// the server ends a stuck statement or transaction before the 60 s task timeout, so no lock wait holds a connection
statement_timeout: 30000,
idle_in_transaction_session_timeout: 30000,
ssl: String(env("SSL")).toLowerCase() === "true" ? { rejectUnauthorized: false } : undefined,
});
// an idle client that loses its connection emits here; without a listener the config process crashes
pool.on("error", (error) => console.warn(`[db] idle connection lost: ${error.message}`));
return pool;
};
const assertWritable = () => {
if (!dbConfigured()) {
throw new Error("db refused - set NOTEN_DB_HOST / NOTEN_DB_NAME / NOTEN_DB_USER.");
}
if (!writesAllowed()) {
throw new Error("db refused - set NOTEN_DB_ALLOW_WRITES=true (test databases only).");
}
};
const withClient = async (fn) => {
const client = await getPool().connect();
try {
return await fn(client);
} finally {
client.release();
}
};
const inTransaction = (fn) =>
withClient(async (client) => {
await client.query("BEGIN");
try {
const result = await fn(client);
await client.query("COMMIT");
return result;
} catch (error) {
await client.query("ROLLBACK");
throw error;
}
});
const explainFailure = (error) => {
const msg = String(error && error.message ? error.message : error);
if (/no pg_hba\.conf entry/i.test(msg)) {
const host = (msg.match(/for host "([^"]+)"/) || [])[1] || "this machine";
return (
`the server refused the connection from ${host} - pg_hba.conf has no rule allowing it. ` +
`Run from a host the database admits, or have an entry added. Details: ${msg}`
);
}
if (/authentication failed/i.test(msg)) return `credentials rejected. Details: ${msg}`;
if (/database .* does not exist/i.test(msg)) return `NOTEN_DB_NAME does not exist. Details: ${msg}`;
if (/ECONNREFUSED/i.test(msg)) return `nothing listening on NOTEN_DB_HOST:NOTEN_DB_PORT. Details: ${msg}`;
if (/ETIMEDOUT|EHOSTUNREACH|ENETUNREACH/i.test(msg)) return `host unreachable (firewall/VPN?). Details: ${msg}`;
return msg;
};
/** Proves the connection works. */
const checkConnection = async ({ requireWrites = true } = {}) => {
if (!dbConfigured()) return { available: false, reason: "not-configured" };
if (requireWrites && !writesAllowed()) return { available: false, reason: "writes-disabled" };
try {
await withClient((client) => client.query("SELECT 1"));
return { available: true };
} catch (error) {
// a failed pool is poisoned - drop it so a retry reconnects cleanly
if (pool) {
const failed = pool;
pool = null;
try {
await failed.end();
} catch (e) {
/* ignore */
}
}
return { available: false, reason: explainFailure(error) };
}
};
const closeDb = async () => {
// drop the reference first: a later task must not get a pool that is ending
const open = pool;
pool = null;
if (open) await open.end();
return null;
};
module.exports = {
env,
dbConfigured,
writesAllowed,
assertWritable,
withClient,
inTransaction,
checkConnection,
closeDb,
};
+423
View File
@@ -0,0 +1,423 @@
/**
* The cy.task functions of the noten suite. Connection and guards come from
* db.js; this file only knows which rows the specs write and how to undo them.
*
* Needed because the Noten API has no delete endpoint, while every test writes
* campus.tbl_lvgesamtnote and lehre.tbl_pruefung. Without a reset the suite is single-shot.
*
* Every statement is scoped by an explicit student_uid list plus the test LV and semester.
* No unscoped DELETE in this file.
*/
const { assertWritable, dbConfigured, withClient, inTransaction, checkConnection } = require("./db");
const REQUIRED_SCOPE = ["lvId", "semKurzbz", "studentUids"];
const assertScope = (scope, keys = REQUIRED_SCOPE) => {
if (!scope || typeof scope !== "object") throw new Error("noten:db scope object is required");
keys.forEach((key) => {
if (scope[key] === undefined || scope[key] === null || scope[key] === "") {
throw new Error(`noten:db refused - missing scope key "${key}"`);
}
});
if (!keys.includes("studentUids")) return;
if (!Array.isArray(scope.studentUids) || scope.studentUids.length === 0) {
throw new Error("noten:db refused - studentUids must be a non-empty array");
}
if (scope.studentUids.some((uid) => typeof uid !== "string" || uid.trim() === "")) {
throw new Error("noten:db refused - studentUids must all be non-empty strings");
}
};
const reset = async (scope) => {
assertWritable();
assertScope(scope);
const { lvId, semKurzbz, studentUids } = scope;
return inTransaction(async (client) => {
// pruefungen hang off lehreinheiten, so scope through the LV's lehreinheiten in this semester
const pruefungen = await client.query(
`DELETE FROM lehre.tbl_pruefung
WHERE student_uid = ANY($1::varchar[])
AND lehreinheit_id IN (
SELECT lehreinheit_id FROM lehre.tbl_lehreinheit
WHERE lehrveranstaltung_id = $2 AND studiensemester_kurzbz = $3)`,
[studentUids, lvId, semKurzbz],
);
const noten = await client.query(
`DELETE FROM campus.tbl_lvgesamtnote
WHERE student_uid = ANY($1::varchar[])
AND lehrveranstaltung_id = $2 AND studiensemester_kurzbz = $3`,
[studentUids, lvId, semKurzbz],
);
// a seeded Zeugnisnote can lock the LV note (lkt_ueberschreibbar), so the reset owns it too
const zeugnisnoten = await client.query(
`DELETE FROM lehre.tbl_zeugnisnote
WHERE student_uid = ANY($1::varchar[])
AND lehrveranstaltung_id = $2 AND studiensemester_kurzbz = $3`,
[studentUids, lvId, semKurzbz],
);
return {
deletedPruefungen: pruefungen.rowCount,
deletedLvGesamtnoten: noten.rowCount,
deletedZeugnisnoten: zeugnisnoten.rowCount,
};
});
};
/**
* Seeds the LV-Note of the Antritt-1 baseline. `freigegeben` sets the freigabedatum, and the
* Freigabe state compares it with the benotungsdatum.
*/
const seedLvGesamtnote = async (scope) => {
assertWritable();
assertScope({ ...scope, studentUids: [scope.studentUid] });
const {
lvId,
semKurzbz,
studentUid,
note,
punkte = null,
mitarbeiterUid,
benotungsdatum,
freigegeben = false,
freigabedatum = null,
} = scope;
if (note === undefined || note === null) throw new Error("noten:db seedLvGesamtnote - note is required");
if (!benotungsdatum) throw new Error("noten:db seedLvGesamtnote - benotungsdatum is required");
if (!mitarbeiterUid) throw new Error("noten:db seedLvGesamtnote - mitarbeiterUid is required");
// default the Freigabe to the benotungsdatum: earlier would read as "changed"
const resolvedFreigabe = freigegeben ? freigabedatum || benotungsdatum : null;
return withClient(async (client) => {
await client.query(
`DELETE FROM campus.tbl_lvgesamtnote
WHERE student_uid = $1 AND lehrveranstaltung_id = $2 AND studiensemester_kurzbz = $3`,
[studentUid, lvId, semKurzbz],
);
await client.query(
`INSERT INTO campus.tbl_lvgesamtnote
(student_uid, lehrveranstaltung_id, studiensemester_kurzbz, note, punkte,
mitarbeiter_uid, benotungsdatum, freigabedatum, freigabevon_uid, insertamum, insertvon)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), $6)`,
[
studentUid,
lvId,
semKurzbz,
note,
punkte,
mitarbeiterUid,
benotungsdatum,
resolvedFreigabe,
resolvedFreigabe ? mitarbeiterUid : null,
],
);
return { studentUid, note, benotungsdatum, freigabedatum: resolvedFreigabe };
});
};
/**
* Seeds one Pruefung row, for states that no endpoint can produce: an entschuldigt and a real
* Termin2 side by side, or a kommPruef (entered in a different tool).
*/
const seedPruefung = async (scope) => {
assertWritable();
assertScope({ ...scope, studentUids: [scope.studentUid] });
const { lvId, semKurzbz, studentUid, lehreinheitId, note, datum, type, mitarbeiterUid } = scope;
if (note === undefined || note === null) throw new Error("noten:db seedPruefung - note is required");
if (!datum) throw new Error("noten:db seedPruefung - datum is required");
if (!type) throw new Error("noten:db seedPruefung - type is required");
if (!lehreinheitId) throw new Error("noten:db seedPruefung - lehreinheitId is required");
if (!mitarbeiterUid) throw new Error("noten:db seedPruefung - mitarbeiterUid is required");
return withClient(async (client) => {
// reset scopes through the LV's lehreinheiten, so a row outside them would be unreclaimable
const owned = await client.query(
`SELECT 1 FROM lehre.tbl_lehreinheit
WHERE lehreinheit_id = $1 AND lehrveranstaltung_id = $2 AND studiensemester_kurzbz = $3`,
[lehreinheitId, lvId, semKurzbz],
);
if (!owned.rowCount) {
throw new Error(
`noten:db seedPruefung refused - lehreinheit ${lehreinheitId} is outside ${lvId}/${semKurzbz}`,
);
}
const res = await client.query(
`INSERT INTO lehre.tbl_pruefung
(lehreinheit_id, student_uid, mitarbeiter_uid, note, pruefungstyp_kurzbz, datum,
anmerkung, insertamum, insertvon)
VALUES ($1, $2, $3, $4, $5, $6, '', NOW(), $3)
RETURNING pruefung_id`,
[lehreinheitId, studentUid, mitarbeiterUid, note, type, datum],
);
return { pruefungId: res.rows[0].pruefung_id, note, datum, type };
});
};
/**
* Seeds the Zeugnisnote. Only the StV writes it; no endpoint of the Benotungstool does.
* It decides two rules: an Anrechnung blocks every Pruefung, and lkt_ueberschreibbar locks the LV-Note.
*/
const seedZeugnisnote = async (scope) => {
assertWritable();
assertScope({ ...scope, studentUids: [scope.studentUid] });
const { lvId, semKurzbz, studentUid, note, mitarbeiterUid } = scope;
if (note === undefined || note === null) throw new Error("noten:db seedZeugnisnote - note is required");
if (!mitarbeiterUid) throw new Error("noten:db seedZeugnisnote - mitarbeiterUid is required");
return withClient(async (client) => {
await client.query(
`DELETE FROM lehre.tbl_zeugnisnote
WHERE student_uid = $1 AND lehrveranstaltung_id = $2 AND studiensemester_kurzbz = $3`,
[studentUid, lvId, semKurzbz],
);
await client.query(
`INSERT INTO lehre.tbl_zeugnisnote
(student_uid, lehrveranstaltung_id, studiensemester_kurzbz, note, benotungsdatum,
insertamum, insertvon)
VALUES ($1, $2, $3, $4, NOW(), NOW(), $5)`,
[studentUid, lvId, semKurzbz, note, mitarbeiterUid],
);
return { studentUid, note };
});
};
/** Raw row, without the `freigabedatum < NOW()` filter getLvGesamtNoten applies. */
const readLvGesamtnote = async ({ lvId, semKurzbz, studentUid }) => {
if (!dbConfigured()) throw new Error("noten:db refused - database is not configured");
return withClient(async (client) => {
const res = await client.query(
`SELECT * FROM campus.tbl_lvgesamtnote
WHERE student_uid = $1 AND lehrveranstaltung_id = $2 AND studiensemester_kurzbz = $3`,
[studentUid, lvId, semKurzbz],
);
return res.rows[0] || null;
});
};
const LV_SCOPE = ["lvId", "semKurzbz"];
const assertReadable = () => {
if (!dbConfigured()) throw new Error("noten:db refused - database is not configured");
};
const readRows = async (sql, params) => withClient(async (client) => (await client.query(sql, params)).rows);
/** Same query as Lehrveranstaltung_model::getLeIdsByStudent. Empty = no participant. */
const readLehreinheitenOfStudent = async (scope) => {
assertReadable();
assertScope({ ...scope, studentUids: [scope.studentUid] });
const rows = await readRows(
`SELECT DISTINCT lehreinheit_id FROM campus.vw_student_lehrveranstaltung
WHERE uid = $1 AND lehrveranstaltung_id = $2 AND studiensemester_kurzbz = $3
ORDER BY lehreinheit_id`,
[scope.studentUid, scope.lvId, scope.semKurzbz],
);
return rows.map((r) => r.lehreinheit_id);
};
/** Same joins as Lehreinheit_model::getLehreinheitenForLv. */
const readLehreinheitenOfLv = async (scope) => {
assertReadable();
assertScope(scope, LV_SCOPE);
const rows = await readRows(
`SELECT DISTINCT le.lehreinheit_id FROM lehre.tbl_lehreinheit le
JOIN lehre.tbl_lehreinheitgruppe leg USING (lehreinheit_id)
JOIN public.tbl_studiengang stg ON stg.studiengang_kz = leg.studiengang_kz
WHERE le.lehrveranstaltung_id = $1 AND le.studiensemester_kurzbz = $2
ORDER BY 1`,
[scope.lvId, scope.semKurzbz],
);
return rows.map((r) => r.lehreinheit_id);
};
/** A Lehreinheit of another LV in the same semester, or null. */
const readForeignLehreinheit = async (scope) => {
assertReadable();
assertScope(scope, LV_SCOPE);
const rows = await readRows(
`SELECT lehreinheit_id, lehrveranstaltung_id FROM lehre.tbl_lehreinheit
WHERE studiensemester_kurzbz = $1 AND lehrveranstaltung_id <> $2
ORDER BY lehreinheit_id LIMIT 1`,
[scope.semKurzbz, scope.lvId],
);
return rows[0] || null;
};
/** An LV of the semester that the uid does not teach, or null. Same condition as getLektorIsTeachingLva. */
const readForeignLv = async ({ semKurzbz, uid } = {}) => {
assertReadable();
if (!semKurzbz || !uid) throw new Error("noten:db readForeignLv - semKurzbz and uid are required");
const rows = await readRows(
`SELECT le.lehrveranstaltung_id FROM lehre.tbl_lehreinheit le
WHERE le.studiensemester_kurzbz = $1
AND le.lehrveranstaltung_id NOT IN (
SELECT t.lehrveranstaltung_id FROM lehre.tbl_lehreinheit t
JOIN lehre.tbl_lehreinheitmitarbeiter m USING (lehreinheit_id)
WHERE t.studiensemester_kurzbz = $1 AND m.mitarbeiter_uid = $2)
ORDER BY 1 LIMIT 1`,
[semKurzbz, uid],
);
return rows[0] ? rows[0].lehrveranstaltung_id : null;
};
/** An LV and semester that the uid teaches and that has no student, or null. */
const readLvWithoutStudents = async ({ uid } = {}) => {
assertReadable();
if (!uid) throw new Error("noten:db readLvWithoutStudents - uid is required");
const rows = await readRows(
`SELECT le.lehrveranstaltung_id, le.studiensemester_kurzbz FROM lehre.tbl_lehreinheit le
JOIN lehre.tbl_lehreinheitmitarbeiter m USING (lehreinheit_id)
WHERE m.mitarbeiter_uid = $1
AND NOT EXISTS (
SELECT 1 FROM campus.vw_student_lehrveranstaltung v
WHERE v.lehrveranstaltung_id = le.lehrveranstaltung_id
AND v.studiensemester_kurzbz = le.studiensemester_kurzbz)
ORDER BY le.studiensemester_kurzbz DESC LIMIT 1`,
[uid],
);
return rows[0] ? { lvId: rows[0].lehrveranstaltung_id, semKurzbz: rows[0].studiensemester_kurzbz } : null;
};
/** An active student without any Lehreinheit in the LV, or null. */
const readNonParticipant = async (scope) => {
assertReadable();
assertScope(scope, LV_SCOPE);
const rows = await readRows(
`SELECT s.student_uid FROM public.tbl_student s
JOIN public.tbl_benutzer b ON b.uid = s.student_uid
WHERE b.aktiv AND NOT EXISTS (
SELECT 1 FROM campus.vw_student_lehrveranstaltung v
WHERE v.uid = s.student_uid AND v.lehrveranstaltung_id = $1 AND v.studiensemester_kurzbz = $2)
ORDER BY s.student_uid LIMIT 1`,
[scope.lvId, scope.semKurzbz],
);
return rows[0] ? rows[0].student_uid : null;
};
/** The LV-Noten of the student in OTHER LVs of the semester; reset never reaches them. */
const countOtherLvNoten = async (scope) => {
assertReadable();
assertScope({ ...scope, studentUids: [scope.studentUid] });
const rows = await readRows(
`SELECT count(*)::int AS total FROM campus.tbl_lvgesamtnote
WHERE student_uid = $1 AND studiensemester_kurzbz = $2 AND lehrveranstaltung_id <> $3`,
[scope.studentUid, scope.semKurzbz, scope.lvId],
);
return rows[0].total;
};
const hasPruefungstyp = async ({ type } = {}) => {
assertReadable();
if (!type) throw new Error("noten:db hasPruefungstyp - type is required");
const rows = await readRows("SELECT 1 FROM lehre.tbl_pruefungstyp WHERE pruefungstyp_kurzbz = $1", [type]);
return rows.length > 0;
};
/** Deletes one Pruefung outside the LV scope, for example one that old code wrote into a foreign Lehreinheit. */
const deletePruefung = async ({ pruefungId, studentUids } = {}) => {
assertWritable();
if (!pruefungId) throw new Error("noten:db deletePruefung - pruefungId is required");
assertScope({ studentUids }, ["studentUids"]);
return withClient(async (client) => {
const res = await client.query(
"DELETE FROM lehre.tbl_pruefung WHERE pruefung_id = $1 AND student_uid = ANY($2::varchar[])",
[pruefungId, studentUids],
);
return res.rowCount;
});
};
/** Active text of each Vorlage, newest version. The suite cannot read the mail itself. */
const readVorlagen = async ({ kurzbz } = {}) => {
assertReadable();
if (!Array.isArray(kurzbz) || !kurzbz.length) throw new Error("noten:db readVorlagen - kurzbz list is required");
return readRows(
`SELECT DISTINCT ON (vorlage_kurzbz) vorlage_kurzbz, text FROM public.tbl_vorlagestudiengang
WHERE vorlage_kurzbz = ANY($1::varchar[]) AND aktiv
ORDER BY vorlage_kurzbz, version DESC`,
[kurzbz],
);
};
/** Lektoren of a Lehreinheit, sorted by uid like Noten::lektorenOfLehreinheit (strcmp). */
const readLektorenOfLehreinheit = async ({ lehreinheitId } = {}) => {
assertReadable();
if (!lehreinheitId) throw new Error("noten:db readLektorenOfLehreinheit - lehreinheitId is required");
const rows = await readRows(
`SELECT mitarbeiter_uid FROM lehre.tbl_lehreinheitmitarbeiter
WHERE lehreinheit_id = $1 ORDER BY mitarbeiter_uid COLLATE "C"`,
[lehreinheitId],
);
return rows.map((r) => r.mitarbeiter_uid);
};
/** The mitarbeiter_uid of one Pruefung row, or null. */
const readPruefungMitarbeiter = async ({ pruefungId, studentUid } = {}) => {
assertReadable();
if (!pruefungId || !studentUid)
throw new Error("noten:db readPruefungMitarbeiter - pruefungId and studentUid are required");
const rows = await readRows(
"SELECT mitarbeiter_uid FROM lehre.tbl_pruefung WHERE pruefung_id = $1 AND student_uid = $2",
[pruefungId, studentUid],
);
return rows[0] ? rows[0].mitarbeiter_uid : null;
};
const registerNotenDbTasks = (on) => {
on("task", {
"noten:db:checkConnection": () => checkConnection(),
"noten:db:reset": reset,
"noten:db:seedLvGesamtnote": seedLvGesamtnote,
"noten:db:seedPruefung": seedPruefung,
"noten:db:seedZeugnisnote": seedZeugnisnote,
"noten:db:readLvGesamtnote": readLvGesamtnote,
"noten:db:readLehreinheitenOfStudent": readLehreinheitenOfStudent,
"noten:db:readLehreinheitenOfLv": readLehreinheitenOfLv,
"noten:db:readForeignLehreinheit": readForeignLehreinheit,
"noten:db:readForeignLv": readForeignLv,
"noten:db:readLvWithoutStudents": readLvWithoutStudents,
"noten:db:readNonParticipant": readNonParticipant,
"noten:db:countOtherLvNoten": countOtherLvNoten,
"noten:db:hasPruefungstyp": hasPruefungstyp,
"noten:db:deletePruefung": deletePruefung,
"noten:db:readVorlagen": readVorlagen,
"noten:db:readLektorenOfLehreinheit": readLektorenOfLehreinheit,
"noten:db:readPruefungMitarbeiter": readPruefungMitarbeiter,
});
};
module.exports = { registerNotenDbTasks };
+165
View File
@@ -0,0 +1,165 @@
/**
* The test data that the noten suite needs. Data only: dbCheck.js runs the checks.
*
* The seeder builds the fixture: demolektor1 teaches LV 5221 (12 students), demoassistenz is Assistenz
* for stg5. suites/.env.example uses the same values.
*
* Every precondition of the chain
* getBenotungstoolContext -> getStudentenNoten -> savePruefung
* is its own query, so a failure names the exact missing link instead of "no students".
*/
const path = require("path");
// tests/cypress/tools/checks -> repo root is four levels up
const SEEDER = path.join(
__dirname,
"..",
"..",
"..",
"..",
"system",
"seeders",
"TST60873_000_GesamtnoteneingabeCis4.sql",
);
/** Test semester: the running one, else the last started. Same rule as the suite and seeder group benotungstool_noten. */
const SEM = `(SELECT studiensemester_kurzbz FROM public.tbl_studiensemester
WHERE start <= now() ORDER BY (ende >= now()) DESC, start DESC LIMIT 1)`;
module.exports = {
title: "Gesamtnoteneingabe",
sqlFiles: {
// the idempotent fixture groups; studiengang_5 inserts without ON CONFLICT and needs a rebuild
seed: ["benotungstool_noten", "benotungstool_berechtigungen", "benotungstool_fixture_erweitert"].map(
(group) => ({ file: SEEDER, group }),
),
},
checks: [
{
label: "Test semester (latest started)",
sql: `SELECT ${SEM} AS value`,
ok: (r) => Boolean(r.value),
hint: "No studiensemester with start <= now(). Apply the base dump + BS003_kompetenzfeld.sql.",
},
{
label: "demolektor1 exists",
sql: `SELECT COUNT(*)::int AS value FROM public.tbl_mitarbeiter WHERE mitarbeiter_uid = 'demolektor1'`,
ok: (r) => r.value > 0,
hint: "Apply BS002_mitarbeiter.sql.",
},
{
label: "STG5 Lehrveranstaltungen (5121 / 5221)",
sql: `SELECT COUNT(*)::int AS value FROM lehre.tbl_lehrveranstaltung
WHERE lehrveranstaltung_id IN (5121, 5221)`,
ok: (r) => r.value === 2,
hint: "Rebuild the database. Seeder group studiengang_5 creates them.",
},
{
label: "Lehreinheiten of demolektor1 in the test semester",
sql: `SELECT COUNT(*)::int AS value
FROM lehre.tbl_lehreinheit le
JOIN lehre.tbl_lehreinheitmitarbeiter USING (lehreinheit_id)
WHERE mitarbeiter_uid = 'demolektor1' AND le.studiensemester_kurzbz = ${SEM}`,
ok: (r) => r.value > 0,
hint:
"demolektor1 teaches nothing in the CURRENT test semester. Seeder group studiengang_5 pins its" +
"Lehreinheiten to the semester that was active when it ran. Apply seeder group benotungstool_noten.",
},
{
label: "Lehreinheitgruppe rows with a non-NULL gruppe_kurzbz",
sql: `SELECT COUNT(*)::int AS value FROM lehre.tbl_lehreinheitgruppe
WHERE gruppe_kurzbz IS NOT NULL`,
ok: (r) => r.value > 0,
hint:
"campus.vw_student_lehrveranstaltung joins ONLY on gruppe_kurzbz. Lehrverband-style rows " +
"(verband set, gruppe_kurzbz NULL) never match. Apply seeder group benotungstool_noten.",
},
{
label: "Group memberships carrying a studiensemester",
sql: `SELECT COUNT(*)::int AS value FROM public.tbl_benutzergruppe
WHERE studiensemester_kurzbz IS NOT NULL`,
ok: (r) => r.value > 0,
hint:
"tbl_benutzergruppe.studiensemester_kurzbz must equal tbl_lehreinheit.studiensemester_kurzbz. " +
"NULL never matches. Apply seeder group benotungstool_noten.",
},
{
label: "Students visible in LV 5221 (the test LV)",
sql: `SELECT COUNT(*)::int AS value FROM campus.vw_student_lehrveranstaltung
WHERE lehrveranstaltung_id = 5221 AND studiensemester_kurzbz = ${SEM}`,
ok: (r) => r.value >= 7,
hint: "The suite needs 7 students (MIN_STUDENTS in notenTestData.js). Apply seeder group benotungstool_noten.",
},
{
label: "tbl_note 'entschuldigt' (resolved by Bezeichnung)",
sql: `SELECT COALESCE((SELECT note::text FROM lehre.tbl_note
WHERE bezeichnung = 'entschuldigt'), '') AS value`,
ok: (r) => r.value !== "",
hint: "Noten.php resolves this by Bezeichnung, not by the config PK. Apply the base inserts.",
},
{
label: "tbl_note 'Noch nicht eingetragen'",
sql: `SELECT COALESCE((SELECT note::text FROM lehre.tbl_note
WHERE bezeichnung = 'Noch nicht eingetragen'), '') AS value`,
ok: (r) => r.value !== "",
hint: "Missing from the base dump. Rebuild the database with db_setup.sh.",
},
{
label: "Pruefungstypen Termin1/Termin2/Termin3/kommPruef",
sql: `SELECT COALESCE(string_agg(pruefungstyp_kurzbz, ',' ORDER BY pruefungstyp_kurzbz), '') AS value
FROM lehre.tbl_pruefungstyp
WHERE pruefungstyp_kurzbz IN ('Termin1','Termin2','Termin3','kommPruef')`,
ok: (r) => r.value.includes("Termin1") && r.value.includes("Termin2"),
hint:
"Termin3 is absent from the base dump. Harmless while CIS_GESAMTNOTE_PRUEFUNG_TERMIN3 is " +
"off, but a Termin3 insert would hit a foreign key error. Seeder group benotungstool_noten adds it.",
},
{
label: "Notenschluessel assigned to LV 5221",
sql: `SELECT COUNT(*)::int AS value
FROM lehre.tbl_notenschluesselzuordnung z
JOIN lehre.tbl_notenschluesselaufteilung a USING (notenschluessel_kurzbz)
WHERE z.lehrveranstaltung_id = 5221`,
ok: (r) => r.value > 0,
hint:
"The Notenschluessel tables are empty in the base dump, so getNoteByPunkte always returns " +
"null. Apply seeder group benotungstool_noten.",
},
{
label: "Students in the second Lehreinheit of LV 5221",
sql: `SELECT COUNT(*)::int AS value FROM campus.vw_student_lehrveranstaltung
WHERE lehreinheit_id = 51103 AND studiensemester_kurzbz = ${SEM}`,
ok: (r) => r.value > 0,
hint: "The Lehreinheit check of the write paths needs it. Apply seeder group benotungstool_fixture_erweitert.",
},
{
label: "Lektoren of Lehreinheit 51103",
sql: "SELECT COUNT(*)::int AS value FROM lehre.tbl_lehreinheitmitarbeiter WHERE lehreinheit_id = 51103",
ok: (r) => r.value >= 2,
hint: "The grader rules need two Lektoren. Apply seeder group benotungstool_fixture_erweitert.",
},
{
label: "Sommersemester after its Frist, taught by demolektor1",
sql: `SELECT COALESCE((SELECT le.studiensemester_kurzbz
FROM lehre.tbl_lehreinheit le
JOIN lehre.tbl_lehreinheitmitarbeiter USING (lehreinheit_id)
WHERE mitarbeiter_uid = 'demolektor1'
AND le.studiensemester_kurzbz ~ '^SS[0-9]{4}$'
AND make_date(substring(le.studiensemester_kurzbz FROM 3 FOR 4)::int, 11, 15) < current_date
ORDER BY le.studiensemester_kurzbz DESC LIMIT 1), '') AS value`,
ok: (r) => r.value !== "",
hint: "The Frist tests need it. Apply seeder group benotungstool_fixture_erweitert.",
},
{
label: "Texts of the Vorlagen Notenfreigabe and Sancho_Mail_Template",
sql: `SELECT COUNT(DISTINCT vorlage_kurzbz)::int AS value FROM public.tbl_vorlagestudiengang
WHERE vorlage_kurzbz IN ('Notenfreigabe', 'Sancho_Mail_Template') AND aktiv
AND COALESCE(text, '') <> ''`,
ok: (r) => r.value === 2,
hint: "Without both texts every Freigabe mail has an empty body. Apply seeder group benotungstool_fixture_erweitert.",
},
],
};
+205
View File
@@ -0,0 +1,205 @@
#!/usr/bin/env node
/**
* Diagnose or seed the database fixture a Cypress suite needs. Suite-agnostic runner.
*
* node tests/cypress/tools/dbCheck.js noten report what is present and what is missing
* node tests/cypress/tools/dbCheck.js noten seed apply the suite's seeder, then re-check
* node tests/cypress/tools/dbCheck.js noten env print the .env block to use
*
* A suite contributes tools/checks/<suite>.js exporting { title, checks, sqlFiles }. Connection
* and output are shared and live here. A sqlFiles entry is a path or { file, group }; a
* "-- ==== <group> ====" line starts a group.
*
* `check` is read-only; `seed` needs NOTEN_DB_ALLOW_WRITES=true and the owner role.
*/
const fs = require("fs");
const path = require("path");
try {
require("dotenv").config({ path: path.join(__dirname, "..", "suites", ".env") });
} catch (e) {
// dotenv missing -> fall back to the ambient environment
}
const db = require("../tasks/db");
const COL = 48;
const CHECKS_DIR = path.join(__dirname, "checks");
const usage = (suite, mode) => `node tests/cypress/tools/dbCheck.js ${suite} ${mode}`;
const loadSuite = (name) => {
const file = path.join(CHECKS_DIR, `${name}.js`);
if (!fs.existsSync(file)) {
const available = fs.existsSync(CHECKS_DIR)
? fs
.readdirSync(CHECKS_DIR)
.map((f) => f.replace(/\.js$/, ""))
.join(", ")
: "(none)";
console.error(`\nUnknown suite "${name}". Available: ${available}\n`);
process.exit(2);
}
return require(file);
};
const runChecks = async (client, suite) => {
console.log(`\n${suite.title} fixture check\n${"=".repeat(70)}`);
const failures = [];
for (const check of suite.checks) {
let row;
try {
row = (await client.query(check.sql)).rows[0] || {};
} catch (error) {
console.log(` [ERR ] ${check.label}\n ${error.message}`);
failures.push(check);
continue;
}
const passed = check.ok(row);
const value = row.value === "" ? "(none)" : String(row.value);
console.log(` [${passed ? "PASS" : "FAIL"}] ${check.label.padEnd(COL)} ${value}`);
if (!passed) failures.push(check);
}
console.log("=".repeat(70));
if (!failures.length) {
console.log("\nAll preconditions met.\n");
return 0;
}
console.log(`\n${failures.length} problem(s):\n`);
failures.forEach((f) => console.log(` - ${f.label}\n ${f.hint}\n`));
console.log(`Fix with: ${usage(suite.name, "seed")}\n`);
return 1;
};
/** The SQL of a file, or of one group in it. A group ends at the next group header. */
const readSql = (file, group) => {
const sql = fs.readFileSync(file, "utf8");
if (!group) return sql;
const parts = sql.split(/^-- ==== (.+?) ====[ \t]*\r?$/m);
for (let i = 1; i < parts.length; i += 2) if (parts[i] === group) return parts[i + 1];
return null;
};
const applySqlFile = async (client, entry) => {
const { file, group } = typeof entry === "string" ? { file: entry } : entry;
const label = path.relative(process.cwd(), file) + (group ? ` [${group}]` : "");
if (!db.writesAllowed()) {
console.error("\nRefusing to write: set NOTEN_DB_ALLOW_WRITES=true first.\n");
return 2;
}
if (!fs.existsSync(file)) {
console.error(`\nFile not found: ${file}\n`);
return 2;
}
const sql = readSql(file, group);
if (sql === null) {
console.error(`\nGroup "${group}" not found in ${file}\n`);
return 2;
}
console.log(`\nApplying ${label} ...\n`);
// One connection, one call: the seeders rely on running as a unit (temp tables, DO blocks and a
// closing verification query).
let result;
try {
result = await client.query(sql);
} catch (error) {
// `check` gets by as the application role; seeding creates and grants objects.
if (/permission denied/i.test(error.message)) {
console.error(`${error.message}\n`);
console.error(`Seeding needs the database owner, but NOTEN_DB_USER is "${db.env("USER")}".`);
console.error("Point it at the owner role, or rebuild the database with db_setup.sh.\n");
return 2;
}
throw error;
}
// Print whatever the last statement returned, whatever its shape.
const results = Array.isArray(result) ? result : [result];
const last = results.filter((r) => r && r.rows && r.rows.length).pop();
if (last) {
console.log("Verification\n" + "-".repeat(50));
last.rows.forEach((row) => Object.entries(row).forEach(([k, v]) => console.log(` ${k.padEnd(24)} ${v}`)));
}
console.log(`\nDone: ${label}\n`);
return 0;
};
const printEnv = () => {
console.log(`
Add to tests/cypress/suites/.env (gitignored):
NOTEN_DB_HOST=<postgres host>
NOTEN_DB_PORT=5432
NOTEN_DB_NAME=<test database>
NOTEN_DB_USER=<user>
NOTEN_DB_PASSWORD=<password>
NOTEN_DB_ALLOW_WRITES=true
`);
};
const main = async () => {
const suiteName = process.argv[2];
const mode = process.argv[3] || "check";
if (!suiteName || suiteName.startsWith("-")) {
console.error(`\n ${usage("<suite>", "[check|seed|env]")}\n`);
return 2;
}
if (mode === "env") {
printEnv();
return 0;
}
if (!["check", "seed"].includes(mode)) {
console.error(`Unknown mode "${mode}". Use: check | seed | env`);
return 2;
}
const suite = loadSuite(suiteName);
suite.name = suiteName;
if (mode !== "check" && !(suite.sqlFiles && suite.sqlFiles[mode])) {
console.error(`Suite "${suiteName}" defines no SQL file for "${mode}".`);
return 2;
}
const status = await db.checkConnection({ requireWrites: mode !== "check" });
if (!status.available) {
console.error(`\nCould not connect: ${status.reason}\n`);
if (status.reason === "not-configured") printEnv();
return 2;
}
try {
return await db.withClient(async (client) => {
client.on("notice", (n) => console.log(` NOTICE: ${n.message}`));
if (mode !== "check") {
// a suite can name several files or groups; they run in this order
for (const file of [].concat(suite.sqlFiles[mode])) {
const code = await applySqlFile(client, file);
if (code !== 0) return code;
}
}
return runChecks(client, suite);
});
} finally {
await db.closeDb();
}
};
main()
.then((code) => process.exit(code))
.catch((error) => {
console.error(`\n${error.message}\n`);
process.exit(1);
});