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;

This commit is contained in:
Johann Hoffmann
2026-08-11 17:35:06 +02:00
parent 3c9f46941c
commit 97be497fda
38 changed files with 3191 additions and 693 deletions
+15 -7
View File
@@ -23,15 +23,23 @@ $config['NOTEN_OCCURANCE_LIMIT_MAP'] = [17 => 1]; // über alle Antritte hinweg
// entry when a new pruefung of the same type is created (instead of being overwritten).
$config['NOTE_ENTSCHULDIGT'] = 17;
// Maximale Anzahl zählender Prüfungsantritte, die IN DIESEM TOOL eingetragen werden können
// (inkl. erstem Antritt). null = aus den Alt-Flags CIS_GESAMTNOTE_PRUEFUNG_TERMIN2/TERMIN3
// ableiten (1 + je aktiviertem Termin), damit bestehende Installationen unverändert laufen.
// Der abschliessende Antritt (kommPruef) wird woanders eingetragen und zählt hier nicht mit.
// The maximum number of attempts that count, the first attempt and the kommissionelle attempt
// included. null derives the number from the old flags:
// 1 (the original assessment) + TERMIN2 + TERMIN3 + KOMMPRUEF
// The examination rules permit three attempts. With TERMIN2 and KOMMPRUEF the formula gives 3.
// An installation with TERMIN3 gets 4, which is one more attempt in the same chain.
$config['CIS_GESAMTNOTE_MAX_ANTRITTE'] = null;
// Prüfungstypen die den Verlauf abschliessen: danach ist kein weiterer Antritt möglich. Das ist
// die einzige Stelle, an der der Typ eines Termins noch eine Bedeutung trägt.
$config['PRUEFUNG_TERMINAL_TYPEN'] = ['kommPruef', 'zusKommPruef'];
// The exam types that take place before a commission.
$config['PRUEFUNG_KOMMISSIONELL_TYPEN'] = ['kommPruef', 'zusKommPruef'];
// The type that the tool writes for the last attempt. The last attempt is always kommissionell.
$config['PRUEFUNG_TYP_KOMMISSIONELL'] = 'kommPruef';
// Exam types that never use an attempt. A zusKommPruef repeats a kommissionelle Prüfung that had
// a procedural fault. It stands outside the attempt chain, and the student administration enters
// it. The examination rules do not describe it.
$config['PRUEFUNG_TYPEN_OHNE_ANTRITT'] = ['zusKommPruef'];
// Spaltenaufteilung der Prüfungen in der Notentabelle:
// 'antritt' - eine Spalte je Antrittsnummer, Datum steht in der Zelle (robust bei Einzelterminen)
+349 -209
View File
@@ -22,6 +22,8 @@ use CI3_Events as Events;
class Noten extends FHCAPI_Controller
{
/** tbl_note by PK, read once per request. @see aktiveNoten() */
private $aktiveNotenCache = null;
/**
* Object initialization
@@ -42,6 +44,7 @@ class Noten extends FHCAPI_Controller
'getCisConfig' => $permissions,
'getNoteByPunkte' => $permissions,
'getBenotungstoolContext' => $permissions,
'getLehrendeFuerLehreinheit' => $permissions,
'getLvForStudiengang' => $permissions
]);
@@ -98,7 +101,7 @@ class Noten extends FHCAPI_Controller
$this->terminateWithSuccess(
array(
// Punkte bei der Noteneingabe anzeigen
// show the points during the grade entry
'CIS_GESAMTNOTE_PUNKTE' => CIS_GESAMTNOTE_PUNKTE,
// basically on/of toggle for the points/grade col and the arrow button
@@ -107,11 +110,11 @@ class Noten extends FHCAPI_Controller
// only relevant in punkte calculation in backend
// 'CIS_GESAMTNOTE_GEWICHTUNG' => CIS_GESAMTNOTE_GEWICHTUNG,
// Maximale Anzahl zählender Antritte in diesem Tool. Serverseitig abgeleitet (explizite
// Konfiguration oder Alt-Flags TERMIN2/TERMIN3); der Client rechnet das nicht mehr nach.
// The maximum number of attempts that count in this tool. The server derives it from the
// configuration or from the old TERMIN2/TERMIN3 flags. The client does not calculate it.
'CIS_GESAMTNOTE_MAX_ANTRITTE' => $this->VerlaufLib->getMaxAntritte(),
// Vorgabe der Spaltenaufteilung ('antritt' | 'datum'), im Tool umschaltbar
// the default column layout ('antritt' or 'datum'); the user can change it in the tool
'CIS_GESAMTNOTE_PRUEFUNGSSPALTEN' => $this->config->item('CIS_GESAMTNOTE_PRUEFUNGSSPALTEN'),
// used to toggle availability of kommPruef type pruefungen
@@ -155,7 +158,7 @@ class Noten extends FHCAPI_Controller
* gets and returns exactly the initial dropdown data for it.
* A teacher (holds lehre/benotungstool) gets their assigned
* Lehrveranstaltungen directly. Assistenz (only lehre/benotungstool_assistenz) gets the
* Studiengänge they are entitled for and picks one before an LV is loaded.
* degree programmes they are entitled for, and picks one before a course is loaded.
*/
public function getBenotungstoolContext() {
$sem_kurzbz = $this->input->get("sem_kurzbz", TRUE);
@@ -165,7 +168,7 @@ class Noten extends FHCAPI_Controller
// teachers keep the classic assigned-LV flow; the Studiengang flow is only for Assistenz.
// Role determination mirrors assertLvAccess, which scopes each role's actual data
// access (teachers to their own LVs, Assistenzen to their entitled Studiengänge).
// access. A teacher sees the own courses, an assistant sees the entitled degree programmes.
$isLektor = $this->permissionlib->isBerechtigt('lehre/benotungstool');
$entitledStgs = $this->permissionlib->getSTG_isEntitledFor('lehre/benotungstool_assistenz');
$isAssistenz = !$isLektor && is_array($entitledStgs) && count($entitledStgs) > 0;
@@ -207,7 +210,7 @@ class Noten extends FHCAPI_Controller
* GET METHOD
* expects 'studiengang_kz', 'sem_kurzbz'
* Returns the Lehrveranstaltungen of a Studiengang in a Studiensemester for the Assistenz flow.
* The caller may only load Studiengänge they are entitled for via lehre/benotungstool_assistenz.
* The caller may load only the degree programmes of lehre/benotungstool_assistenz.
*/
public function getLvForStudiengang() {
$studiengang_kz = $this->input->get("studiengang_kz", TRUE);
@@ -281,7 +284,7 @@ class Noten extends FHCAPI_Controller
* returns List of all Students of given lehrveranstaltung and semester and fetches their grades.
* Loads LvGesamtnote aswell as Teilnoten from externalSources via getExternalGrades Event.
* Calculates the Notenvorschlag for every student based on averaging their Teilnoten.
* Finally also fetches all Prüfungen for every student which are linked to lva and semester.
* At the end it also fetches all exams of every student for that course and semester.
*/
public function getStudentenNoten() {
$lv_id = $this->input->get("lv_id",TRUE);
@@ -330,10 +333,10 @@ class Noten extends FHCAPI_Controller
foreach($student_uids as $uid) {
$grades[$uid]['grades'] = [];
$result = $this->LvgesamtnoteModel->getLvGesamtNoten($lv_id, $uid, $sem_kurzbz);
// $this->addMeta($uid.'getLvGesamtNoten', $result);
if(!isError($result) && hasData($result)) {
$lvgesamtnote = getData($result)[0];
// Read without the filter. getLvGesamtNoten() gives released grades only. With that getter
// the course grade and the release state are empty after a reload.
$lvgesamtnote = $this->getLvGesamtnoteRow($lv_id, $uid, $sem_kurzbz);
if($lvgesamtnote !== null) {
$grades[$uid]['note_lv'] = $lvgesamtnote->note;
$grades[$uid]['freigabedatum'] = $lvgesamtnote->freigabedatum;
$grades[$uid]['benotungsdatum'] = $lvgesamtnote->benotungsdatum;
@@ -367,7 +370,7 @@ class Noten extends FHCAPI_Controller
// assign the anw% to the students in the studentData loop
$anwresult = $this->getAnwesenheiten($prestudent_ids, $lv_id, $sem_kurzbz);
// calculate notenvorschläge from teilnoten
// calculate the grade proposals from the partial grades
foreach($studentenData as $student) {
// null when the Anwesenheiten addon is absent - the column stays empty in the UI
@@ -399,43 +402,37 @@ class Noten extends FHCAPI_Controller
}
}
if (CIS_GESAMTNOTE_PUNKTE) {
if (defined('CIS_GESAMTNOTE_GEWICHTUNG') && CIS_GESAMTNOTE_GEWICHTUNG) {
// Lehreinheitsgewichtung
$punkte_vorschlag = round($punktesumme_gewichtet / $gewichtsumme, 2);
// Without a partial grade that counts there is no average. Without this guard the
// division uses zero. PHP 7 gives INF, and INF gives the best grade. PHP 8 stops with
// a fatal error and the full grade table stays empty.
$gewichtet = defined('CIS_GESAMTNOTE_GEWICHTUNG') && CIS_GESAMTNOTE_GEWICHTUNG;
$divisor = $gewichtet ? $gewichtsumme : $anzahlnoten;
if ($divisor > 0) {
if (CIS_GESAMTNOTE_PUNKTE) {
$punkte_vorschlag = round(($gewichtet ? $punktesumme_gewichtet : $punktesumme) / $divisor, 2);
$note_vorschlag_result = $this->NotenschluesselaufteilungModel->getNote($punkte_vorschlag, $lv_id, $sem_kurzbz);
$note_vorschlag = $this->getDataOrTerminateWithError($note_vorschlag_result);
} else {
$punkte_vorschlag = round($punktesumme / $anzahlnoten, 2);
$note_vorschlag_result = $this->NotenschluesselaufteilungModel->getNote($punkte_vorschlag, $lv_id, $sem_kurzbz);
$note_vorschlag = $this->getDataOrTerminateWithError($note_vorschlag_result);
}
} else {
if (defined('CIS_GESAMTNOTE_GEWICHTUNG') && CIS_GESAMTNOTE_GEWICHTUNG) {
$note_vorschlag = round($notensumme_gewichtet / $gewichtsumme);
} else {
$note_vorschlag = round($notensumme / $anzahlnoten);
$note_vorschlag = round(($gewichtet ? $notensumme_gewichtet : $notensumme) / $divisor);
}
$student->note_vorschlag = $note_vorschlag;
}
$student->note_vorschlag = $note_vorschlag;
}
}
// get all prüfungen with noten held in that semester in that lva
// get all exams with grades of that semester and that course
$pruefungen = $this->LePruefungModel->getPruefungenByLvStudiensemester($lv_id, $sem_kurzbz);
$pruefungenData = getData($pruefungen);
// Prüfungsverlauf je Student serverseitig ableiten: Position, Antrittsnummer, ob ein Termin
// einen Antritt verbraucht und ob noch einer möglich ist. Der Client wertet das nur noch aus,
// damit die Regeln nicht in zwei Implementierungen auseinanderlaufen.
// the server derives the history for each student; the client only reads it
$proStudent = [];
foreach($pruefungenData ?: [] as $p) {
$proStudent[$p->student_uid][] = $p;
}
// Zeugnisnoten kommen bereits mit der Studentenliste (tbl_zeugnisnote.note) - keine
// Extra-Abfrage je Zeile nötig
// the transcript grades come with the student list (tbl_zeugnisnote.note)
$zeugnisnoten = [];
foreach($studentenData as $s) $zeugnisnoten[$s->uid] = $s->note;
@@ -573,14 +570,21 @@ class Noten extends FHCAPI_Controller
$res = $this->LvgesamtnoteModel->load($id->retval);
if(hasData($res)) {
$lvgesamtnote = getData($res)[0];
$ret[] = array('uid' => $note->uid, 'freigabedatum' => $lvgesamtnote->freigabedatum, 'benotungsdatum' => $lvgesamtnote->benotungsdatum);
// Mit der Freigabe wird die Note verbindlich - genau hier entsteht der
// erste Prüfungstermin. Das Anlegen einer Prüfung legt nie zusätzlich
// einen zweiten Termin an.
// The release makes the grade binding, therefore the first exam starts
// here. A new exam never creates a second exam in addition.
$this->upsertErstantritt(
$lv_id, $lvgesamtnote->student_uid, $sem_kurzbz,
$lvgesamtnote->note, $lvgesamtnote->punkte, $lvgesamtnote->freigabedatum
$lvgesamtnote->note, $lvgesamtnote->punkte, $lvgesamtnote->benotungsdatum
);
// The verlauf goes back with the answer, so the table shows the new exam
// at once. Without it the row updates only after a reload.
$ret[] = array(
'uid' => $note->uid,
'freigabedatum' => $lvgesamtnote->freigabedatum,
'benotungsdatum' => $lvgesamtnote->benotungsdatum,
'verlauf' => $this->buildVerlaufSummary($note->uid, $lv_id, $sem_kurzbz)
);
}
}
@@ -690,10 +694,9 @@ class Noten extends FHCAPI_Controller
* POST METHOD
* expects 'datum', 'lva_id', 'student_uid', 'note'
* Inserts or updates a pruefung for lva & student_uid at given datum (YYYY-MM-DD). When creating a new
* Pruefung, sets the provided (Prüfungs-) Note.
* Updates the LvGesamtnote of student (niemals die Zeugnisnote - die Übernahme passiert in der STV).
* Can return 1 or 2 Prüfungen: fehlt der erste Antritt als eigener Datensatz (Altdaten), wird er
* beim Anlegen einer Wiederholung aus der ursprünglichen LV-Note nachgetragen.
* exam, and sets the grade of that exam.
* Updates the course grade of the student. It never writes the transcript grade, the student
* administration does that.
*/
public function saveStudentPruefung() { // einzelne pruefung speichern
$result = $this->getPostJSON();
@@ -716,13 +719,13 @@ class Noten extends FHCAPI_Controller
$this->assertLvAccess($lva_id, $stsem);
// Prüfungsordnung §1: no entry/change past the Noteneintragungsfrist
// examination rules: no entry and no change after the grade entry deadline
$this->enforceNoteneintragungsfrist($stsem);
$jetzt = date("Y-m-d H:i:s");
if(CIS_GESAMTNOTE_PUNKTE && isset($punkte) && $punkte >= 0) {
// Bei Punkteeingabe wird die Note nochmals geprueft und ggf korrigiert
// with a points entry the server checks the grade again and corrects it
$resultNote = $this->NotenschluesselaufteilungModel->getNote($punkte, $lva_id, $stsem);
if(isError($resultNote)) {
$this->terminateWithError(getError($resultNote));
@@ -743,47 +746,44 @@ class Noten extends FHCAPI_Controller
$note = getData($result)[0]->note;
}
$result = $this->savePruefungFuerStudent($pruefung_id, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum);
// the dialog sends the teacher when the Lehreinheit has more than one
$mitarbeiter_uid = property_exists($result, 'mitarbeiter_uid') ? $result->mitarbeiter_uid : null;
// Validierungs- und Schreibfehler kommen als lokalisierte Meldung zurück
$result = $this->savePruefungFuerStudent($pruefung_id, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum, $mitarbeiter_uid);
// validation errors and write errors come back as a translated message
if(is_string($result)) $this->terminateWithError($result, 'general');
$savedPruefung = $result['savedPruefung'] ?? [];
$savedPruefungData = count($savedPruefung) > 0 ? $savedPruefung[0] : null;
$lvgesamtnote = $result['lvgesamtnote'] ?? null;
// eine Aktion = ein Termin, daher kein zusätzlicher Datensatz mehr in der Antwort
$this->terminateWithSuccess(array($savedPruefungData, $lvgesamtnote, $result['verlauf'] ?? null));
}
/**
* Kern einer Prüfungseingabe für EINEN Studenten: prüfen, LV-Note schreiben, dann den Termin.
* The core of one exam entry for ONE student: validate, write the course grade, write the exam.
* The dialog and the bulk endpoints both use this method. The transcript grade stays unchanged.
*
* Einzige Stelle, an der das passiert - der Dialog aus der Tabelle (saveStudentPruefung) und die
* Bulk-Endpunkte verwenden sie gleichermassen. Beide Wege tun damit exakt dasselbe, der Bulk-Weg
* nur für mehrere Studenten auf einmal. Die Zeugnisnote bleibt unberührt, deren Übernahme
* passiert in der Studierendenverwaltung.
*
* @return array|string ['savedPruefung' => [...], 'lvgesamtnote' => stdClass, 'verlauf' => [...]]
* oder eine lokalisierte Fehlermeldung
* @return array|string ['savedPruefung', 'lvgesamtnote', 'verlauf'] or an error message
*/
private function savePruefungFuerStudent($pruefung_id, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum)
private function savePruefungFuerStudent($pruefung_id, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum, $mitarbeiter_uid = null)
{
// validate the edit before any write: the date must stay between the neighbouring exams and,
// once a later pruefung exists, the grade may no longer be changed (only the date).
// only applies when editing an existing record ($pruefung_id set)
// §7 and §11: every exam must take place before the grade entry deadline. The kommissionelle
// Prüfung is the important case, because it decides if the student loses a semester.
$fristError = $this->pruefungsdatumNachFrist($stsem, $datum, $student_uid);
if($fristError !== null) return $fristError;
// validate before any write
$editError = $this->validatePruefungEdit($student_uid, $lva_id, $stsem, $note, $datum, $pruefung_id);
if($editError !== null) return $editError;
// for a NEW attempt (no pruefung_id) enforce the add rules server-side
// maxAntritte calc, chronological order, occurrence limit for entschuldigt
if($pruefung_id === null || $pruefung_id === '') {
$addError = $this->validatePruefungAdd($student_uid, $lva_id, $stsem, $note, $datum);
if($addError !== null) return $addError;
}
// fehlt die lehreinheit_id (der Client liefert sie nicht immer), serverseitig auflösen -
// sonst scheitert das Insert stillschweigend am NOT NULL
// the client does not always send it; without it the insert fails on the NOT NULL column
if(!$lehreinheit_id) {
$resLe = $this->LehrveranstaltungModel->getLeByStudent($student_uid, $stsem, $lva_id);
if(!isError($resLe) && hasData($resLe)) $lehreinheit_id = current(getData($resLe))->lehreinheit_id;
@@ -791,17 +791,16 @@ class Noten extends FHCAPI_Controller
$jetzt = date("Y-m-d H:i:s");
// ungefiltert: eine noch nicht freigegebene LV-Note existiert bereits und muss aktualisiert
// statt neu angelegt werden (sonst Verstoss gegen den Primärschlüssel)
// Read without the filter. A grade that is not released still exists. You must update it.
// A new insert breaks the primary key.
$bestehendeLvNote = $this->getLvGesamtnoteRow($lva_id, $student_uid, $stsem);
// ursprüngliche Note festhalten, bevor sie überschrieben wird: sie entscheidet, ob die
// LV-Note als impliziter erster Antritt zählt
// this decides if the course grade is the implicit first attempt; read it before the update
$origLvNote = $bestehendeLvNote ? $bestehendeLvNote->note : null;
$lvgesamtnote = $bestehendeLvNote;
if($bestehendeLvNote === null) {
$lvgesamtnote = $this->createLvGesamtnote($lva_id, $student_uid, $stsem, $note, $punkte);
$lvgesamtnote = $this->createLvGesamtnote($lva_id, $student_uid, $stsem, $note, $punkte, $lehreinheit_id, $mitarbeiter_uid);
$this->logLib->logInfoDB(array('pruefung: lvnote angelegt', $student_uid, $lva_id, $stsem,
$note, $punkte, getAuthUID(), getAuthPersonId()));
@@ -827,7 +826,7 @@ class Noten extends FHCAPI_Controller
}
// save pruefung after updating lvnote, since pruefungspunkte get loaded by lv punkte
$pruefungenChanged = $this->savePruefungstermin($pruefung_id, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum, $origLvNote);
$pruefungenChanged = $this->savePruefungstermin($pruefung_id, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum, $origLvNote, $mitarbeiter_uid);
if(is_string($pruefungenChanged)) return $pruefungenChanged;
$pruefungenChanged['lvgesamtnote'] = $lvgesamtnote;
@@ -836,20 +835,15 @@ class Noten extends FHCAPI_Controller
}
/**
* Legt einen Prüfungstermin an oder aktualisiert einen bestehenden.
* Creates an exam or updates an exam. The exam type has no meaning here: the position comes
* from the history. The code writes pruefungstyp_kurzbz, but it never reads it.
*
* Termintyp-agnostisch: die Position eines Antritts ergibt sich aus dem Verlauf (Datum bzw.
* Reihenfolge), nicht aus pruefungstyp_kurzbz. Der Alt-Typ wird beim Schreiben nur noch als
* abgeleitete Rückwärtskompatibilitäts-Projektion mitgeschrieben (siehe PruefungsverlaufLib),
* damit bestehende Auswertungen weiterlaufen; zurückgelesen wird er nie.
*
* @param int $pruefung_id gesetzt = genau diesen Datensatz bearbeiten, null = neuer Antritt
* @return array|string geänderte Prüfungen oder eine lokalisierte Fehlermeldung
* @param int $pruefung_id gesetzt = diesen Datensatz bearbeiten, null = neuer Antritt
* @return array|string the changed exams or a translated error message
*/
private function savePruefungstermin($pruefung_id, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum, $origLvNote = null)
private function savePruefungstermin($pruefung_id, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum, $origLvNote = null, $mitarbeiter_uid = null)
{
// allocating pruefungen before lv note is forbidden (ungefiltert: auch eine noch nicht
// freigegebene LV-Note zählt als vorhanden, siehe getLvGesamtnoteRow)
// no exam without a course grade (a grade that is not released also counts)
if($this->getLvGesamtnoteRow($lva_id, $student_uid, $stsem) === null) {
return $this->p->t('benotungstool', 'c4keineLvNoteEingetragen');
}
@@ -874,8 +868,7 @@ class Noten extends FHCAPI_Controller
if(count($status) > 0 && $status[0] == true) {
$entschuldigtNote = $this->VerlaufLib->getSpecialNotes()['entschuldigt'];
// the occurrence limit was validated on the PRE-override note, so re-check it here: only
// apply the auto-excuse if it would not exceed the allowed number of excused Termine
// the limit was checked on the grade BEFORE the override, therefore check it again
if($entschuldigtNote !== null
&& !$this->VerlaufLib->ueberschreitetNotenLimit($pruefungen, $entschuldigtNote, $pruefung_id)) {
$note = $entschuldigtNote;
@@ -886,8 +879,7 @@ class Noten extends FHCAPI_Controller
$pruefungenChanged = [];
// Bearbeitung: der adressierte Datensatz wird geändert, seine Position im Verlauf ergibt
// sich danach wieder aus dem Datum. Kein Typwechsel, keine Neuanlage.
// edit: only the addressed record, no type change and no new record
if($pruefung_id !== null && $pruefung_id !== '') {
$id = $this->LePruefungModel->update(
$pruefung_id,
@@ -918,9 +910,7 @@ class Noten extends FHCAPI_Controller
$verlauf = $this->VerlaufLib->buildVerlauf($pruefungen, $origLvNote);
$rolle = $verlauf->naechsteRolle;
// Eine Aktion legt genau EINEN Termin an. Der erste Antritt entsteht nicht beiläufig hier,
// sondern bei der Notenfreigabe (upsertErstantritt) - dort wird die Note verbindlich.
// one action makes one exam; the release creates the first attempt (upsertErstantritt)
$typ = ($rolle === PruefungsverlaufLib::ROLLE_ERSTANTRITT)
? $this->VerlaufLib->legacyTypFuerAntritt(1)
: $this->VerlaufLib->legacyTypFuerWiederholung($verlauf);
@@ -929,7 +919,7 @@ class Noten extends FHCAPI_Controller
array(
'lehreinheit_id' => $lehreinheit_id,
'student_uid' => $student_uid,
'mitarbeiter_uid' => getAuthUID(),
'mitarbeiter_uid' => $this->benotenderMitarbeiter($lehreinheit_id, $mitarbeiter_uid),
'note' => $note,
'punkte' => $punkte,
'pruefungstyp_kurzbz' => $typ,
@@ -949,8 +939,8 @@ class Noten extends FHCAPI_Controller
$this->logLib->logInfoDB(array('pruefung inserted ('.$rolle.')', $res, getAuthUID(), getAuthPersonId()));
// Schlägt das Insert fehl (zB fehlende lehreinheit_id), darf hier KEIN Erfolg zurückkommen:
// der Client wertet die Antwort sonst als angelegt und zeigt einen leeren Verlauf an.
// savedPruefung is the proof of success. Without this guard a failed insert (for example
// a missing lehreinheit_id) tells the client that the write was successful.
if(!isset($pruefungenChanged['savedPruefung'])) {
return $this->p->t('benotungstool', 'c4pruefungNichtGespeichert', [$student_uid]);
}
@@ -959,11 +949,7 @@ class Noten extends FHCAPI_Controller
return $pruefungenChanged;
}
/**
* Verlaufskennzahlen und die abgeleiteten Termine eines Studenten für den Client. Wird nach
* jeder Änderung mitgeliefert, damit die Oberfläche die Regeln nicht selbst nachrechnet und
* ihre Zeilen einfach aus dem Serverstand neu aufbauen kann.
*/
/** The history for the client. Each write answer contains it, so the client calculates nothing. */
private function buildVerlaufSummary($student_uid, $lva_id, $stsem)
{
$lvNote = $this->getLvGesamtnoteRow($lva_id, $student_uid, $stsem);
@@ -977,7 +963,7 @@ class Noten extends FHCAPI_Controller
}
/**
* @param bool $withPruefungen Termine mitliefern (Schreibpfade) oder nur die Kennzahlen
* @param bool $withPruefungen add the exams (for the write paths) or send the counters only
* @return array
*/
private function verlaufSummary($verlauf, $withPruefungen = false, $hatLvNote = null)
@@ -989,12 +975,9 @@ class Noten extends FHCAPI_Controller
'terminal' => $verlauf->terminal,
'erstantrittMoeglich' => $verlauf->erstantrittMoeglich,
'naechsteRolle' => $verlauf->naechsteRolle,
// Zeugnisnote ist eine Anrechnung -> Zeile wird angezeigt, ist aber nicht auswählbar und
// bekommt keine Prüfungen
// credited: the row is visible, but you cannot select it and it gets no exams
'angerechnet' => $verlauf->angerechnet,
// ob überhaupt eine LV-Note existiert - ungefiltert, also inklusive noch nicht
// freigegebener. Die Oberfläche kennt über lv_note nur die freigegebene.
'hatLvNote' => $hatLvNote
'hatLvNote' => $hatLvNote // ungefiltert, also inklusive noch nicht freigegebener
);
if($withPruefungen) $summary['pruefungen'] = $verlauf->pruefungen;
@@ -1003,32 +986,15 @@ class Noten extends FHCAPI_Controller
}
/**
* Validates an edit to an existing pruefung. Returns a localized error string if the edit is
* not allowed, or null if it is. Mirrors the frontend guards so a disallowed edit cannot be
* forced through the API.
* Guards an EDIT ($pruefung_id is set; if not, validatePruefungAdd applies). The date must stay
* between the two adjacent exams. An exam with a later date locks the grade.
*
* Rules:
* - The new $datum must stay strictly between the dates of the chronologically adjacent
* pruefungen so the attempt order is preserved.
* - Once a later attempt exists the grade may no longer be changed (only the datum may be
* corrected within the bounds above).
*
* Only guards EDITS: $pruefung_id identifies the record being edited; when it is null a new
* attempt is being added and validatePruefungAdd applies instead.
*
* @param string $student_uid
* @param int $lva_id
* @param string $stsem
* @param int $newNote the (already resolved) note being saved
* @param string $newDatum the datum being saved (Y-m-d)
* @param int $pruefung_id pk of the record being edited, or null for an add
* @return string|null
* @return string|null lokalisierte Fehlermeldung oder null
*/
private function validatePruefungEdit($student_uid, $lva_id, $stsem, $newNote, $newDatum, $pruefung_id)
{
if($pruefung_id === null || $pruefung_id === '') return null; // add, not an edit
// Anrechnung: die Leistung wurde vorab anerkannt, an bestehenden Terminen wird nichts geändert
$zeugnisNote = $this->getZeugnisnote($lva_id, $student_uid, $stsem);
if($this->VerlaufLib->istAnrechnungsnote($zeugnisNote)) {
return $this->p->t('benotungstool', 'c4angerechnetKeinePruefung', [$student_uid]);
@@ -1046,13 +1012,14 @@ class Noten extends FHCAPI_Controller
}
if($current === null) return null;
// abschliessende Termine (kommPruef) werden in einem anderen Tool gepflegt
if($current->terminal) return null;
// The student administration owns the exams that use no attempt (zusKommPruef), so this tool
// does not validate them. The kommissionelle Prüfung itself is a normal attempt now.
if(in_array($current->pruefungstyp_kurzbz, $this->VerlaufLib->getTypenOhneAntritt())) return null;
$currentDate = substr((string)$current->datum, 0, 10);
$new = substr((string)$newDatum, 0, 10);
// chronologische Grenzen aus den unmittelbaren Datums-Nachbarn
// the limits come from the two exams before and after this one
$lower = null; $upper = null;
foreach($verlauf->pruefungen as $p) {
if($p->pruefung_id == $current->pruefung_id) continue;
@@ -1083,25 +1050,13 @@ class Noten extends FHCAPI_Controller
}
/**
* Validates ADDING a new pruefung (attempt). Returns an error string if the add is not
* allowed, or null if it is.
* Guards a NEW attempt. Rule A is the attempt limit, and nothing follows a final exam. Rule B
* is the chronological order. Rule C is the occurrence limit of a grade.
*
* Rules (Prüfungsordnung §1):
* - Rule A: die Zahl der zählenden Prüfungsantritte darf das Maximum nicht überschreiten, und
* nach einem abschliessenden Termin ist kein weiterer Antritt mehr möglich
* - Rule B: attempts are taken in chronological order
* - Rule C: a note carrying an occurrence limit (entschuldigt) may not exceed it.
*
* @param string $student_uid
* @param int $lva_id
* @param string $stsem
* @param int $note the (already resolved) note being saved
* @param string $datum the datum being saved (Y-m-d)
* @return string|null
* @return string|null lokalisierte Fehlermeldung oder null
*/
private function validatePruefungAdd($student_uid, $lva_id, $stsem, $note, $datum)
{
// Anrechnung: wer die Lehrveranstaltung angerechnet bekommen hat, tritt zu keiner Prüfung an
$zeugnisNote = $this->getZeugnisnote($lva_id, $student_uid, $stsem);
if($this->VerlaufLib->istAnrechnungsnote($zeugnisNote)) {
return $this->p->t('benotungstool', 'c4angerechnetKeinePruefung', [$student_uid]);
@@ -1109,19 +1064,18 @@ class Noten extends FHCAPI_Controller
$pruefungen = $this->VerlaufLib->getPruefungen($student_uid, $lva_id, $stsem);
// current LV note (the implicit first Antritt), read before it gets mutated by the caller
// the implicit first attempt; read it before the caller changes it
$lvRow = $this->getLvGesamtnoteRow($lva_id, $student_uid, $stsem);
$lvNote = $lvRow ? $lvRow->note : null;
$verlauf = $this->VerlaufLib->buildVerlauf($pruefungen, $lvNote, $zeugnisNote);
// Rule A: max Antritte. Der erste Antritt materialisiert nur die ohnehin als Antritt gezählte
// LV-Note und erhöht die Zahl der Antritte daher nicht.
// A: the first attempt only materialises the course grade, therefore it adds no attempt
if($verlauf->naechsteRolle !== PruefungsverlaufLib::ROLLE_ERSTANTRITT && !$verlauf->canAdd) {
return $this->p->t('benotungstool', 'maxAntritteReached', [$student_uid, $verlauf->maxAntritte]);
}
// Rule B: no new attempt on/before an existing dated attempt
// B: no attempt on the same day as an existing exam, and no attempt before it
$newDate = substr((string)$datum, 0, 10);
foreach($pruefungen as $p) {
$d = substr((string)$p->datum, 0, 10);
@@ -1130,7 +1084,7 @@ class Noten extends FHCAPI_Controller
}
}
// Rule C: occurrence limit (e.g. only one 'entschuldigt' across the Antritte)
// C: the occurrence limit, for example one 'entschuldigt' only
if($this->VerlaufLib->ueberschreitetNotenLimit($pruefungen, $note, null)) {
return $this->p->t('benotungstool', 'noteOccuranceLimitReached', [$student_uid]);
}
@@ -1139,10 +1093,78 @@ class Noten extends FHCAPI_Controller
}
/**
* Enforces the Noteneintragungsfrist (Prüfungsordnung §1) for grade/pruefung entry. Terminates the
* request with an error when the deadline for the given studiensemester has passed. Does nothing
* when the window enforcement is disabled or the deadline cannot be determined.
* The active grades, keyed by their PK. getNoten() sends the client the same set, so both sides
* judge a grade by the same flags.
*
* @return array note (string) => zeile aus lehre.tbl_note
*/
private function aktiveNoten()
{
if($this->aktiveNotenCache !== null) return $this->aktiveNotenCache;
$this->aktiveNotenCache = array();
$result = $this->NoteModel->getAllActive();
if(!isError($result) && hasData($result)) {
foreach(getData($result) as $n) $this->aktiveNotenCache[(string)$n->note] = $n;
}
return $this->aktiveNotenCache;
}
/**
* Guards a DIRECT api write of the course grade (saveNotenvorschlag and its bulk variant). All
* three rules are also in the cell editor, but the API and the CSV import reach the server
* without it. The order follows the editor: grade, exam, transcript.
*
* @return string|null lokalisierte Fehlermeldung oder null
*/
private function validateNotenvorschlag($lva_id, $student_uid, $stsem, $note)
{
$noten = $this->aktiveNoten();
$wert = trim((string)$note);
// the editor offers the lehre grades only; an administrative grade belongs to the transcript
if(!isset($noten[$wert]) || !$noten[$wert]->lehre) {
return $this->p->t('benotungstool', 'c4noteNichtInLehre', [$student_uid]);
}
// as soon as an exam exists the grade belongs to the attempt history and follows its rules
if(count($this->VerlaufLib->getPruefungen($student_uid, $lva_id, $stsem)) > 0) {
return $this->p->t('benotungstool', 'c4notenvorschlagGesperrt', [$student_uid]);
}
// a transcript grade can forbid the teacher to overwrite it, for example 'intern angerechnet'.
// An unknown or inactive grade locks nothing, which is what the editor does as well.
$zeugnisnote = trim((string)$this->getZeugnisnote($lva_id, $student_uid, $stsem));
if(isset($noten[$zeugnisnote]) && !$noten[$zeugnisnote]->lkt_ueberschreibbar) {
return $this->p->t('benotungstool', 'c4zeugnisnoteGesperrt', [$student_uid]);
}
return null;
}
/**
* Checks the DATE of the exam against the deadline. enforceNoteneintragungsfrist compares the
* current time, which is a different question: a user can enter an exam today and give it a
* date after the deadline.
*
* @return string|null a translated error message, or null if the date is permitted
*/
private function pruefungsdatumNachFrist($sem_kurzbz, $datum, $student_uid)
{
if(!$this->config->item('CIS_GESAMTNOTE_NOTENEINTRAGUNGSFRIST')) return null;
$deadline = $this->computeNoteneintragungsfrist($sem_kurzbz);
if($deadline === null) return null;
$tag = substr((string) $datum, 0, 10);
if($tag === '' || $tag <= $deadline->format('Y-m-d')) return null;
return $this->p->t('benotungstool', 'pruefungsdatumNachFrist', [$student_uid, $deadline->format('d.m.Y')]);
}
/** Stops the request if the grade entry deadline of the semester has passed. */
private function enforceNoteneintragungsfrist($sem_kurzbz)
{
if(!$this->config->item('CIS_GESAMTNOTE_NOTENEINTRAGUNGSFRIST')) return;
@@ -1158,11 +1180,7 @@ class Noten extends FHCAPI_Controller
}
}
/**
* Computes the Noteneintragungsfrist deadline (end of day) for a studiensemester_kurzbz of the
* form '{SS|WS}yyyy'. SS -> deadline in the same calendar year, WS -> in the following year. The
* month/day come from the noten config. Returns a DateTime, or null when it can't be determined.
*/
/** The deadline from '{SS|WS}yyyy': SS in the same year, WS in the next year. @return DateTime|null */
private function computeNoteneintragungsfrist($sem_kurzbz)
{
if(!is_string($sem_kurzbz) || strlen($sem_kurzbz) < 6) return null;
@@ -1212,9 +1230,21 @@ class Noten extends FHCAPI_Controller
$this->assertLvAccess($lv_id, $sem_kurzbz);
// Prüfungsordnung §1: no entry/change past the Noteneintragungsfrist
// examination rules: no entry and no change after the grade entry deadline
$this->enforceNoteneintragungsfrist($sem_kurzbz);
// In the points mode the grading scale decides, not the grade from the client. If not, you
// get a course grade that contradicts its own points. Without points there is nothing to
// derive (for example a proposal from Moodle partial grades), then the given grade applies.
if(CIS_GESAMTNOTE_PUNKTE && $punkte !== null && $punkte !== '') {
$abgeleitet = $this->noteAusPunkten($punkte, $lv_id, $sem_kurzbz, $student_uid);
if(is_string($abgeleitet)) $this->terminateWithError($abgeleitet, 'general');
$note = $abgeleitet;
}
$fehler = $this->validateNotenvorschlag($lv_id, $student_uid, $sem_kurzbz, $note);
if($fehler !== null) $this->terminateWithError($fehler, 'general');
$result = $this->LvgesamtnoteModel->getLvGesamtNoteVorschlag($lv_id, $student_uid, $sem_kurzbz);
// $this->addMeta('LvgesamtnoteModelresult', $result);
@@ -1248,7 +1278,7 @@ class Noten extends FHCAPI_Controller
'studiensemester_kurzbz' => $sem_kurzbz,
'note' => $note,
'punkte' => $punkte,
'mitarbeiter_uid' => getAuthUID(),
'mitarbeiter_uid' => $this->benotenderMitarbeiterFuerStudent($lv_id, $student_uid, $sem_kurzbz),
'benotungsdatum' => date("Y-m-d H:i:s"),
'freigabedatum' => null,
'freigabevon_uid' => null,
@@ -1274,6 +1304,7 @@ class Noten extends FHCAPI_Controller
* POST METHOD
* expects 'sem_kurzbz', 'lv_id', 'noten'
* Bulk variant of saveNotenvorschlag, used when importing grades from csv.
* The answer uses the uid as the key. Each row holds the course grade or an error message.
*/
public function saveNotenvorschlagBulk() {
$result = $this->getPostJSON();
@@ -1289,7 +1320,7 @@ class Noten extends FHCAPI_Controller
$this->assertLvAccess($lv_id, $sem_kurzbz);
// Prüfungsordnung §1: no entry/change past the Noteneintragungsfrist
// examination rules: no entry and no change after the grade entry deadline
$this->enforceNoteneintragungsfrist($sem_kurzbz);
$retLvNoten = [];
@@ -1301,11 +1332,16 @@ class Noten extends FHCAPI_Controller
// $this->addMeta($note->uid.'$result', $result);
if(CIS_GESAMTNOTE_PUNKTE) {
$resultNote = $this->NotenschluesselaufteilungModel->getNote($note->punkte, $lv_id, $sem_kurzbz);
$note->note = $this->getDataOrTerminateWithError($resultNote);
// $this->addMeta($note->uid.'note', $note);
$abgeleitet = $this->noteAusPunkten($note->punkte, $lv_id, $sem_kurzbz, $note->uid);
// no grade can be derived: skip the row, but do not stop the full request
if(is_string($abgeleitet)) { $retLvNoten[$note->uid] = $abgeleitet; continue; }
$note->note = $abgeleitet;
}
// one bad row must not stop the import, so the message goes into this row
$fehler = $this->validateNotenvorschlag($lv_id, $note->uid, $sem_kurzbz, $note->note);
if($fehler !== null) { $retLvNoten[$note->uid] = $fehler; continue; }
if(!isError($result) && hasData($result)) {
$lvgesamtnote = getData($result)[0];
@@ -1335,7 +1371,7 @@ class Noten extends FHCAPI_Controller
'studiensemester_kurzbz' => $sem_kurzbz,
'note' => trim($note->note),
'punkte' => $note->punkte,
'mitarbeiter_uid' => getAuthUID(),
'mitarbeiter_uid' => $this->benotenderMitarbeiterFuerStudent($lv_id, $note->uid, $sem_kurzbz),
'benotungsdatum' => date("Y-m-d H:i:s"),
'freigabedatum' => null,
'freigabevon_uid' => null,
@@ -1354,7 +1390,7 @@ class Noten extends FHCAPI_Controller
$this->logLib->logInfoDB(array('saveNotenvorschlagBulk insert lv gesamtnote',$res, getAuthUID(), getAuthPersonId()));
}
$retLvNoten[] = $lvgesamtnote;
$retLvNoten[$note->uid] = $lvgesamtnote;
}
$this->terminateWithSuccess($retLvNoten);
@@ -1363,8 +1399,8 @@ class Noten extends FHCAPI_Controller
/**
* POST METHOD
* expects 'uids', 'datum', optional 'note'/'punkte'
* Bulk variant of saveStudentPruefung, used when creating a new Prüfung for several students.
* Ohne ausgewählte Note wird "noch nicht eingetragen" gesetzt.
* The bulk variant of saveStudentPruefung. It creates one exam for several students.
* Without a selected grade it writes "noch nicht eingetragen".
*/
public function createPruefungen() {
$payload = $this->getPostJSON();
@@ -1381,7 +1417,7 @@ class Noten extends FHCAPI_Controller
$this->assertLvAccess($lva_id, $stsem);
// Prüfungsordnung §1: no entry past the Noteneintragungsfrist
// examination rules: no entry after the grade entry deadline
$this->enforceNoteneintragungsfrist($stsem);
$ret = [];
@@ -1389,24 +1425,27 @@ class Noten extends FHCAPI_Controller
$note = property_exists($payload, 'note') ? $payload->note : null;
$punkte = property_exists($payload, 'punkte') ? $payload->punkte : null;
// Punkteeingabe hat Vorrang: die Note kommt dann aus dem Notenschlüssel
// the points win: the grade then comes from the grading scale
if(CIS_GESAMTNOTE_PUNKTE && $punkte !== null && $punkte !== '' && $punkte >= 0) {
$resNote = $this->NotenschluesselaufteilungModel->getNote($punkte, $lva_id, $stsem);
$note = $this->getDataOrTerminateWithError($resNote);
}
// ohne Auswahl bleibt der Termin unbenotet
// without a selection the exam has no grade
if($note === null || $note === '') {
$resNote = $this->NoteModel->getNochNichtEingetragenNote();
$note = getData($resNote)[0]->note;
$punkte = null;
}
// identisch zum Dialog aus der Tabelle, nur für mehrere Studenten: derselbe Kern schreibt
// LV-Note und Termin. Fehler kommen je Zeile als lokalisierte Meldung zurück.
// the dialog sends the teacher when all selected students share one Lehreinheit
$mitarbeiter_uid = property_exists($payload, 'mitarbeiter_uid') ? $payload->mitarbeiter_uid : null;
// the same core as the dialog; each row gets its own error message
foreach ($uids as $student) {
$ret[$student->uid] = $this->savePruefungFuerStudent(
null, $student->uid, $lva_id, $stsem, $student->lehreinheit_id, $note, $punkte, $datum
null, $student->uid, $lva_id, $stsem, $student->lehreinheit_id, $note, $punkte, $datum,
$mitarbeiter_uid
);
}
@@ -1434,24 +1473,25 @@ class Noten extends FHCAPI_Controller
$this->assertLvAccess($lv_id, $sem_kurzbz);
// Prüfungsordnung §1: no entry past the Noteneintragungsfrist
// examination rules: no entry after the grade entry deadline
$this->enforceNoteneintragungsfrist($sem_kurzbz);
$ret = [];
foreach ($pruefungen as $pruefung) {
if(CIS_GESAMTNOTE_PUNKTE) {
$result = $this->NotenschluesselaufteilungModel->getNote($pruefung->punkte, $lv_id, $sem_kurzbz);
// $this->addMeta($pruefung->uid."result", $result);
$pruefung->note = $this->getDataOrTerminateWithError($result);
// $this->addMeta($pruefung->uid."note", $pruefung->note);
$note = $this->noteAusPunkten($pruefung->punkte, $lv_id, $sem_kurzbz, $pruefung->uid);
// no grade can be derived: skip the row, but do not stop the full request
if(is_string($note)) { $ret[$pruefung->uid] = $note; continue; }
$pruefung->note = $note;
}
// identisch zum Dialog aus der Tabelle, nur je Importzeile
// the same as the dialog in the table, but for each import row
$ret[$pruefung->uid] = $this->savePruefungFuerStudent(
null, $pruefung->uid, $lv_id, $sem_kurzbz, $pruefung->lehreinheit_id,
$pruefung->note, $pruefung->punkte, $pruefung->datum
$pruefung->note, $pruefung->punkte, $pruefung->datum,
property_exists($pruefung, 'mitarbeiter_uid') ? $pruefung->mitarbeiter_uid : null
);
}
@@ -1461,23 +1501,35 @@ class Noten extends FHCAPI_Controller
}
/**
* Aktuelle LV-Gesamtnote eines Studenten oder null. In den Bulk-Pfaden ist sie zugleich die
* ursprüngliche Note, weil dort - anders als in saveStudentPruefung - vorher nichts
* überschrieben wird.
* Creates or updates the first attempt. The release calls it.
*
* The date is the benotungsdatum of the course grade, which is the moment the teacher recorded
* the assessment. The examination rules ask for the date of the last performance (§8), but the
* system does not hold that date, because many courses have no exam date at all. The old tool
* used the benotungsdatum for the same row, so the value stays comparable with the old data.
*
* It applies only while one exam or no exam exists. After that the course grade holds the last
* grade, and it must not overwrite attempt 1. A credited grade changes nothing.
*/
/**
* Legt den ersten Prüfungstermin an oder aktualisiert ihn (Upsert).
* The grade from the points for ONE bulk row. It gives the grade, or a translated error message
* if no grade can be derived. The caller then skips the row and continues with the other rows.
*
* Aufgerufen bei der Notenfreigabe: erst dort wird die Note verbindlich, deshalb entsteht genau
* dann der erste Antritt - und nicht beiläufig beim Anlegen einer Prüfung. Datiert auf das
* Freigabedatum.
*
* Greift nur, solange höchstens ein Termin existiert: sobald Wiederholungen erfasst sind, führt
* die LV-Note die zuletzt erreichte Note und dürfte den ersten Antritt nicht mehr überschreiben.
* Bei einer Anrechnung passiert nichts.
*
* @return void
* @return mixed|string
*/
private function noteAusPunkten($punkte, $lv_id, $sem_kurzbz, $uid)
{
$result = $this->NotenschluesselaufteilungModel->getNote($punkte, $lv_id, $sem_kurzbz);
if(isError($result)) return getError($result);
$note = getData($result);
if($note === null || $note === '') {
return $this->p->t('benotungstool', 'c4punkteKeineNoteErmittelt', [$uid]);
}
return $note;
}
private function upsertErstantritt($lva_id, $student_uid, $stsem, $note, $punkte, $datum)
{
if($this->VerlaufLib->istAnrechnungsnote($this->getZeugnisnote($lva_id, $student_uid, $stsem))) return;
@@ -1503,7 +1555,7 @@ class Noten extends FHCAPI_Controller
return;
}
// lehreinheit_id serverseitig auflösen statt sie vom Client zu übernehmen
// the server finds the lehreinheit_id; it does not use the value from the client
$resLe = $this->LehrveranstaltungModel->getLeByStudent($student_uid, $stsem, $lva_id);
if(isError($resLe) || !hasData($resLe)) return;
$le = current(getData($resLe));
@@ -1512,7 +1564,7 @@ class Noten extends FHCAPI_Controller
array(
'lehreinheit_id' => $le->lehreinheit_id,
'student_uid' => $student_uid,
'mitarbeiter_uid' => getAuthUID(),
'mitarbeiter_uid' => $this->benotenderMitarbeiter($le->lehreinheit_id),
'note' => $note,
'punkte' => $punkte,
'pruefungstyp_kurzbz' => $this->VerlaufLib->legacyTypFuerAntritt(1),
@@ -1530,11 +1582,70 @@ class Noten extends FHCAPI_Controller
}
/**
* Zeugnisnote eines Studenten in einer LV, oder null. Anrechnungen werden dort geführt (nicht in
* der LV-Note) und entscheiden darüber, ob überhaupt Prüfungen möglich sind.
* The teachers of one Lehreinheit, from lehre.tbl_lehreinheitmitarbeiter.
*
* @return mixed|null
* @return array
*/
private function lehrendeDerLehreinheit($lehreinheit_id)
{
if(!$lehreinheit_id) return array();
$this->load->model('education/Lehreinheitmitarbeiter_model', 'LehreinheitmitarbeiterModel');
$result = $this->LehreinheitmitarbeiterModel->getLektorenByLe($lehreinheit_id);
if(isError($result) || !hasData($result)) return array();
$lehrende = getData($result);
usort($lehrende, function($a, $b) {
return strcmp($a->mitarbeiter_uid, $b->mitarbeiter_uid);
});
return $lehrende;
}
/**
* The uid of the person who gave the grade. It goes into lehre.tbl_pruefung and into
* campus.tbl_lvgesamtnote.
*
* Historically only teachers used this tool, so the caller was always right. An assistant may
* do the data entry too, but the grade still comes from the teacher. The row must therefore
* carry the teacher of the Lehreinheit, not the person who types. insertvon and updatevon keep
* the caller, because they answer a different question.
*
* Order: a valid selection wins, then the caller if the caller teaches this Lehreinheit, then
* the only teacher. With several teachers and no selection the first one wins, because an
* assistant is never the correct answer. Without any teacher the caller stays.
*
* @return string
*/
private function benotenderMitarbeiter($lehreinheit_id, $gewaehlt = null)
{
$uids = array();
foreach($this->lehrendeDerLehreinheit($lehreinheit_id) as $lehrend) $uids[] = $lehrend->mitarbeiter_uid;
if(count($uids) === 0) return getAuthUID();
if($gewaehlt !== null && $gewaehlt !== '' && in_array($gewaehlt, $uids)) return $gewaehlt;
if(in_array(getAuthUID(), $uids)) return getAuthUID();
return $uids[0];
}
/**
* The same answer for a course grade, which has no Lehreinheit of its own. The Lehreinheit of
* the student decides.
*
* @return string
*/
private function benotenderMitarbeiterFuerStudent($lva_id, $student_uid, $stsem, $gewaehlt = null)
{
$lehreinheit_id = null;
$resLe = $this->LehrveranstaltungModel->getLeByStudent($student_uid, $stsem, $lva_id);
if(!isError($resLe) && hasData($resLe)) $lehreinheit_id = current(getData($resLe))->lehreinheit_id;
return $this->benotenderMitarbeiter($lehreinheit_id, $gewaehlt);
}
/** The transcript grade or null. Credited grades are there, not in the course grade. @return mixed|null */
private function getZeugnisnote($lva_id, $student_uid, $stsem)
{
$this->load->model('education/Zeugnisnote_model', 'ZeugnisnoteModel');
@@ -1548,26 +1659,26 @@ class Noten extends FHCAPI_Controller
return (!isError($res) && hasData($res)) ? getData($res)[0]->note : null;
}
/**
* Reads the course grade WITHOUT the filter. getLvGesamtNoten() uses 'freigabedatum < NOW()'
* and therefore hides a new grade. In this tool this wrapper is always the correct one.
*/
private function getLvGesamtnoteRow($lva_id, $student_uid, $stsem)
{
// ACHTUNG: getLvGesamtNoten filtert auf 'freigabedatum < NOW()' und liefert daher NUR
// freigegebene Noten. Für die Frage "existiert eine LV-Note?" ist das falsch - eine gerade
// erst angelegte Note hat noch kein Freigabedatum und wäre unsichtbar (Endlos-Fehler
// "keine LV-Note eingetragen" bzw. doppelter Insert). Deshalb hier der ungefilterte Zugriff.
$res = $this->LvgesamtnoteModel->getLvGesamtNoteVorschlag($lva_id, $student_uid, $stsem);
return (!isError($res) && hasData($res)) ? getData($res)[0] : null;
}
/**
* Legt eine LV-Gesamtnote an. Die Zeugnisnote bleibt unberührt - dieses Tool liest sie nur,
* die Übernahme in das Zeugnis passiert in der Studierendenverwaltung.
*
* @return stdClass|null
*/
private function createLvGesamtnote($lva_id, $student_uid, $stsem, $note, $punkte)
/** Creates a course grade. The student administration writes it to the transcript. @return stdClass|null */
private function createLvGesamtnote($lva_id, $student_uid, $stsem, $note, $punkte, $lehreinheit_id = null, $mitarbeiter_uid = null)
{
$jetzt = date("Y-m-d H:i:s");
// the caller knows the Lehreinheit here, so no second lookup is needed
$benotender = $lehreinheit_id
? $this->benotenderMitarbeiter($lehreinheit_id, $mitarbeiter_uid)
: $this->benotenderMitarbeiterFuerStudent($lva_id, $student_uid, $stsem, $mitarbeiter_uid);
$id = $this->LvgesamtnoteModel->insert(
array(
'student_uid' => $student_uid,
@@ -1575,7 +1686,7 @@ class Noten extends FHCAPI_Controller
'studiensemester_kurzbz' => $stsem,
'note' => $note,
'punkte' => $punkte,
'mitarbeiter_uid' => getAuthUID(),
'mitarbeiter_uid' => $benotender,
'benotungsdatum' => $jetzt,
'freigabedatum' => null,
'freigabevon_uid' => null,
@@ -1618,6 +1729,35 @@ class Noten extends FHCAPI_Controller
}
/**
* GET METHOD
* expects 'lehreinheit_id', 'lv_id', 'sem_kurzbz'
* The teachers of one Lehreinheit. The dialog needs them when the Lehreinheit has more than one,
* because the exam must carry the teacher and not the person who types.
*/
public function getLehrendeFuerLehreinheit() {
$lehreinheit_id = $this->input->get('lehreinheit_id');
$lv_id = $this->input->get('lv_id');
$sem_kurzbz = $this->input->get('sem_kurzbz');
if(!$lehreinheit_id || !$lv_id || !$sem_kurzbz) {
$this->terminateWithError($this->p->t('global', 'missingParameters'), 'general');
}
$this->assertLvAccess($lv_id, $sem_kurzbz);
$lehrende = array();
foreach($this->lehrendeDerLehreinheit($lehreinheit_id) as $l) {
$lehrende[] = array(
'mitarbeiter_uid' => $l->mitarbeiter_uid,
'vorname' => $l->vorname,
'nachname' => $l->nachname
);
}
$this->terminateWithSuccess($lehrende);
}
public function getNoteByPunkte() {
$result = $this->getPostJSON();
@@ -0,0 +1,410 @@
<?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 exam history of one student in one course: the order of the attempts, the attempt count
* and the limits. This class is the only source of the examination rules. The controller and the
* client only read the result.
*/
class PruefungsverlaufLib
{
/** The first attempt: the original assessment, which is the transcript grade itself. */
const ROLLE_ERSTANTRITT = 'erstantritt';
/** Each attempt after the first one. */
const ROLLE_PRUEFUNG = 'pruefung';
/** The last attempt. The examination rules require a commission for it. */
const ROLLE_KOMMISSIONELL = 'kommissionell';
/** Maps an attempt number to the old exam type. Kept for backward compatibility. */
private static $_legacyTypen = [1 => 'Termin1', 2 => 'Termin2', 3 => 'Termin3'];
private $_ci;
private $_noteCache = [];
private $_specialNotes = null;
private $_typen = 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');
}
/**
* @param mixed $lvNote counts as the implicit first attempt while no exam counts
* @param mixed $zeugnisNote a credited grade makes all exams impossible
* @return stdClass
*/
public function getVerlauf($student_uid, $lv_id, $sem_kurzbz, $lvNote = null, $zeugnisNote = null)
{
return $this->buildVerlauf($this->getPruefungen($student_uid, $lv_id, $sem_kurzbz), $lvNote, $zeugnisNote);
}
/** All exams of one student in one course, in chronological order. @return array */
public function getPruefungen($student_uid, $lv_id, $sem_kurzbz)
{
$result = $this->_ci->LePruefungModel->getPruefungenByUidTypLvStudiensemester($student_uid, null, $lv_id, $sem_kurzbz);
if (isError($result) || !hasData($result)) return [];
return $this->sortPruefungen(getData($result));
}
/** Sort keys: datum, then tbl_pruefungstyp.sort (old rows without a date), then pruefung_id. */
public function sortPruefungen($pruefungen)
{
$pruefungen = array_values($pruefungen);
$typen = $this->getTypen();
usort($pruefungen, function ($a, $b) use ($typen) {
$da = substr((string) $a->datum, 0, 10);
$db = substr((string) $b->datum, 0, 10);
if ($da !== $db) {
if ($da === '') return -1;
if ($db === '') return 1;
return ($da < $db) ? -1 : 1;
}
$sa = isset($typen[$a->pruefungstyp_kurzbz]) ? (int) $typen[$a->pruefungstyp_kurzbz]->sort : 0;
$sb = isset($typen[$b->pruefungstyp_kurzbz]) ? (int) $typen[$b->pruefungstyp_kurzbz]->sort : 0;
if ($sa !== $sb) return ($sa < $sb) ? -1 : 1;
return ((int) $a->pruefung_id < (int) $b->pruefung_id) ? -1 : 1;
});
return $pruefungen;
}
/**
* Builds the history from a list of exams. Each exam gets: position, zaehlt, antritt_nr (null
* if the exam does not count) and terminal.
*
* @return stdClass
*/
public function buildVerlauf($pruefungen, $lvNote = null, $zeugnisNote = null)
{
$pruefungen = $this->sortPruefungen($pruefungen);
$antrittCount = 0;
$terminal = false;
$eintraege = [];
foreach ($pruefungen as $i => $p) {
$zaehlt = $this->zaehltAlsAntritt($p->note, $p->pruefungstyp_kurzbz);
if ($zaehlt) $antrittCount++;
// A kommissionelle exam closes the chain, also if its grade uses no attempt. A student
// with a kommissionelle Prüfung gets no further exam from this tool.
$kommissionell = $this->istKommissionell($p->pruefungstyp_kurzbz);
if ($kommissionell) $terminal = true;
$eintrag = clone $p;
$eintrag->position = $i + 1;
$eintrag->zaehlt = $zaehlt;
$eintrag->antritt_nr = $zaehlt ? $antrittCount : null;
$eintrag->kommissionell = $kommissionell;
$eintraege[] = $eintrag;
}
// if no exam counts, the course grade itself is the first attempt
$implizit = ($antrittCount === 0 && $lvNote !== null && $this->zaehltAlsAntritt($lvNote));
if ($implizit) $antrittCount = 1;
$maxAntritte = $this->getMaxAntritte();
// credited: the row stays visible, but you cannot enter anything
$angerechnet = $this->istAnrechnungsnote($zeugnisNote);
$verlauf = new stdClass();
$verlauf->pruefungen = $eintraege;
$verlauf->antrittCount = $antrittCount;
$verlauf->maxAntritte = $maxAntritte;
$verlauf->terminal = $terminal;
$verlauf->angerechnet = $angerechnet;
$verlauf->impliziterErstantritt = $implizit;
$verlauf->canAdd = !$terminal && !$angerechnet && $antrittCount < $maxAntritte;
// after the first exam each new entry is a repeat attempt
$verlauf->erstantrittMoeglich = !$angerechnet && (count($eintraege) === 0);
// The last attempt of the chain is kommissionell (§17 Abs 1). The role therefore follows
// from the position, and the user interface offers no type to select.
if (count($eintraege) === 0 && !$implizit) {
$verlauf->naechsteRolle = self::ROLLE_ERSTANTRITT;
} elseif (($antrittCount + 1) >= $maxAntritte) {
$verlauf->naechsteRolle = self::ROLLE_KOMMISSIONELL;
} else {
$verlauf->naechsteRolle = self::ROLLE_PRUEFUNG;
}
return $verlauf;
}
/** 'angerechnet' or 'intern angerechnet'. The TRANSCRIPT grade decides. @return bool */
public function istAnrechnungsnote($note)
{
if ($note === null || $note === '') return false;
return in_array($note, $this->getSpecialNotes()['anrechnung']);
}
/**
* All attempts that count, the kommissionelle attempt included. The explicit configuration
* wins. Without it the number comes from the old flags: the original assessment, one attempt
* for each active repeat, and the kommissionelle attempt.
*
* @return int
*/
public function getMaxAntritte()
{
$max = $this->_ci->config->item('CIS_GESAMTNOTE_MAX_ANTRITTE');
if (is_numeric($max) && (int) $max > 0) return (int) $max;
$max = 1;
if (defined('CIS_GESAMTNOTE_PRUEFUNG_TERMIN2') && CIS_GESAMTNOTE_PRUEFUNG_TERMIN2) $max++;
if (defined('CIS_GESAMTNOTE_PRUEFUNG_TERMIN3') && CIS_GESAMTNOTE_PRUEFUNG_TERMIN3) $max++;
if (defined('CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF') && CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) $max++;
return $max;
}
/**
* The NOTE decides, therefore the same type can occur more than one time. The type decides in
* one case only: a type in PRUEFUNG_TYPEN_OHNE_ANTRITT never uses an attempt.
*
* @return bool
*/
public function zaehltAlsAntritt($note, $typ = null)
{
if ($typ !== null && in_array($typ, $this->getTypenOhneAntritt())) return false;
if ($note === null || $note === '') return false;
if (in_array($note, $this->getSpecialNotes()['ohneAntritt'])) return false;
// a credited grade is not an assessment, therefore it uses no attempt
if ($this->istAnrechnungsnote($note)) return false;
$noteRow = $this->getNote($note);
return $noteRow ? (bool) $noteRow->lehre : false;
}
/** Tells you if an exam takes place before a commission. @return bool */
public function istKommissionell($typ)
{
$typen = $this->_ci->config->item('PRUEFUNG_KOMMISSIONELL_TYPEN');
if (!is_array($typen)) $typen = ['kommPruef', 'zusKommPruef'];
return in_array($typ, $typen);
}
/** The exam types that never use an attempt. @return array */
public function getTypenOhneAntritt()
{
$typen = $this->_ci->config->item('PRUEFUNG_TYPEN_OHNE_ANTRITT');
return is_array($typen) ? $typen : ['zusKommPruef'];
}
/**
* The type for an attempt number. The student administration still reads this column, so the
* value must be correct. The last attempt gets the kommissionelle type, the attempts before it
* get the old Termin types.
*
* @return string
*/
public function legacyTypFuerAntritt($antrittNr)
{
$antrittNr = max(1, (int) $antrittNr);
$typen = $this->getTypen();
$maxAntritte = $this->getMaxAntritte();
$kommissionell = $this->_ci->config->item('PRUEFUNG_TYP_KOMMISSIONELL');
if (!is_string($kommissionell) || $kommissionell === '') $kommissionell = 'kommPruef';
if ($maxAntritte > 1 && $antrittNr >= $maxAntritte && isset($typen[$kommissionell])) {
return $kommissionell;
}
for ($n = $antrittNr; $n >= 1; $n--) {
if (isset(self::$_legacyTypen[$n]) && isset($typen[self::$_legacyTypen[$n]])) {
return self::$_legacyTypen[$n];
}
}
return self::$_legacyTypen[1];
}
/**
* The old type for a new repeat attempt. It is never lower than Termin2, also if the first
* exam does not use an attempt.
*
* @return string
*/
public function legacyTypFuerWiederholung($verlauf)
{
return $this->legacyTypFuerAntritt(max(2, $verlauf->antrittCount + 1));
}
/** @return array{entschuldigt: mixed, ohneAntritt: array, anrechnung: array, limitMap: array} */
public function getSpecialNotes()
{
if ($this->_specialNotes !== null) return $this->_specialNotes;
$cfgEntschuldigt = $this->_ci->config->item('NOTE_ENTSCHULDIGT');
$cfgOhneAntritt = $this->_ci->config->item('NOTEN_OHNE_ANTRITT');
$cfgLimitMap = $this->_ci->config->item('NOTEN_OCCURANCE_LIMIT_MAP');
$bezeichnungen = $this->_ci->config->item('NOTEN_OHNE_ANTRITT_BEZEICHNUNGEN');
if (!is_array($cfgOhneAntritt)) $cfgOhneAntritt = [];
if (!is_array($cfgLimitMap)) $cfgLimitMap = [];
if (!is_array($bezeichnungen)) $bezeichnungen = [];
$resEnt = $this->_ci->NoteModel->getEntschuldigtNote();
$entschuldigt = (!isError($resEnt) && hasData($resEnt)) ? getData($resEnt)[0]->note : $cfgEntschuldigt;
// The name wins against the configured key. Do NOT merge the two sets: the same key has a
// different meaning in each installation (17 is 'entschuldigt' here, but 'nicht zugelassen'
// in the standard data).
$ohneAntritt = [];
foreach ($bezeichnungen as $bezeichnung) {
$note = $this->getNoteByBezeichnung($bezeichnung);
if ($note !== null && !in_array($note, $ohneAntritt)) $ohneAntritt[] = $note;
}
if (count($ohneAntritt) === 0) $ohneAntritt = $cfgOhneAntritt;
if ($entschuldigt !== null && !in_array($entschuldigt, $ohneAntritt)) $ohneAntritt[] = $entschuldigt;
// move the configured limit for 'entschuldigt' to the key that was found
$limitMap = [];
foreach ($cfgLimitMap as $k => $v) {
$limitMap[($k == $cfgEntschuldigt) ? $entschuldigt : $k] = $v;
}
$this->_specialNotes = [
'entschuldigt' => $entschuldigt,
'ohneAntritt' => array_values($ohneAntritt),
'anrechnung' => $this->resolveNoten('NOTEN_ANRECHNUNG_BEZEICHNUNGEN', 'NOTEN_ANRECHNUNG'),
'limitMap' => $limitMap
];
return $this->_specialNotes;
}
/**
* The occurrence limit from NOTEN_OCCURANCE_LIMIT_MAP. $excludePruefungId skips the record
* that the user edits at this moment.
*
* @return bool
*/
public function ueberschreitetNotenLimit($pruefungen, $note, $excludePruefungId = null)
{
$limitMap = $this->getSpecialNotes()['limitMap'];
$limit = null;
foreach ($limitMap as $limitNote => $limitVal) {
if ($limitNote == $note) { $limit = $limitVal; break; }
}
if ($limit === null) return false;
$count = 0;
foreach ($pruefungen as $p) {
if ($excludePruefungId !== null && $p->pruefung_id == $excludePruefungId) continue;
if ($p->note == $note) $count++;
}
return ($count + 1) > $limit;
}
/** An exam with a later date locks the grade. You can still correct the date. @return bool */
public function hatSpaeterenTermin($verlauf, $pruefung_id)
{
$current = null;
foreach ($verlauf->pruefungen as $p) {
if ($p->pruefung_id == $pruefung_id) { $current = $p; break; }
}
if ($current === null) return false;
foreach ($verlauf->pruefungen as $p) {
if ($p->pruefung_id == $current->pruefung_id) continue;
if ($p->position > $current->position) return true;
}
return false;
}
/**
* tbl_pruefungstyp as a map. It sorts old rows, and it limits the legacy type to the types
* that exist. A type that does not exist breaks the foreign key. Termin3 is absent from the
* standard data.
*
* @return array
*/
private function getTypen()
{
if ($this->_typen !== null) return $this->_typen;
$this->_typen = [];
$result = $this->_ci->PruefungstypModel->load();
if (!isError($result) && hasData($result)) {
foreach (getData($result) as $typ) {
$this->_typen[$typ->pruefungstyp_kurzbz] = $typ;
}
}
return $this->_typen;
}
/** Finds the grades by their name. The configured keys are the fallback only. @return array */
private function resolveNoten($bezeichnungKey, $pkKey)
{
$bezeichnungen = $this->_ci->config->item($bezeichnungKey);
if (!is_array($bezeichnungen)) $bezeichnungen = [];
$noten = [];
foreach ($bezeichnungen as $bezeichnung) {
$note = $this->getNoteByBezeichnung($bezeichnung);
if ($note !== null && !in_array($note, $noten)) $noten[] = $note;
}
if (count($noten) === 0) {
$fallback = $this->_ci->config->item($pkKey);
if (is_array($fallback)) $noten = $fallback;
}
return array_values($noten);
}
/**
* @return stdClass|null
*/
private function getNote($note)
{
if (array_key_exists($note, $this->_noteCache)) return $this->_noteCache[$note];
$result = $this->_ci->NoteModel->load($note);
$this->_noteCache[$note] = (!isError($result) && hasData($result)) ? getData($result)[0] : null;
return $this->_noteCache[$note];
}
/**
* @return mixed|null
*/
private function getNoteByBezeichnung($bezeichnung)
{
$result = $this->_ci->NoteModel->loadWhere(['bezeichnung' => $bezeichnung]);
return (!isError($result) && hasData($result)) ? getData($result)[0]->note : null;
}
}
@@ -13,7 +13,7 @@ class Notenschluesselaufteilung_model extends DB_Model
}
/**
* Liefert die Note zu Punkten einer Lehrveranstaltung
* Gives the grade for the points of a course.
*
* @param number $points
* @param integer $lehrveranstaltung_id
@@ -23,6 +23,12 @@ class Notenschluesselaufteilung_model extends DB_Model
*/
public function getNote($points, $lehrveranstaltung_id, $studiensemester_kurzbz)
{
// Without a valid points value you cannot derive a grade. Without this guard the query
// builder makes a bad statement from "punkte <=" => null ('punkte' < 'IS' 'NULL'). The
// request then fails with HTTP 500 instead of an answer that says "no grade".
if (!is_numeric($points))
return success(null);
$this->load->model('education/Notenschluesselzuordnung_model', 'NotenschluesselzuordnungModel');
$notenschluessel_kurzbz = $this->NotenschluesselzuordnungModel->getKurzbzForLv($lehrveranstaltung_id, $studiensemester_kurzbz);
+5
View File
@@ -3,6 +3,11 @@
"cy:open": "cypress open",
"cy:run": "cypress run",
"cy:noten": "cypress run --spec \"tests/cypress/e2e/specs/**/*.cy.js\"",
"cy:noten:api": "cypress run --spec \"tests/cypress/e2e/specs/{unit,api}/**/*.cy.js\"",
"cy:noten:ui": "cypress run --browser chrome --spec \"tests/cypress/e2e/specs/ui/**/*.cy.js\"",
"cy:noten:ui:watch": "cypress run --browser chrome --headed --no-exit --spec \"tests/cypress/e2e/specs/ui/**/*.cy.js\"",
"cy:noten:punkte-on": "cypress run --browser chrome --env NOTEN_PUNKTE_MODUS=on --spec \"tests/cypress/e2e/specs/**/*.cy.js\"",
"cy:noten:punkte-off": "cypress run --browser chrome --env NOTEN_PUNKTE_MODUS=off --spec \"tests/cypress/e2e/specs/**/*.cy.js\"",
"cy:noten:p0": "cypress run --spec \"tests/cypress/e2e/specs/unit/notenRules.cy.js,tests/cypress/e2e/specs/api/noten/noten.pruefungsantritte.cy.js,tests/cypress/e2e/specs/api/noten/noten.pruefungstermin.cy.js,tests/cypress/e2e/specs/api/noten/noten.ueberschreiben.cy.js\"",
"noten:check": "node tests/cypress/tools/dbCheck.js noten"
},
+13 -4
View File
@@ -51,19 +51,28 @@ export default {
},
// kein Termintyp mehr: welchen Antritt die Prüfung darstellt, leitet der Server aus dem
// bestehenden Prüfungsverlauf des Studenten in dieser LV und diesem Semester ab
saveStudentPruefung(student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, pruefung_id = null){
// mitarbeiter_uid: der benotende Lektor. Nur nötig, wenn die Lehreinheit mehrere hat - sonst
// löst der Server ihn selbst auf.
saveStudentPruefung(student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, pruefung_id = null, mitarbeiter_uid = null){
return {
method: 'post',
url: '/api/frontend/v1/Noten/saveStudentPruefung',
params: { student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, pruefung_id }
params: { student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, pruefung_id, mitarbeiter_uid }
};
},
// note/punkte optional: ohne Auswahl wird der Termin als "noch nicht eingetragen" angelegt
createPruefungen(uids, datum, lva_id, sem_kurzbz, note = null, punkte = null){
createPruefungen(uids, datum, lva_id, sem_kurzbz, note = null, punkte = null, mitarbeiter_uid = null){
return {
method: 'post',
url: '/api/frontend/v1/Noten/createPruefungen',
params: { uids, datum, lva_id, sem_kurzbz, note, punkte }
params: { uids, datum, lva_id, sem_kurzbz, note, punkte, mitarbeiter_uid }
};
},
getLehrendeFuerLehreinheit(lehreinheit_id, lv_id, sem_kurzbz) {
return {
method: 'get',
url: '/api/frontend/v1/Noten/getLehrendeFuerLehreinheit',
params: { lehreinheit_id, lv_id, sem_kurzbz }
};
},
saveNotenvorschlagBulk(lv_id, sem_kurzbz, noten) {
File diff suppressed because it is too large Load Diff
@@ -13,9 +13,9 @@ export const NotenlisteLinks = {
return LehreinheitenModule;
},
lehreinheiten() {
// reuse the already-loaded LE options from the shared module
// use the teaching unit options that the shared module loaded before
const all = LehreinheitenModule.options ?? [];
// when a single Lehreinheit is selected, restrict the list to just that one
// if the user selected one teaching unit, show only that one
if (this.selectedLehreinheit?.lehreinheit_id != null) {
return all.filter(le => le.lehreinheit_id === this.selectedLehreinheit.lehreinheit_id);
}
@@ -1,21 +1,20 @@
/**
* Prüfungsordnung §1 rules as the client applies them.
* The examination rules as the client applies them.
*
* Die Regeln selbst liegen im Server (PruefungsverlaufLib): er liefert je Student einen `verlauf`
* mit Antrittszahl, Grenze und der nächsten möglichen Rolle, und je Termin dessen Position sowie
* ob er einen Antritt verbraucht. Hier wird das nur noch ausgewertet - damit gibt es keine zweite
* Implementierung der Regeln mehr, die auseinanderlaufen kann.
* PruefungsverlaufLib sends one `verlauf` for each student. It contains the attempt count, the
* limit and the next possible role. For each exam it contains the position and a flag that tells
* you if the exam uses an attempt.
*
* Der lokale Fallback greift nur, solange der Verlauf für eine Zeile noch nicht geladen ist.
* The local fallback applies only while the client has no `verlauf` for a row.
*/
/** Maximale Anzahl zählender Antritte in diesem Tool (serverseitig abgeleitet). */
/** The maximum number of attempts that count in this tool. The server derives this value. */
export const maxAntrittCount = (config) => config?.CIS_GESAMTNOTE_MAX_ANTRITTE ?? 1;
/**
* Zählende Prüfungsantritte. Gezählt wird über die NOTE, nicht über den Termintyp: Noten in
* NOTEN_OHNE_ANTRITT (entschuldigt, nicht beurteilt, noch nicht eingetragen) verbrauchen keinen
* Antritt. Ohne zählenden Termin gilt die LV-Note selbst als erster Antritt, sofern sie zählt.
* The attempts that count. The NOTE decides, not the exam type: the grades in NOTEN_OHNE_ANTRITT
* (excused, not assessed, not entered yet) use no attempt. If no exam counts, the course grade
* itself is the first attempt, but only if that grade counts.
*/
export const antrittCountStudent = (student, config, notenOptions) => {
if (student.verlauf) return student.verlauf.antrittCount;
@@ -34,21 +33,25 @@ export const antrittCountStudent = (student, config, notenOptions) => {
return count;
};
/** Ob für den Studenten überhaupt noch ein Antritt angelegt werden darf. */
/** Tells you if the student can get one more attempt. */
export const canAddPruefung = (student, config) => {
if (student.verlauf) return student.verlauf.canAdd;
return !student.kommPruef && antrittCountStudent(student, config) < maxAntrittCount(config);
};
/**
* Ob mit der neuen Prüfung auch erst die LV-Note entsteht. Kommt aus dem Verlauf und nicht aus
* `lv_note`: letzteres kennt nur FREIGEGEBENE Noten, eine erfasste aber nicht freigegebene LV-Note
* existiert trotzdem. Nur für den Hinweis in der Oberfläche.
* Tells you if the new exam also creates the course grade. The value comes from the `verlauf` and
* not from `lv_note`, because `lv_note` contains RELEASED grades only. An entered grade that is
* not released still exists. The user interface uses this for a hint only.
*/
export const brauchtNeueLvNote = (student) => student.verlauf ? !student.verlauf.hatLvNote : !student.lv_note;
/** Grade state from the two timestamps. */
/**
* The grade state from the two timestamps: offen = no grade entered (an empty row), changed = a
* grade is entered but not released, ok = released and not changed since the release.
*/
export const checkFreigabe = (freigabedatum, benotungsdatum) => {
if (!freigabedatum) return "offen";
if (!benotungsdatum) return "offen";
if (!freigabedatum) return "changed";
return benotungsdatum > freigabedatum ? "changed" : "ok";
};
+120
View File
@@ -62327,6 +62327,126 @@ I have been informed that I am under no obligation to consent to the transmissio
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'c4lektor',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Lektor/in',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Lecturer',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'pruefungsdatumNachFrist',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Das Prüfungsdatum für Studierenden {0} liegt nach dem Ende der Noteneintragungsfrist ({1}). Es wurde keine Prüfung angelegt.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'The exam date for student {0} is after the grade entry deadline ({1}). No exam was created.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'c4punkteKeineNoteErmittelt',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Für Studierenden {0} konnte aus den Punkten keine Note ermittelt werden. Die Zeile wurde übersprungen.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'No grade could be derived from the points for student {0}. The row was skipped.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'c4noteNichtInLehre',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Die gewählte Note ist für eine Lehrveranstaltung nicht zugelassen. Die LV-Note für {0} bleibt unverändert.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'The selected grade is not permitted for a course. The course grade for {0} stays unchanged.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'c4notenvorschlagGesperrt',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Für {0} existiert bereits eine Prüfung. Ändern Sie die LV-Note über den Prüfungstermin.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'An exam already exists for {0}. Change the course grade through the exam.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'c4zeugnisnoteGesperrt',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Die Zeugnisnote von {0} ist für Lehrende gesperrt. Die LV-Note bleibt unverändert.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'The transcript grade of {0} is locked for teachers. The course grade stays unchanged.',
'description' => '',
'insertvon' => 'system'
)
)
),
// CIS4 GESAMTNOTENEINGABE ENDE ------------------------------------------------------------------------------------
);
-33
View File
@@ -1,33 +0,0 @@
-- Grant all db users access to the "extension" schema.
--
-- The dump creates the schema and gives it no grants, unlike public/lehre/campus, and
-- truncate_db.sql only grants on public. Any application not connecting as the owner then fails
-- every query in that schema with "permission denied for schema extension"
DO $$
DECLARE
v_role text;
v_roles text[] := ARRAY['web', 'vilesci', 'wawi', 'admin'];
v_done integer := 0;
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'extension') THEN
RAISE NOTICE 'Schema "extension" does not exist, skipped.';
RETURN;
END IF;
FOREACH v_role IN ARRAY v_roles
LOOP
CONTINUE WHEN NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = v_role);
EXECUTE format('GRANT USAGE ON SCHEMA extension TO %I', v_role);
EXECUTE format('GRANT SELECT ON ALL TABLES IN SCHEMA extension TO %I', v_role);
EXECUTE format('GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA extension TO %I', v_role);
-- and for whatever an addon creates later
EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA extension GRANT SELECT ON TABLES TO %I', v_role);
EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA extension GRANT EXECUTE ON FUNCTIONS TO %I', v_role);
v_done := v_done + 1;
END LOOP;
RAISE NOTICE 'Granted on schema "extension" for % role(s).', v_done;
END $$;
+40
View File
@@ -0,0 +1,40 @@
-- Vollzugriff auf ALLE Anwendungsschemata für alle DB-Rollen.
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 $$;
@@ -1,8 +1,8 @@
/**
* Access control (P2) - assertLvAccess scoping (Noten.php:243).
* Access control (P2) - das Scoping von assertLvAccess.
*
* There is no impersonation in this API, so testing teacher scoping needs that teacher's real
* credentials: NOTEN_TEACHER_USER / NOTEN_TEACHER_PASSWORD / NOTEN_FOREIGN_LV_ID.
* Diese API kennt keine Impersonation, der Lektoren-Scope braucht daher echte Zugangsdaten:
* NOTEN_TEACHER_USER / NOTEN_TEACHER_PASSWORD / NOTEN_FOREIGN_LV_ID.
*/
import { notenApi } from "../../../../support/api/notenApi";
@@ -81,10 +81,7 @@ describe("Noten API - access control", () => {
password: Cypress.env("NOTEN_TEACHER_PASSWORD"),
});
/**
* Without this the denials below pass on a 401 from the LOGIN, never reaching assertLvAccess -
* a scoping suite that is green because the second account cannot log in at all.
*/
/** Ohne das wären die Ablehnungen unten grün, weil der zweite Account gar nicht erst reinkommt. */
it("authenticates as the configured teacher", () => {
cy.request({
method: "GET",
@@ -158,9 +155,8 @@ describe("Noten API - access control", () => {
});
});
// getLvForStudiengang's refusal is NOT covered: the configured user is an admin, so
// isBerechtigt('admin') short-circuits the entitlement check and every call succeeds. It needs
// the same non-admin login as the teacher tests above.
// getLvForStudiengang ist NICHT abgedeckt: als Admin greift isBerechtigt('admin') und jeder
// Aufruf gelingt. Braucht denselben Nicht-Admin-Login wie die Lektorentests oben.
describe("getBenotungstoolContext shape", () => {
it("returns the role-determining payload", () => {
@@ -1,9 +1,6 @@
/**
* Bulk / import paths (P2).
*
* Same validatePruefungAdd guards as the single save, but reported differently: HTTP 200 with the
* error string in data[uid] per row (Noten.php:1600/:1656). createPruefungen is the main vehicle
* because it always writes "Noch nicht eingetragen" and never touches punkte.
* Bulk- und Importpfade (P2). Dieselben Guards wie beim einzelnen Speichern, aber je Zeile
* gemeldet: HTTP 200 mit der Fehlermeldung in data[uid].
*/
import { notenApi } from "../../../../support/api/notenApi";
@@ -114,21 +111,18 @@ describe("Noten API - bulk paths", () => {
});
describe("savePruefungenBulk", () => {
beforeEach(function () {
if (ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) {
// With punkte mode on, this endpoint unconditionally re-derives every row's note from
// the Notenschlüssel (Noten.php:1638-1643), so a row would need valid punkte rather than
// a grade. Covering that needs a points-based fixture; skip rather than assert nonsense.
cy.log(
"Skipped: CIS_GESAMTNOTE_PUNKTE is enabled, so savePruefungenBulk grades by points " +
"and this grade-based scenario does not apply.",
);
this.skip();
}
beforeEach(() => {
requireDbReset();
});
it("applies the §1 rules per row", () => {
it("applies the §1 rules per row", function () {
if (ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) {
// im Punktemodus leitet der Endpunkt die Note aus den Punkten ab; die Regelprüfung
// deckt der punktebasierte Test unten ab
cy.log("Skipped: CIS_GESAMTNOTE_PUNKTE ist aktiv, siehe den punktebasierten Test.");
this.skip();
}
const atCap = ctx.students[0];
const fresh = ctx.students[1];
@@ -165,10 +159,88 @@ describe("Noten API - bulk paths", () => {
expectBulkRowAccepted(data, fresh.uid);
});
});
// Punktemodus: die Note kommt aus dem Notenschlüssel, die Zeile liefert nur Punkte.
describe("Punktemodus", () => {
beforeEach(function () {
if (!ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) {
cy.log("Skipped: CIS_GESAMTNOTE_PUNKTE ist aus.");
this.skip();
}
});
it("leitet die Note aus den Punkten ab", () => {
const student = ctx.students[1];
resetNotenState(ctx);
seedBaseline(ctx, student.uid, { freigegeben: true });
// erwartete Note zur Laufzeit erfragen, damit der Notenschlüssel nicht hardcodiert ist
notenApi.getNoteByPunkte(100, ctx.lvId, ctx.semKurzbz).then((response) => {
const erwartet = expectNotenSuccess(response, "getNoteByPunkte(100)");
expect(erwartet, "100 Punkte müssen eine Note ergeben").to.not.be.null;
notenApi
.savePruefungenBulk(ctx.lvId, ctx.semKurzbz, [
{
uid: student.uid,
note: null,
punkte: 100,
datum: attemptDate(ctx, 1),
lehreinheit_id: student.lehreinheit_id,
},
])
.then((bulkResponse) => {
const data = expectNotenSuccess(bulkResponse, "savePruefungenBulk mit Punkten");
expectBulkRowAccepted(data, student.uid);
expect(String(data[student.uid].savedPruefung[0].note)).to.eq(String(erwartet));
});
});
});
it("überspringt eine Zeile ohne Punkte und schreibt die übrigen", () => {
const ohnePunkte = ctx.students[0];
const mitPunkten = ctx.students[1];
resetNotenState(ctx);
seedBaseline(ctx, ohnePunkte.uid, { freigegeben: true });
seedBaseline(ctx, mitPunkten.uid, { freigegeben: true });
notenApi
.savePruefungenBulk(ctx.lvId, ctx.semKurzbz, [
{
uid: ohnePunkte.uid,
note: null,
punkte: null,
datum: attemptDate(ctx, 1),
lehreinheit_id: ohnePunkte.lehreinheit_id,
},
{
uid: mitPunkten.uid,
note: null,
punkte: 100,
datum: attemptDate(ctx, 1),
lehreinheit_id: mitPunkten.lehreinheit_id,
},
])
.then((response) => {
// eine kaputte Zeile darf den ganzen Import nicht abbrechen
const data = expectNotenSuccess(response, "savePruefungenBulk mit Luecke");
expectBulkRowError(data, ohnePunkte.uid, "c4punkteKeineNoteErmittelt");
expectBulkRowAccepted(data, mitPunkten.uid);
});
});
});
});
describe("saveNotenvorschlagBulk", () => {
it("writes an LV note for every row", () => {
it("writes an LV note for every row", function () {
if (ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) {
// im Punktemodus kommt die Note aus dem Notenschlüssel, siehe den Punktemodus-Block
cy.log("Skipped: CIS_GESAMTNOTE_PUNKTE ist aktiv.");
this.skip();
}
const [a, b] = ctx.students;
resetNotenState(ctx);
@@ -179,8 +251,10 @@ describe("Noten API - bulk paths", () => {
{ uid: b.uid, note: ctx.gradeNotes[1], punkte: null },
])
.then((response) => {
// nach uid geschlüsselt: je Zeile die LV-Note oder eine Fehlermeldung
const data = expectNotenSuccess(response, "saveNotenvorschlagBulk");
expect(data, "one lvgesamtnote row per input row").to.be.an("array").and.have.length(2);
expectBulkRowAccepted(data, a.uid);
expectBulkRowAccepted(data, b.uid);
});
readLvGesamtnote(ctx, a.uid).then((row) => {
@@ -193,5 +267,59 @@ describe("Noten API - bulk paths", () => {
expect(String(row.note)).to.eq(String(ctx.gradeNotes[1]));
});
});
describe("Punktemodus", () => {
beforeEach(function () {
if (!ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) {
cy.log("Skipped: CIS_GESAMTNOTE_PUNKTE ist aus.");
this.skip();
}
});
it("schreibt die aus den Punkten abgeleitete Note", () => {
const student = ctx.students[0];
resetNotenState(ctx);
notenApi.getNoteByPunkte(100, ctx.lvId, ctx.semKurzbz).then((response) => {
const erwartet = expectNotenSuccess(response, "getNoteByPunkte(100)");
expect(erwartet, "100 Punkte müssen eine Note ergeben").to.not.be.null;
notenApi
.saveNotenvorschlagBulk(ctx.lvId, ctx.semKurzbz, [
{ uid: student.uid, note: null, punkte: 100 },
])
.then((bulk) => expectBulkRowAccepted(expectNotenSuccess(bulk, "Notenimport"), student.uid))
.then(() => readLvGesamtnote(ctx, student.uid))
.then((row) => {
expect(String(row.note), "Note kommt aus dem Notenschlüssel").to.eq(String(erwartet));
expect(Number(row.punkte), "die Punkte werden mitgeschrieben").to.eq(100);
});
});
});
it("überspringt eine Zeile ohne Punkte und schreibt die übrigen", () => {
const ohnePunkte = ctx.students[0];
const mitPunkten = ctx.students[1];
resetNotenState(ctx);
notenApi
.saveNotenvorschlagBulk(ctx.lvId, ctx.semKurzbz, [
{ uid: ohnePunkte.uid, note: null, punkte: null },
{ uid: mitPunkten.uid, note: null, punkte: 100 },
])
.then((response) => {
// eine kaputte Zeile darf den ganzen Import nicht abbrechen
const data = expectNotenSuccess(response, "Notenimport mit Luecke");
expectBulkRowError(data, ohnePunkte.uid, "c4punkteKeineNoteErmittelt");
expectBulkRowAccepted(data, mitPunkten.uid);
});
readLvGesamtnote(ctx, ohnePunkte.uid).then((row) => {
expect(row, "die übersprungene Zeile bleibt ungeschrieben").to.be.null;
});
});
});
});
});
@@ -1,9 +1,9 @@
/**
* Notenfreigabe (P1, cases 12-14).
*
* A successful saveStudentenNoten ALWAYS sends the Notenfreigabe mail (Noten.php:585, not behind a
* config flag), so the happy path is opt-in via NOTEN_FREIGABE_ENABLED. The wrong-password test
* needs no gate: the check runs before any write and before the mail.
* Ein erfolgreiches saveStudentenNoten verschickt IMMER die Freigabemail (kein Config-Flag),
* deshalb ist der Happy Path opt-in über NOTEN_FREIGABE_ENABLED. Der Passworttest braucht das
* nicht: die Prüfung läuft vor jedem Schreibzugriff.
*/
import { notenApi } from "../../../../support/api/notenApi";
@@ -109,6 +109,15 @@ describe("Noten API - Notenfreigabe", () => {
const entry = data.find((r) => r.uid === student.uid);
expect(entry, `${student.uid} must be reported as freigegeben`).to.exist;
expect(entry.freigabedatum, "freigabedatum is stamped").to.exist;
// Die Freigabe legt den ersten Antritt an. Ohne den Verlauf in der Antwort
// zeigt die Tabelle den neuen Termin erst nach einem Reload.
expect(entry.verlauf, "der Verlauf kommt mit der Antwort zurück").to.exist;
expect(entry.verlauf.pruefungen, "inklusive der Termine").to.be.an("array");
expect(
entry.verlauf.pruefungen.length,
"der erste Antritt ist dabei",
).to.be.greaterThan(0);
});
readState(ctx).then((data) => {
@@ -1,10 +1,9 @@
/**
* Prüfungsordnung §1 - Noteneintragungsfrist (P0, cases 8-9).
*
* enforceNoteneintragungsfrist runs after assertLvAccess but before any write, so a request naming
* a past-deadline semester is rejected without touching a row. The message carries the derived
* deadline, which doubles as a check of computeNoteneintragungsfrist
* (SS -> 15.11.yyyy, WS -> 15.05.yyyy+1).
* Die Prüfung läuft vor jedem Schreibzugriff, ein abgelaufenes Semester wird also abgelehnt ohne
* eine Zeile anzufassen. Die Fehlermeldung nennt die Frist und prüft damit zugleich deren
* Ableitung (SS -> 15.11.yyyy, WS -> 15.05.yyyy+1).
*/
import { notenApi } from "../../../../support/api/notenApi";
@@ -34,6 +33,42 @@ describe("Noten API - Noteneintragungsfrist (Prüfungsordnung §1)", () => {
});
});
// §7 und §11: nicht nur der Zeitpunkt der Eingabe zählt, sondern auch das Prüfungsdatum selbst.
// Die kommissionelle Prüfung muss bis zur Frist STATTFINDEN, sonst verliert der Student ein
// Semester. Das ist eine andere Frage als "darf jetzt noch eingetragen werden".
describe("Prüfungsdatum nach der Frist", () => {
beforeEach(function () {
if (!enforced) {
cy.log("Übersprungen: CIS_GESAMTNOTE_NOTENEINTRAGUNGSFRIST ist aus.");
this.skip();
}
requireDbReset();
});
it("lehnt einen Termin ab, der nach der Frist liegt", () => {
const student = ctx.students[0];
const jahr = Number(ctx.semKurzbz.slice(2, 6));
// die Frist des laufenden Semesters liegt in der Zukunft, ein Datum dahinter ist ungültig
const nachDerFrist = `${jahr + 1}-12-31`;
givenBaseline(ctx, student);
addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: nachDerFrist }).then((response) => {
expectNotenError(response, "pruefungsdatumNachFrist");
});
});
it("nimmt einen Termin vor der Frist an", () => {
const student = ctx.students[1];
givenBaseline(ctx, student);
addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 1) }).then(
(response) => expectNotenSuccess(response, "Termin innerhalb der Frist"),
);
});
});
describe("deadline has passed", () => {
// A rejected request writes nothing, so these need no DB reset.
[
@@ -93,7 +128,7 @@ describe("Noten API - Noteneintragungsfrist (Prüfungsordnung §1)", () => {
it("accepts grade entry in a semester whose deadline has not passed", function () {
if (enforced && fristHasPassed(ctx.semKurzbz)) {
// writing is impossible here; the whole mutating suite would be blocked too
// hier ist kein Schreiben möglich, damit wäre die gesamte mutierende Suite blockiert
throw new Error(
`Noteneintragungsfrist enforcement is ON and the deadline for the test semester ` +
`${ctx.semKurzbz} (${expectedFristString(ctx.semKurzbz)}) has already passed, so no ` +
@@ -114,7 +149,6 @@ describe("Noten API - Noteneintragungsfrist (Prüfungsordnung §1)", () => {
});
});
// The SS/WS derivation is asserted against the SERVER in the two "deadline has passed" tests,
// which compare the deadline it names in the error message. A local check of expectedFristString()
// against constants only tested the helper, so it was removed.
// Die SS/WS-Ableitung wird gegen den SERVER geprüft (die Frist in der Fehlermeldung oben) -
// ein lokaler Vergleich von expectedFristString() gegen Konstanten testet nur den Helfer.
});
@@ -1,9 +1,8 @@
/**
* Notenschlüssel - points to grade (P0, case 10). Read-only.
* Notenschlüssel - Punkte zu Note (P0, case 10). Read-only.
*
* getNote() picks the aufteilung row with the highest punkte <= input, and returns null when the LV
* has no Notenschlüssel. Rather than re-implementing that lookup, this sweeps the range and asserts
* the properties a correct scale must have: total, monotonic, exact at each boundary.
* Statt die Zuordnung nachzubauen, wird der Bereich abgetastet und auf die Eigenschaften einer
* korrekten Skala geprüft: lückenlos, monoton, exakt an jeder Grenze.
*/
import { notenApi } from "../../../../support/api/notenApi";
@@ -47,8 +46,8 @@ describe("Noten API - Notenschlüssel (getNoteByPunkte)", () => {
`for every value in 0..${MAX_PUNKTE}. Pin NOTEN_LV_ID to an LV that has one.`,
).to.be.greaterThan(0);
// (4) below the lowest threshold the model answers null, and that region must be a
// contiguous prefix - a null appearing *after* a graded value would mean a gap in the scale
// unter der niedrigsten Schwelle antwortet das Modell null; das muss ein zusammenhängender
// Anfang sein, ein null NACH einer Note wäre eine Lücke in der Skala
const firstGradedIndex = observed.findIndex((e) => e.note !== null && e.note !== undefined);
observed.slice(firstGradedIndex).forEach((entry) => {
expect(
@@ -57,7 +56,7 @@ describe("Noten API - Notenschlüssel (getNoteByPunkte)", () => {
).to.not.be.oneOf([null, undefined]);
});
// (2) monotonic: grade PKs run 1 (best) .. 5 (worst), so the number must never increase
// monoton: die PKs laufen 1 (beste) .. 5, die Zahl darf also nie steigen
for (let i = 1; i < graded.length; i += 1) {
expect(
Number(graded[i].note),
@@ -65,7 +64,7 @@ describe("Noten API - Notenschlüssel (getNoteByPunkte)", () => {
).to.be.at.most(Number(graded[i - 1].note));
}
// (3) exact boundaries: report each step so a shifted threshold is visible in the output
// jede Stufe einzeln melden, damit eine verschobene Schwelle in der Ausgabe sichtbar wird
const boundaries = [];
for (let i = 1; i < graded.length; i += 1) {
if (Number(graded[i].note) !== Number(graded[i - 1].note)) {
@@ -109,8 +108,7 @@ describe("Noten API - Notenschlüssel (getNoteByPunkte)", () => {
});
it("reports no grade for an LV without a Notenschlüssel", () => {
// A non-existent LV id can have no Notenschlüssel assigned, so the model's success(null)
// contract is observable without depending on a particular fixture.
// eine nicht existierende LV hat garantiert keinen Schlüssel - fixturefrei prüfbar
notenApi.getNoteByPunkte(50, 0, ctx.semKurzbz).then((response) => {
const note = expectNotenSuccess(response, "getNoteByPunkte for an LV without a schluessel");
expect(note, "no Notenschlüssel -> no grade").to.be.oneOf([null, undefined]);
@@ -1,14 +1,9 @@
/**
* Notenvorschlag - LV note write and resulting state (P1, case 11).
* Notenvorschlag - LV-Note schreiben und der resultierende Status (P1, case 11).
*
* State comes from two timestamps (Benotungstool.js::checkFreigabe): no freigabedatum -> offen,
* benotungsdatum > freigabedatum -> changed, else freigegeben. The "changed" case is produced by
* seeding an already-freigegebene baseline, which keeps this spec free of the LDAP password check
* and the Freigabe mail.
*
* Caveat: getLvGesamtNoten() (behind getStudentenNoten) filters `freigabedatum < NOW()`, and
* NULL < NOW() is NULL - so offene notes are dropped, while saveNotenvorschlag writes through the
* unfiltered getLvGesamtNoteVorschlag(). The tests below separate write from read for that reason.
* Status aus zwei Zeitstempeln (notenRules.js::checkFreigabe). Der "changed nach Freigabe"-Fall
* wird über eine bereits freigegebene Baseline erzeugt, damit dieser Spec ohne LDAP-Passwort und
* ohne Freigabemail auskommt.
*/
import { notenApi } from "../../../../support/api/notenApi";
@@ -59,7 +54,7 @@ describe("Noten API - Notenvorschlag", () => {
});
});
it("persists the row with freigabedatum NULL (state: offen)", () => {
it("persists the row with freigabedatum NULL (not freigegeben)", () => {
const student = ctx.students[0];
resetNotenState(ctx);
@@ -73,8 +68,6 @@ describe("Noten API - Notenvorschlag", () => {
});
});
// The unfiltered getter behind getNotenvorschlagStudent sees the same row fine - which is the
// getter finding 1 says getStudentenNoten should be using.
it("reports the offen note through getNotenvorschlagStudent", () => {
const student = ctx.students[0];
@@ -88,8 +81,8 @@ describe("Noten API - Notenvorschlag", () => {
});
});
// EXPECTED TO FAIL while getLvGesamtNoten filters `freigabedatum < NOW()`: the row exists
// (test above proves it) but the read model drops it until the note is freigegeben.
// getStudentenNoten liest seit dem Wechsel auf den ungefilterten Getter auch noch nicht
// freigegebene Noten - sonst sind LV-Note und Freigabestatus nach einem Reload leer.
it("reports the offen note back through getStudentenNoten", () => {
const student = ctx.students[0];
@@ -109,6 +102,9 @@ describe("Noten API - Notenvorschlag", () => {
});
});
// `erstantritt: false` ist die Altdatenform: freigegebene LV-Note ohne Prüfungszeile. Nur dort
// ist ein direktes Umbenoten erlaubt - existiert eine Prüfung, lehnt der Server ab
// (validateNotenvorschlag, siehe noten.ueberschreiben).
describe("re-grading an already freigegebene note", () => {
it("moves the state from freigegeben to changed", () => {
const student = ctx.students[1];
@@ -117,6 +113,7 @@ describe("Noten API - Notenvorschlag", () => {
seedBaseline(ctx, student.uid, {
note: ctx.gradeNotes[0],
freigegeben: true,
erstantritt: false,
benotungsdatum: baselineBenotungsdatum(ctx),
});
@@ -154,7 +151,11 @@ describe("Noten API - Notenvorschlag", () => {
const student = ctx.students[1];
resetNotenState(ctx);
seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: true });
seedBaseline(ctx, student.uid, {
note: ctx.gradeNotes[0],
freigegeben: true,
erstantritt: false,
});
notenApi.saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.gradeNotes[1]);
@@ -1,12 +1,11 @@
/**
* Prüfungsordnung §1 - Prüfungsantritte.
*
* Rules fire in order A (max Antritte) -> B (chronology) -> C (occurrence limit), so each test
* builds a state where exactly one can fire; otherwise an earlier rule masks the one under test.
* Die Regeln greifen in der Reihenfolge A (Maximum) -> B (Chronologie) -> C (Vorkommenslimit).
* Jeder Test baut einen Zustand, in dem genau eine davon feuern kann, sonst maskiert die frühere.
*
* Bookkeeping since the Prüfungsverlauf refactor: one add writes one row. Antritt 1 is a real
* Prüfung (created by the Freigabe, seeded by givenBaseline). Attempts are counted by NOTE, not by
* type: "entschuldigt", "Noch nicht eingetragen" and "Nicht beurteilt" never count towards the cap.
* Gezählt wird über die NOTE: entschuldigt, "Noch nicht eingetragen" und "Nicht beurteilt" zählen
* nie mit. Antritt 1 ist eine echte Prüfung (von givenBaseline geseedet).
*/
import { notenApi } from "../../../../support/api/notenApi";
@@ -65,9 +64,8 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
return attemptDate(ctx, firstIndex + ctx.maxAntritte - 1);
};
// Every rule below is keyed on these PKs. getSpecialNotes() resolves them from tbl_note by
// Bezeichnung and only falls back to the config values - on demo data entschuldigt is 14, not the
// 17 in application/config/noten.php. If the fallback ever wins, the rules silently stop matching.
// Alle Regeln hängen an diesen PKs. Auf Demodaten ist entschuldigt 14, nicht die 17 aus
// application/config/noten.php - gewinnt der Fallback, greifen die Regeln stillschweigend nicht mehr.
it("resolves the special notes from tbl_note, not from config", () => {
notenApi.getCisConfig().then((response) => {
const config = expectNotenSuccess(response, "getCisConfig");
@@ -119,8 +117,7 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
});
describe("Rule B - attempts are taken in chronological order", () => {
// "Noch nicht eingetragen" occupies a date without counting towards the cap, so Rule A
// cannot fire first and mask Rule B.
// "Noch nicht eingetragen" belegt ein Datum ohne zu zählen, damit Regel A nicht maskiert
const givenOpenAttemptOn = (student, datum) => {
givenBaseline(ctx, student);
addPruefung(ctx, student, { note: ctx.notes.nochNichtEingetragen, datum }).then((response) => {
@@ -240,7 +237,61 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
});
});
describe("kommPruef is the terminal attempt", () => {
describe("die kommissionelle Prüfung ist der letzte Antritt", () => {
// §17 Abs 1: die zweite Wiederholung ist kommissionell. Der Server leitet den Typ aus der
// Position ab und schreibt ihn in pruefungstyp_kurzbz, weil die Studierendenverwaltung
// diese Spalte weiterhin liest.
it("schreibt den letzten Antritt als kommPruef", () => {
const student = studentFor(3);
givenBaseline(ctx, student);
// bis zum vorletzten Antritt auffüllen
for (let i = 1; i < ctx.maxAntritte - 1; i += 1) {
addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, i) }).then(
(response) => expectNotenSuccess(response, `Antritt ${i + 1}`),
);
}
addPruefung(ctx, student, {
note: ctx.gradeNotes[0],
datum: attemptDate(ctx, ctx.maxAntritte),
}).then((response) => {
const [saved] = expectNotenSuccess(response, "letzter Antritt");
expect(saved.pruefungstyp_kurzbz, "der letzte Antritt ist kommissionell").to.eq("kommPruef");
});
readState(ctx).then((data) => {
const verlauf = verlaufOfStudent(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;
});
});
// Auch eine kommissionelle Prüfung ohne zählende Note schliesst die Kette. Sonst liesse sich
// nach einer noch unbenoteten kommissionellen ein weiterer Antritt anlegen.
it("sperrt weitere Antritte auch bei einer kommPruef ohne zählende Note", () => {
const student = studentFor(1);
givenBaseline(ctx, student);
seedPruefung(ctx, student, {
note: ctx.notes.nochNichtEingetragen,
datum: attemptDate(ctx, 1),
typ: "kommPruef",
});
readState(ctx).then((data) => {
const verlauf = verlaufOfStudent(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.gradeNotes[0], datum: attemptDate(ctx, 2) }).then(
(response) => expectNotenError(response, "maxAntritteReached"),
);
});
it("refuses a further attempt once a kommPruef exists", () => {
const student = studentFor(0);
@@ -295,8 +346,7 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
return;
}
// the students of this LV carry their Zeugnisnote from the fixture; pick one that has an
// Anrechnung, otherwise there is nothing to assert here
// braucht einen Studenten, dessen Fixture-Zeugnisnote eine Anrechnung ist
readState(ctx).then((data) => {
const target = (data[0] || []).find((s) => String(s.note) === String(angerechnet));
if (!target) {
@@ -316,10 +366,7 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
});
describe("edit guards", () => {
/**
* Builds: Antritt 1 (baseline date) < excused (attempt 1) < real grade (attempt 2)
* and yields the excused row, which now has both an earlier and a later neighbour.
*/
/** Antritt 1 < entschuldigt < echte Note; liefert die entschuldigte Zeile (Nachbarn beidseits). */
const givenThreeAttempts = (student) => {
givenBaseline(ctx, student);
@@ -371,9 +418,8 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
});
});
// The add-time limit is covered by Rule C; this is the edit-time re-check. Its converse -
// re-saving the excused row itself, which the exclusion must let through - is the date-only
// correction above.
// Regel C beim Bearbeiten; die Gegenprobe (die entschuldigte Zeile selbst neu speichern)
// ist die reine Datumskorrektur oben
it("rejects an edit that would exceed the entschuldigt limit", () => {
const student = studentFor(0);
@@ -1,10 +1,8 @@
/**
* savePruefungFuerStudent / savePruefungstermin - the write path shared by saveStudentPruefung,
* createPruefungen and savePruefungenBulk. The other specs cover the validators; this one covers
* the writer.
* Der Schreibpfad (savePruefungFuerStudent), den saveStudentPruefung, createPruefungen und
* savePruefungenBulk teilen. Die Validatoren decken die anderen Specs ab.
*
* Core invariant since the Prüfungsverlauf refactor: one action writes exactly one Prüfung. The
* former Termin1 snapshot is gone - Antritt 1 is created by the password-gated Freigabe.
* Invariante: eine Aktion schreibt genau eine Prüfung; Antritt 1 entsteht bei der Freigabe.
*/
import { expectNotenSuccess } from "../../../../support/helpers/notenErrors";
@@ -107,8 +105,7 @@ describe("Noten API - Prüfungstermin (write path)", () => {
});
it("appends a new row per add instead of overwriting the previous one", () => {
// The old writer re-derived its target as "the first non-excused Termin2" and updated it.
// Now only an explicit pruefung_id updates; an add always inserts.
// nur eine explizite pruefung_id aktualisiert, ein Add fügt immer ein
const student = studentFor(0);
givenBaseline(ctx, student);
@@ -171,9 +168,8 @@ describe("Noten API - Prüfungstermin (write path)", () => {
});
it("adds an attempt while the LV note is still offen", () => {
// getLvGesamtNoten filters `freigabedatum < NOW()`. The writer must read the LV note through
// the UNFILTERED getter, otherwise it takes the INSERT branch against an existing primary key
// and answers "keine LV-Note eingetragen" forever.
// liest der Schreibpfad gefiltert, sieht er die Note nicht, versucht ein INSERT gegen den
// bestehenden PK und antwortet für immer "keine LV-Note eingetragen"
const student = studentFor(3);
givenBaseline(ctx, student, { freigegeben: false });
@@ -212,8 +208,7 @@ describe("Noten API - Prüfungstermin (write path)", () => {
});
it("creates the LV note when a student has none yet", () => {
// Previously this answered c4keineLvNoteEingetragen. The bulk path now runs the same core as
// the single dialog, which writes the LV note before the Prüfung.
// der Bulk-Pfad läuft durch denselben Kern und schreibt die LV-Note vor dem Termin
const student = studentFor(0);
resetNotenState(ctx);
@@ -1,26 +1,19 @@
/**
* Overwrite rules for the Notenvorschlag column - all of them client-side only.
* Overwrite-Regeln der Notenvorschlag-Spalte.
*
* CIS_GESAMTNOTE_UEBERSCHREIBEN is exposed by getCisConfig but read by nobody in CIS4; only the
* What the CIS4 tool enforces sits in Benotungstool.js and never reaches the server:
* - the editor list offers lehre notes only (:1925), so 'nicht zugelassen' can never be picked
* - the column is non-editable once the student has any Prüfung (:1017)
*
* saveNotenvorschlag applies neither, so both tests are expected RED: a direct API call bypasses
* the rules. Assertions read the row back through the DB, since getLvGesamtNoten hides
* un-freigegebene notes.
*
* A third rule - lkt_ueberschreibbar on the student's Zeugnisnote (:1019) - is not covered here:
* it needs a seeded lehre.tbl_zeugnisnote row and the test DB role has no rights on that table.
* Drei Regeln: die Editorliste bietet nur lehre-Noten an, die Spalte ist gesperrt, sobald eine
* Prüfung existiert, und eine Zeugnisnote mit lkt_ueberschreibbar = false sperrt sie ebenfalls.
* Alle drei stehen in Benotungstool.js UND in Noten::validateNotenvorschlag - der direkte
* API-Aufruf und der CSV-Import erreichen den Client nie.
*/
import { expectNotenSuccess } from "../../../../support/helpers/notenErrors";
import { expectBulkRowError, expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
import {
attemptDate,
loadNotenContext,
readLvGesamtnote,
requireDbReset,
seedZeugnisnote,
} from "../../../../support/helpers/notenTestData";
import { addPruefung, givenBaseline } from "../../../../support/helpers/notenScenario";
import { notenApi } from "../../../../support/api/notenApi";
@@ -44,10 +37,12 @@ describe("Noten API - Notenvorschlag overwrite rules", () => {
const student = studentFor(0);
givenBaseline(ctx, student);
// ohne Erstantritt: sonst greift die Prüfungsregel und die Notenregel bliebe ungeprüft
givenBaseline(ctx, student, { erstantritt: false });
notenApi
.saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.notes.nichtLehre)
.then((response) => expectNotenError(response, "c4noteNichtInLehre"))
.then(() => readLvGesamtnote(ctx, student.uid))
.then((row) => {
expect(String(row.note), "a non-lehre note must not become the LV note").to.eq(
@@ -68,6 +63,7 @@ describe("Noten API - Notenvorschlag overwrite rules", () => {
// the grade now belongs to the attempt history; a direct edit bypasses the Antritt rules
notenApi
.saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.gradeNotes[0])
.then((response) => expectNotenError(response, "c4notenvorschlagGesperrt"))
.then(() => readLvGesamtnote(ctx, student.uid))
.then((row) => {
expect(String(row.note), "the attempt's grade must survive the call").to.eq(
@@ -76,20 +72,52 @@ describe("Noten API - Notenvorschlag overwrite rules", () => {
});
});
// Same hole through the CSV import, which is the path an Assistenz actually drives.
// saveNotenvorschlagBulk validates nothing at all, so these are RED for the same reason.
it("refuses to overwrite a locked Zeugnisnote", function () {
if (ctx.notes.nichtUeberschreibbar === null) {
this.skip(); // every active note allows the teacher to overwrite it on this instance
}
const student = studentFor(4);
givenBaseline(ctx, student, { erstantritt: false });
seedZeugnisnote(ctx, student.uid, ctx.notes.nichtUeberschreibbar);
notenApi
.saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.gradeNotes[1])
.then((response) => expectNotenError(response, "c4zeugnisnoteGesperrt"))
.then(() => readLvGesamtnote(ctx, student.uid))
.then((row) => {
expect(String(row.note), "the LV note must not change").to.eq(String(ctx.gradeNotes[0]));
});
});
// Derselbe Weg über den CSV-Import, den eine Assistenz tatsächlich fährt. Der Bulk-Endpunkt
// antwortet 200 und meldet die abgelehnte Zeile in data[uid].
describe("saveNotenvorschlagBulk", () => {
beforeEach(function () {
if (ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) {
// im Punktemodus leitet der Import die Note aus den Punkten ab und verwirft eine
// Zeile ohne Punkte, bevor eine dieser Regeln greift
cy.log("Skipped: CIS_GESAMTNOTE_PUNKTE ist aktiv.");
this.skip();
}
});
it("refuses a note the editor list never offers", function () {
if (ctx.notes.nichtLehre === null) this.skip();
const student = studentFor(2);
givenBaseline(ctx, student);
givenBaseline(ctx, student, { erstantritt: false });
notenApi
.saveNotenvorschlagBulk(ctx.lvId, ctx.semKurzbz, [
{ uid: student.uid, note: ctx.notes.nichtLehre, punkte: null },
])
.then((response) => {
const data = expectNotenSuccess(response, "saveNotenvorschlagBulk");
expectBulkRowError(data, student.uid, "c4noteNichtInLehre");
})
.then(() => readLvGesamtnote(ctx, student.uid))
.then((row) => {
expect(String(row.note), "a non-lehre note must not become the LV note").to.eq(
@@ -98,6 +126,30 @@ describe("Noten API - Notenvorschlag overwrite rules", () => {
});
});
it("refuses to overwrite a locked Zeugnisnote", function () {
if (ctx.notes.nichtUeberschreibbar === null) this.skip();
const student = studentFor(5);
givenBaseline(ctx, student, { erstantritt: false });
seedZeugnisnote(ctx, student.uid, ctx.notes.nichtUeberschreibbar);
notenApi
.saveNotenvorschlagBulk(ctx.lvId, ctx.semKurzbz, [
{ uid: student.uid, note: ctx.gradeNotes[1], punkte: null },
])
.then((response) => {
const data = expectNotenSuccess(response, "saveNotenvorschlagBulk");
expectBulkRowError(data, student.uid, "c4zeugnisnoteGesperrt");
})
.then(() => readLvGesamtnote(ctx, student.uid))
.then((row) => {
expect(String(row.note), "the LV note must not change").to.eq(
String(ctx.gradeNotes[0]),
);
});
});
it("refuses to change the Notenvorschlag once a Prüfung exists", () => {
const student = studentFor(3);
@@ -113,6 +165,10 @@ describe("Noten API - Notenvorschlag overwrite rules", () => {
.saveNotenvorschlagBulk(ctx.lvId, ctx.semKurzbz, [
{ uid: student.uid, note: ctx.gradeNotes[0], punkte: null },
])
.then((response) => {
const data = expectNotenSuccess(response, "saveNotenvorschlagBulk");
expectBulkRowError(data, student.uid, "c4notenvorschlagGesperrt");
})
.then(() => readLvGesamtnote(ctx, student.uid))
.then((row) => {
expect(String(row.note), "the attempt's grade must survive the import").to.eq(
@@ -0,0 +1,120 @@
import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
import {
loadNotenContext,
readLvGesamtnote,
requireDbReset,
resetNotenState,
seedBaseline,
} from "../../../../support/helpers/notenTestData";
/**
* Notenfreigabe über den Modal-Dialog mit Passwort.
*
* Der Dialog listet genau die Zeilen, die freigegeben werden (changedNoten), und verlangt das
* LDAP-Passwort. Danach muss die Statusspalte ohne Reload von changed auf ok springen.
*
* Ein erfolgreiches Speichern verschickt IMMER die Freigabemail - dieselbe Sperre wie in
* noten.freigabe: opt-in über NOTEN_FREIGABE_ENABLED. Dialogansicht und Passwortablehnung
* schreiben nichts und laufen daher immer.
*/
const freigabeEnabled = () => String(Cypress.env("NOTEN_FREIGABE_ENABLED")).toLowerCase() === "true";
const freigabePassword = () => Cypress.env("NOTEN_FREIGABE_PASSWORD") || Cypress.env("adminpassword");
context("Benotungstool UI - Notenfreigabe", () => {
let ctx;
let bezeichnung;
before(() => {
requireDbReset();
loadNotenContext().then((context) => {
ctx = context;
bezeichnung = (note) => page.bezeichnungOf(ctx, note);
});
});
describe("Dialog", () => {
it("listet die noch nicht freigegebene Note mit ihrer Zielnote", () => {
const student = ctx.students[0];
resetNotenState(ctx);
seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: false });
page.visitAndWaitForTable(ctx);
page.expectFreigabeState(student.uid, "changed");
page.openFreigabeModal();
page.expectFreigabeSummaryRow(student.uid, bezeichnung(ctx.gradeNotes[0]));
});
it("meldet eine leere Auswahl, wenn nichts freizugeben ist", () => {
resetNotenState(ctx);
page.visitAndWaitForTable(ctx);
page.openFreigabeModal();
cy.get("[data-cy='freigabe-summary-empty']").should("be.visible");
});
it("lehnt ein falsches Passwort ab und lässt den Status unverändert", () => {
const student = ctx.students[0];
resetNotenState(ctx);
seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: false });
page.visitAndWaitForTable(ctx);
page.openFreigabeModal();
page.typeFreigabePasswort("definitely-not-the-password");
page.submitFreigabe();
cy.wait("@saveStudentenNoten");
readLvGesamtnote(ctx, student.uid).then((rowData) => {
expect(rowData.freigabedatum, "eine abgelehnte Freigabe stempelt nichts").to.be.null;
});
});
});
describe("Freigabe (verschickt Mail - opt in über NOTEN_FREIGABE_ENABLED)", () => {
beforeEach(function () {
if (!freigabeEnabled()) {
cy.log(
"Skipped: die Freigabe verschickt die Notenfreigabe-Mail. NOTEN_FREIGABE_ENABLED=true " +
"nur auf einer Umgebung setzen, auf der das harmlos ist.",
);
this.skip();
}
});
it("gibt frei und schaltet die Statusspalte auf ok", () => {
const student = ctx.students[0];
resetNotenState(ctx);
seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: false });
page.visitAndWaitForTable(ctx);
page.expectFreigabeState(student.uid, "changed");
page.openFreigabeModal();
page.freigeben(freigabePassword());
page.expectFreigabeState(student.uid, "ok");
page.expectLvNote(student.uid, bezeichnung(ctx.gradeNotes[0]));
});
it("legt mit der Freigabe den ersten Antritt an", () => {
const student = ctx.students[1];
resetNotenState(ctx);
seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: false });
page.visitAndWaitForTable(ctx);
page.openFreigabeModal();
page.freigeben(freigabePassword());
// upsertErstantritt schreibt die Prüfungszeile; sichtbar wird sie beim nächsten Laden
page.visitAndWaitForTable(ctx);
page.expectPruefung(student.uid, "antritt_1", { note: ctx.gradeNotes[0], antritt: 1 });
page.expectAntrittCount(student.uid, 1);
});
});
});
@@ -0,0 +1,122 @@
import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
import {
attemptDate,
loadNotenContext,
requireDbReset,
resetNotenState,
seedBaseline,
} from "../../../../support/helpers/notenTestData";
/**
* Die beiden Importe aus der Toolbar.
*
* Notenimport: "UID<TAB>Note" je Zeile, schreibt nur die LV-Note.
* Prüfungsimport: "UID<TAB>dd.MM.yyyy<TAB>Note" je Zeile, legt zusätzlich den Termin an.
*
* Beide sind über CIS_GESAMTNOTE_NOTENIMPORT / CIS_GESAMTNOTE_PRUEFUNGSIMPORT konfigurierbar - ist
* der Import aus, existiert der Button nicht und der Block wird übersprungen.
*/
context("Benotungstool UI - Import", () => {
let ctx;
let bezeichnung;
before(() => {
requireDbReset();
loadNotenContext().then((context) => {
ctx = context;
bezeichnung = (note) => page.bezeichnungOf(ctx, note);
});
});
beforeEach(function () {
if (ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) {
// im Punktemodus erwarten beide Importe Punkte statt einer Note
cy.log("Skipped: CIS_GESAMTNOTE_PUNKTE ist aktiv.");
this.skip();
}
});
describe("Notenimport", () => {
beforeEach(function () {
if (!ctx.cisConfig.CIS_GESAMTNOTE_NOTENIMPORT) {
cy.log("Skipped: CIS_GESAMTNOTE_NOTENIMPORT ist aus, der Button existiert nicht.");
this.skip();
}
});
it("schreibt die LV-Note für jede Zeile", () => {
const [a, b] = ctx.students;
resetNotenState(ctx);
page.visitAndWaitForTable(ctx);
page.importNoten([
[a.uid, ctx.gradeNotes[0]],
[b.uid, ctx.gradeNotes[1]],
]);
page.expectLvNote(a.uid, bezeichnung(ctx.gradeNotes[0]));
page.expectLvNote(b.uid, bezeichnung(ctx.gradeNotes[1]));
// erfasst, aber nicht freigegeben
page.expectFreigabeState(a.uid, "changed");
page.expectFreigabeState(b.uid, "changed");
});
it("legt dabei keine Prüfung an", () => {
const student = ctx.students[0];
resetNotenState(ctx);
page.visitAndWaitForTable(ctx);
page.importNoten([[student.uid, ctx.gradeNotes[0]]]);
// die LV-Note zählt als impliziter erster Antritt, ohne dass eine Zeile entsteht
page.expectAntrittCount(student.uid, 1);
page.expectKeinePruefung(student.uid, "antritt_1");
});
});
describe("Prüfungsimport", () => {
beforeEach(function () {
if (!ctx.cisConfig.CIS_GESAMTNOTE_PRUEFUNGSIMPORT) {
cy.log("Skipped: CIS_GESAMTNOTE_PRUEFUNGSIMPORT ist aus, der Button existiert nicht.");
this.skip();
}
});
it("legt je Zeile einen datierten Antritt an", () => {
const [a, b] = ctx.students;
const datum = page.toDDMMYYYY(attemptDate(ctx, 1));
resetNotenState(ctx);
seedBaseline(ctx, a.uid, { note: ctx.gradeNotes[0], freigegeben: true });
seedBaseline(ctx, b.uid, { note: ctx.gradeNotes[0], freigegeben: true });
page.visitAndWaitForTable(ctx);
page.importPruefungen([
[a.uid, datum, ctx.gradeNotes[1]],
[b.uid, datum, ctx.gradeNotes[1]],
]);
[a, b].forEach((student) => {
page.expectPruefung(student.uid, "antritt_2", { note: ctx.gradeNotes[1], antritt: 2 });
page.expectAntrittCount(student.uid, 2);
page.expectLvNote(student.uid, bezeichnung(ctx.gradeNotes[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.toDDMMYYYY(attemptDate(ctx, 1)), ctx.gradeNotes[0]]]);
page.expectPruefung(student.uid, "antritt_1", { note: ctx.gradeNotes[0], antritt: 1 });
page.expectLvNote(student.uid, bezeichnung(ctx.gradeNotes[0]));
});
});
});
@@ -0,0 +1,101 @@
import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
import {
loadNotenContext,
requireDbReset,
resetNotenState,
seedBaseline,
} from "../../../../support/helpers/notenTestData";
/**
* Notenvorschlag eintragen und übernehmen.
*
* Die Regeln dahinter prüft noten.notenvorschlag; hier geht es darum, dass die Tabelle den
* Serverzustand ohne Reload korrekt nachführt: LV-Note, Freigabestatus und das Verschwinden des
* Übernehmen-Buttons.
*/
context("Benotungstool UI - Notenvorschlag", () => {
let ctx;
let bezeichnung;
before(() => {
requireDbReset();
loadNotenContext().then((context) => {
ctx = context;
bezeichnung = (note) => page.bezeichnungOf(ctx, note);
});
});
beforeEach(function () {
if (ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) {
// im Punktemodus ist die Vorschlagsspalte gesperrt, die Note kommt aus den Punkten
cy.log("Skipped: CIS_GESAMTNOTE_PUNKTE ist aktiv.");
this.skip();
}
});
it("trägt einen Vorschlag über den Zelleneditor ein", () => {
const student = ctx.students[0];
resetNotenState(ctx);
page.visitAndWaitForTable(ctx);
// ohne Note ist die Zeile leer: kein Benotungsdatum -> offen
page.expectFreigabeState(student.uid, "offen");
page.setNotenvorschlag(student.uid, bezeichnung(ctx.gradeNotes[0]));
page.expectNotenvorschlag(student.uid, bezeichnung(ctx.gradeNotes[0]));
// der Vorschlag allein schreibt noch keine LV-Note
page.expectFreigabeState(student.uid, "offen");
});
it("übernimmt den Vorschlag als LV-Note und zeigt den Status changed", () => {
const student = ctx.students[0];
resetNotenState(ctx);
page.visitAndWaitForTable(ctx);
page.setNotenvorschlag(student.uid, bezeichnung(ctx.gradeNotes[0]));
page.uebernehmen(student.uid);
page.expectLvNote(student.uid, bezeichnung(ctx.gradeNotes[0]));
page.expectFreigabeState(student.uid, "changed");
});
it("blendet den Übernehmen-Button aus, sobald Vorschlag und LV-Note übereinstimmen", () => {
const student = ctx.students[0];
resetNotenState(ctx);
page.visitAndWaitForTable(ctx);
page.setNotenvorschlag(student.uid, bezeichnung(ctx.gradeNotes[0]));
page.uebernehmen(student.uid);
page.getUebernehmenButton(student.uid).should("not.exist");
});
it("zeigt eine bereits freigegebene Note nach dem Laden als ok", () => {
const student = ctx.students[1];
resetNotenState(ctx);
seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: true });
page.visitAndWaitForTable(ctx);
page.expectLvNote(student.uid, bezeichnung(ctx.gradeNotes[0]));
page.expectFreigabeState(student.uid, "ok");
});
it("zeigt eine erfasste, nicht freigegebene Note nach dem Laden als changed", () => {
const student = ctx.students[1];
resetNotenState(ctx);
seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: false });
page.visitAndWaitForTable(ctx);
// getStudentenNoten liest ungefiltert - wäre das nicht so, bliebe die Zeile hier leer
page.expectLvNote(student.uid, bezeichnung(ctx.gradeNotes[0]));
page.expectFreigabeState(student.uid, "changed");
});
});
@@ -0,0 +1,206 @@
import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
import {
attemptDate,
loadNotenContext,
requireDbReset,
resetNotenState,
seedBaseline,
seedPruefung,
} from "../../../../support/helpers/notenTestData";
/**
* Prüfungen anlegen und bearbeiten, über beide Wege: den Dialog aus der Tabellenzelle und die
* Sammelanlage aus der Toolbar. Beide laufen serverseitig durch denselben Kern
* (siehe noten.pruefungstermin); geprüft wird, dass die Zelle danach ohne Reload stimmt.
*
* Die Specs laufen im Antrittsmodus, weil die Spaltennamen dort stabil sind: antritt_1, antritt_2,
* ... Im Datumsmodus heisst die Spalte wie das Prüfungsdatum.
*/
context("Benotungstool UI - Prüfungen", () => {
let ctx;
let bezeichnung;
before(() => {
requireDbReset();
loadNotenContext().then((context) => {
ctx = context;
bezeichnung = (note) => page.bezeichnungOf(ctx, note);
expect(ctx.maxAntritte, "braucht Platz für mindestens eine Wiederholung").to.be.greaterThan(1);
});
});
beforeEach(function () {
if (ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) {
// im Punktemodus ist das Notenfeld gesperrt, die Note kommt aus dem Notenschlüssel
cy.log("Skipped: CIS_GESAMTNOTE_PUNKTE ist aktiv.");
this.skip();
}
});
it("zeigt den von der Freigabe angelegten ersten Antritt", () => {
const student = ctx.students[0];
resetNotenState(ctx);
seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: true });
page.visitAndWaitForTable(ctx);
page.expectPruefung(student.uid, "antritt_1", { note: ctx.gradeNotes[0], antritt: 1 });
page.expectAntrittCount(student.uid, 1);
});
it("legt aus der Zelle eine Wiederholung an und zählt sie als Antritt 2", () => {
const student = ctx.students[0];
resetNotenState(ctx);
seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: true });
page.visitAndWaitForTable(ctx);
page.addPruefungInCell(student.uid, "antritt_2", {
note: bezeichnung(ctx.gradeNotes[1]),
datum: page.toDDMMYYYY(attemptDate(ctx, 1)),
});
page.expectPruefung(student.uid, "antritt_2", { note: ctx.gradeNotes[1], antritt: 2 });
page.expectAntrittCount(student.uid, 2);
// die LV-Note folgt dem neuesten Antritt und ist damit wieder unfreigegeben
page.expectLvNote(student.uid, bezeichnung(ctx.gradeNotes[1]));
page.expectFreigabeState(student.uid, "changed");
});
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-ohne-lvnote']").should("be.visible");
cy.get("[data-cy='pruefung-submit']").click();
page.getPruefungModal().should("not.be.visible");
// ohne gewählte Note entsteht "Noch nicht eingetragen" - das verbraucht keinen Antritt
page.expectPruefung(student.uid, "antritt_1", { note: ctx.notes.nochNichtEingetragen });
page.expectLvNote(student.uid, bezeichnung(ctx.notes.nochNichtEingetragen));
page.expectAntrittCount(student.uid, 0);
});
it("korrigiert nur das Datum eines bestehenden Antritts", () => {
const student = ctx.students[0];
const neuesDatum = attemptDate(ctx, 2);
resetNotenState(ctx);
seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: true });
page.visitAndWaitForTable(ctx);
page.addPruefungInCell(student.uid, "antritt_2", {
note: bezeichnung(ctx.gradeNotes[1]),
datum: page.toDDMMYYYY(attemptDate(ctx, 1)),
});
page.editPruefungInCell(student.uid, "antritt_2", { datum: page.toDDMMYYYY(neuesDatum) });
page.expectPruefung(student.uid, "antritt_2", { note: ctx.gradeNotes[1], antritt: 2 });
page.getCell(student.uid, "antritt_2").should("contain.text", page.toDDMMYYYY(neuesDatum));
});
it("sperrt die Note, sobald ein späterer Antritt existiert", () => {
const student = ctx.students[0];
resetNotenState(ctx);
seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: true });
page.visitAndWaitForTable(ctx);
// entschuldigt zuerst: verbraucht keinen Antritt, daher bleibt die dritte Spalte auch bei
// maxAntritte = 2 erreichbar
page.addPruefungInCell(student.uid, "antritt_2", {
note: bezeichnung(ctx.notes.entschuldigt),
datum: page.toDDMMYYYY(attemptDate(ctx, 1)),
});
page.addPruefungInCell(student.uid, "antritt_3", {
note: bezeichnung(ctx.gradeNotes[1]),
datum: page.toDDMMYYYY(attemptDate(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", () => {
const [a, b] = ctx.students;
resetNotenState(ctx);
seedBaseline(ctx, a.uid, { note: ctx.gradeNotes[0], freigegeben: true });
seedBaseline(ctx, b.uid, { note: ctx.gradeNotes[0], freigegeben: true });
page.visitAndWaitForTable(ctx);
page.addPruefungBulk({
uids: [a.uid, b.uid],
note: bezeichnung(ctx.gradeNotes[1]),
datum: page.toDDMMYYYY(attemptDate(ctx, 1)),
});
[a, b].forEach((student) => {
page.expectPruefung(student.uid, "antritt_2", { note: ctx.gradeNotes[1], antritt: 2 });
page.expectAntrittCount(student.uid, 2);
page.expectLvNote(student.uid, bezeichnung(ctx.gradeNotes[1]));
});
});
it("legt den letzten Antritt als kommissionelle Prüfung an", () => {
const student = ctx.students[1];
resetNotenState(ctx);
seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: true });
page.visitAndWaitForTable(ctx);
// Baseline liefert Antritt 1. Bis zum vorletzten Antritt laufen die normalen Spalten.
for (let i = 1; i < ctx.maxAntritte - 1; i += 1) {
page.addPruefungInCell(student.uid, `antritt_${i + 1}`, {
note: bezeichnung(ctx.gradeNotes[0]),
datum: page.toDDMMYYYY(attemptDate(ctx, i)),
});
}
// Der letzte Antritt ist laut Prüfungsordnung kommissionell und wird daher in der
// kommPruef-Spalte angelegt, nicht in einer weiteren Antrittsspalte.
page.getPruefungAddButton(student.uid, "kommPruef").should("exist");
page.expectKeineAntrittsspalte(ctx.maxAntritte);
page.addPruefungInCell(student.uid, "kommPruef", {
note: bezeichnung(ctx.gradeNotes[0]),
datum: page.toDDMMYYYY(attemptDate(ctx, ctx.maxAntritte)),
});
page.expectAntrittCount(student.uid, ctx.maxAntritte);
page.expectPruefung(student.uid, "kommPruef", { note: ctx.gradeNotes[0], antritt: "K" });
});
it("bietet keinen weiteren Antritt an, sobald die Grenze erreicht ist", () => {
const student = ctx.students[2];
resetNotenState(ctx);
seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: true });
// die kommissionelle Prüfung schliesst die Kette; sie kommt aus dem anderen Tool
seedPruefung(ctx, student, {
note: ctx.gradeNotes[0],
datum: attemptDate(ctx, 1),
typ: "kommPruef",
});
page.visitAndWaitForTable(ctx);
page.getPruefungAddButton(student.uid, "kommPruef").should("not.exist");
page.expectKeineAntrittsspalte(2);
});
});
@@ -0,0 +1,197 @@
import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
import { requirePunkteModus } from "../../../../support/helpers/notenConfig";
import {
attemptDate,
loadNotenContext,
readLvGesamtnote,
requireDbReset,
resetNotenState,
seedBaseline,
} from "../../../../support/helpers/notenTestData";
import { notenApi } from "../../../../support/api/notenApi";
/**
* Punktemodus (CIS_GESAMTNOTE_PUNKTE) in der Oberfläche.
*
* Die Punktespalte und die Punktefelder der beiden Dialoge existieren nur mit diesem Flag, die Note
* wird dann aus dem Notenschlüssel abgeleitet statt gewählt. Genau diese Felder deckt sonst nichts
* ab, deshalb steht der ganze Modus hier in einer eigenen Datei.
*
* Der Notenschlüssel wird nie hartcodiert: die erwartete Note kommt zur Laufzeit aus
* getNoteByPunkte, damit die Specs an jeder Instanz mit eigenem Schlüssel laufen.
*/
context("Benotungstool UI - Punktemodus", () => {
let ctx;
// Punktewerte, deren Noten zur Laufzeit ermittelt werden
const OBEN = 100;
const MITTE = 70;
let noteOben;
let noteMitte;
before(() => {
requireDbReset();
loadNotenContext().then((context) => {
ctx = context;
if (!ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) return;
notenApi.getNoteByPunkte(OBEN, ctx.lvId, ctx.semKurzbz).then((r) => {
noteOben = r.body.data;
expect(noteOben, `${OBEN} Punkte müssen eine Note ergeben`).to.not.be.null;
});
notenApi.getNoteByPunkte(MITTE, ctx.lvId, ctx.semKurzbz).then((r) => {
noteMitte = r.body.data;
expect(noteMitte, `${MITTE} Punkte müssen eine Note ergeben`).to.not.be.null;
expect(String(noteMitte), "die beiden Punktewerte müssen verschiedene Noten liefern").to.not.eq(
String(noteOben),
);
});
});
});
beforeEach(function () {
requirePunkteModus(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");
// die Note kommt aus den Punkten, sie darf nicht direkt gewählt werden
page.expectNotenvorschlagGesperrt(student.uid);
});
it("leitet den Notenvorschlag beim Tippen aus den Punkten ab", () => {
const student = ctx.students[0];
resetNotenState(ctx);
page.visitAndWaitForTable(ctx);
page.setPunkteInCell(student.uid, OBEN);
page.expectNotenvorschlag(student.uid, page.bezeichnungOf(ctx, noteOben));
});
it("schreibt Note und Punkte, wenn der Vorschlag übernommen wird", () => {
const student = ctx.students[0];
resetNotenState(ctx);
page.visitAndWaitForTable(ctx);
page.setPunkteInCell(student.uid, MITTE);
page.uebernehmen(student.uid);
page.expectLvNote(student.uid, page.bezeichnungOf(ctx, noteMitte));
page.expectFreigabeState(student.uid, "changed");
readLvGesamtnote(ctx, student.uid).then((row) => {
expect(String(row.note), "abgeleitete Note").to.eq(String(noteMitte));
expect(Number(row.punkte), "die Punkte werden mitgeschrieben").to.eq(MITTE);
});
});
it("zeigt gespeicherte Punkte nach einem Reload wieder an", () => {
const student = ctx.students[0];
resetNotenState(ctx);
page.visitAndWaitForTable(ctx);
page.setPunkteInCell(student.uid, OBEN);
page.uebernehmen(student.uid);
page.visitAndWaitForTable(ctx);
page.expectPunkte(student.uid, OBEN);
page.expectLvNote(student.uid, page.bezeichnungOf(ctx, noteOben));
});
it("sperrt die Punktespalte, sobald ein Termin existiert", () => {
const student = ctx.students[1];
resetNotenState(ctx);
seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: true });
page.visitAndWaitForTable(ctx);
page.expectPunkteZelleGesperrt(student.uid);
});
});
describe("Prüfungsdialog", () => {
it("bietet ein Punktefeld statt der Notenauswahl", () => {
const student = ctx.students[1];
resetNotenState(ctx);
seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], 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.expectNoteFeldGesperrt("pruefung-note");
});
it("legt den Termin mit der aus den Punkten abgeleiteten Note an", () => {
const student = ctx.students[1];
resetNotenState(ctx);
seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: true });
page.visitAndWaitForTable(ctx);
page.getPruefungAddButton(student.uid, "antritt_2").click();
page.getPruefungModal().should("be.visible");
page.setDatum("pruefung-datum", page.toDDMMYYYY(attemptDate(ctx, 1)));
page.setPruefungPunkte(OBEN);
cy.get("[data-cy='pruefung-submit']").click();
cy.wait("@saveStudentPruefung").its("response.statusCode").should("eq", 200);
page.getPruefungModal().should("not.be.visible");
page.expectPruefung(student.uid, "antritt_2", { note: noteOben, antritt: 2 });
page.expectLvNote(student.uid, page.bezeichnungOf(ctx, noteOben));
});
});
describe("Sammelanlage", () => {
it("legt für mehrere Studierende Termine mit der abgeleiteten Note an", () => {
const [a, b] = ctx.students;
resetNotenState(ctx);
seedBaseline(ctx, a.uid, { note: ctx.gradeNotes[0], freigegeben: true });
seedBaseline(ctx, b.uid, { note: ctx.gradeNotes[0], freigegeben: true });
page.visitAndWaitForTable(ctx);
cy.get("[data-cy='btn-neue-pruefung']").click();
page.getNeuePruefungModal().should("be.visible");
// dort gibt es kein Notenfeld zum Auswählen, nur Punkte
cy.get("[data-cy='neue-pruefung-punkte']").should("be.visible");
page.expectNoteFeldGesperrt("neue-pruefung-note");
page.setDatum("neue-pruefung-datum", page.toDDMMYYYY(attemptDate(ctx, 1)));
page.setNeuePruefungPunkte(MITTE);
cy.get("[data-cy='neue-pruefung-studenten']").click();
[a, b].forEach((s) =>
cy.contains(".p-multiselect-panel .p-multiselect-item", s.uid).click(),
);
cy.get("[data-cy='neue-pruefung-studenten']").click();
cy.get(".p-multiselect-panel").should("not.exist");
cy.get("[data-cy='neue-pruefung-submit']").click();
cy.wait("@createPruefungen").its("response.statusCode").should("eq", 200);
page.getNeuePruefungModal().should("not.be.visible");
[a, b].forEach((s) => {
page.expectPruefung(s.uid, "antritt_2", { note: noteMitte, antritt: 2 });
page.expectLvNote(s.uid, page.bezeichnungOf(ctx, noteMitte));
});
});
});
});
@@ -0,0 +1,159 @@
/**
* Die §1-Regeln auf Clientseite. Pure functions - kein Server, keine Fixture.
*
* Die Regeln liegen im Server und erreichen den Client als `student.verlauf`; hier wird nur das
* Auswerten und der lokale Fallback (Verlauf noch nicht geladen) festgenagelt.
*/
import {
antrittCountStudent,
brauchtNeueLvNote,
canAddPruefung,
checkFreigabe,
maxAntrittCount,
} from "../../../../../public/js/components/Cis/Benotungstool/notenRules.js";
const ENTSCHULDIGT = 14;
const NOCH_NICHT = 9;
const NICHT_BEURTEILT = 7;
/** Mirrors getCisConfig: maxAntritte is derived server-side, the client only reads the number. */
const configWith = (maxAntritte) => ({
CIS_GESAMTNOTE_MAX_ANTRITTE: maxAntritte,
NOTEN_OHNE_ANTRITT: [NOCH_NICHT, ENTSCHULDIGT, NICHT_BEURTEILT],
});
const ours = configWith(2);
const notenOptions = [
{ note: 1, lehre: true },
{ note: 5, lehre: true },
{ note: ENTSCHULDIGT, lehre: true },
{ note: NOCH_NICHT, lehre: true },
{ note: NICHT_BEURTEILT, lehre: true }, // "Nicht beurteilt" - a grade on paper only
{ note: 20, lehre: false }, // "intern angerechnet" - no participation
];
const student = (props) => ({ pruefungen: [], note: null, hoechsterAntritt: 0, ...props });
/** A row whose Verlauf the server has already delivered. */
const withVerlauf = (verlauf, props = {}) => student({ verlauf: { maxAntritte: 2, ...verlauf }, ...props });
describe("Benotungstool rules (client)", () => {
describe("maxAntrittCount", () => {
it("reads the server-derived maximum", () => {
expect(maxAntrittCount(configWith(1)), "no retakes").to.eq(1);
expect(maxAntrittCount(ours), "our installation").to.eq(2);
expect(maxAntrittCount(configWith(3)), "a raised limit").to.eq(3);
});
it("falls back to a single attempt when the config is missing", () => {
expect(maxAntrittCount({}), "no key at all").to.eq(1);
expect(maxAntrittCount(null), "no config at all").to.eq(1);
});
});
describe("antrittCountStudent", () => {
it("takes the count from the Verlauf when the server delivered one", () => {
const s = withVerlauf({ antrittCount: 2 }, { note: 1, pruefungen: [{ note: 5 }] });
expect(antrittCountStudent(s, ours, notenOptions), "server wins over the local fallback").to.eq(2);
});
describe("local fallback (Verlauf not loaded yet)", () => {
it("does not count entschuldigt, 'noch nicht eingetragen' or 'nicht beurteilt'", () => {
// still just the original note: none of these consumed an attempt, which is what lets a
// second retake be entered
const s = student({
note: 1,
pruefungen: [{ note: ENTSCHULDIGT }, { note: NOCH_NICHT }, { note: NICHT_BEURTEILT }],
});
expect(antrittCountStudent(s, ours, notenOptions)).to.eq(1);
});
it("counts a real grade on a pruefung", () => {
const s = student({ note: 1, pruefungen: [{ note: ENTSCHULDIGT }, { note: 5 }] });
expect(antrittCountStudent(s, ours, notenOptions)).to.eq(1);
});
it("counts the original LV note as the first Antritt", () => {
expect(antrittCountStudent(student({ note: 1 }), ours, notenOptions)).to.eq(1);
});
it("does not count a non-lehre note as an Antritt", () => {
// "intern angerechnet" means no participation, so no attempt was used
expect(antrittCountStudent(student({ note: 20 }), ours, notenOptions)).to.eq(0);
});
it("does not count a 'Nicht beurteilt' note as an Antritt", () => {
expect(antrittCountStudent(student({ note: NICHT_BEURTEILT }), ours, notenOptions)).to.eq(0);
});
it("counts nothing for a student without any grade", () => {
expect(antrittCountStudent(student({}), ours, notenOptions)).to.eq(0);
});
// notenOptions holds only ACTIVE notes; an unknown note must not take the grid render with it
it("survives a note that is no longer an active option", () => {
expect(() => antrittCountStudent(student({ note: 999 }), ours, notenOptions)).to.not.throw();
});
});
});
describe("canAddPruefung", () => {
it("follows the Verlauf", () => {
expect(canAddPruefung(withVerlauf({ canAdd: true, antrittCount: 1 }), ours)).to.be.true;
expect(canAddPruefung(withVerlauf({ canAdd: false, antrittCount: 2 }), ours)).to.be.false;
});
it("blocks an angerechnet row even though attempts are left", () => {
// the server sets canAdd:false for an Anrechnung; the client must not second-guess it
const s = withVerlauf({ canAdd: false, antrittCount: 0, angerechnet: true });
expect(canAddPruefung(s, ours)).to.be.false;
});
it("falls back to the cap and the terminal attempt without a Verlauf", () => {
expect(canAddPruefung(student({ note: 1 }), ours), "one attempt of two used").to.be.true;
expect(
canAddPruefung(student({ note: 1, pruefungen: [{ note: 5 }, { note: 4 }] }), ours),
"cap reached",
).to.be.false;
expect(
canAddPruefung(student({ note: 1, kommPruef: { note: 5 } }), ours),
"terminal attempt exists",
).to.be.false;
});
// Bekannte Lücke, hier festgenagelt: die LV-Note zählt nur solange gar keine Prüfung
// existiert. Deshalb schreibt die Freigabe Antritt 1 als echte Zeile.
it("stops counting the LV note once a Prüfung exists", () => {
const s = student({ note: 1, pruefungen: [{ note: 5 }] });
expect(antrittCountStudent(s, ours, notenOptions), "the LV note is no longer added").to.eq(1);
expect(canAddPruefung(s, ours), "so another attempt still looks available").to.be.true;
});
});
describe("brauchtNeueLvNote", () => {
it("uses hatLvNote from the Verlauf, not the displayed lv_note", () => {
// hatLvNote ist massgeblich, lv_note kann noch leer sein
const entered = withVerlauf({ hatLvNote: true }, { lv_note: null });
expect(brauchtNeueLvNote(entered), "an existing note must not read as missing").to.be.false;
const missing = withVerlauf({ hatLvNote: false }, { lv_note: null });
expect(brauchtNeueLvNote(missing)).to.be.true;
});
it("falls back to lv_note without a Verlauf", () => {
expect(brauchtNeueLvNote(student({ lv_note: 1 }))).to.be.false;
expect(brauchtNeueLvNote(student({ lv_note: null }))).to.be.true;
});
});
describe("checkFreigabe", () => {
it("derives the state from the two timestamps", () => {
expect(checkFreigabe(null, null), "no grade at all").to.eq("offen");
expect(checkFreigabe(null, "2026-01-10 08:00:00"), "never released").to.eq("changed");
expect(checkFreigabe("2026-01-10 08:00:00", "2026-01-20 08:00:00"), "re-graded").to.eq("changed");
expect(checkFreigabe("2026-01-10 08:00:00", "2026-01-10 08:00:00"), "unchanged").to.eq("ok");
});
});
});
@@ -0,0 +1,112 @@
BENOTUNGSTOOL - TESTSUITE
=========================
WARNUNG: Die Suite löscht und schreibt Noten in der Datenbank. Starte sie nur gegen eine
Testinstanz.
1. WAS HIER LIEGT
-----------------
e2e/specs/unit/ Regeln als reine Funktion. Kein Server, keine Datenbank.
e2e/specs/api/noten/ Die API. Ein Test ruft einen Endpunkt auf und prüft die Antwort.
e2e/specs/ui/ Die Oberfläche im Browser.
support/api/ Die API-Aufrufe an einer Stelle. Spiegelt public/js/api/factory/noten.js.
support/pages/ Die Oberfläche an einer Stelle. Selektoren und Klickwege.
support/helpers/ Testdaten, Fehlermeldungen, Fixture-Reset.
tasks/ Datenbankverbindung und SSH-Tunnel. Läuft in Node, nicht im Browser.
suites/noten.js Verbindet die Suite mit cypress.config.js.
tools/dbCheck.js Prüft die Fixture ohne HTTP. Gutes erstes Kommando bei Problemen.
Die Konfiguration steht in cypress.config.js. Die Zugangsdaten stehen in
tests/cypress/.env.
2. VORBEREITUNG
---------------
Einmalig:
npm install
Dann die Zugangsdaten anlegen:
cp tests/cypress/.env.example tests/cypress/.env
Fülle die Datei aus. Die Kommentare darin erklären jeden Wert. Drei Punkte sind wichtig:
- .env darf nie in Git landen.
- USER_NAME ist der LEKTOR, nicht ein Administrator. Ein Administrator unterrichtet meist nichts,
dann findet die Suite keine Lehrveranstaltung.
- Von einer Arbeitsstation aus brauchst du den SSH-Tunnel. Die Datenbank nimmt nur den
Applikationsserver an. Setze NOTEN_SSH_TUNNEL=true und die drei NOTEN_SSH_*-Werte.
Beide Seiten müssen auf dieselbe Datenbank zeigen: die Webinstanz (config/system.config.inc.php,
DB_NAME) und die Suite (NOTEN_DB_NAME in .env). Sonst seedet die Suite die eine Datenbank und prüft
gegen die andere.
3. STARTEN
----------
npm run noten:check Fixture prüfen.
npm run cy:noten:api Unit- und API-Tests.
npm run cy:noten:ui Oberfläche in Chrome.
npm run cy:open Cypress interaktiv.
Zwei weitere Skripte prüfen den Punktemodus:
npm run cy:noten:punkte-on erwartet CIS_GESAMTNOTE_PUNKTE = true
npm run cy:noten:punkte-off erwartet CIS_GESAMTNOTE_PUNKTE = false
Passt die Instanz nicht zur Erwartung, bricht der Lauf sofort ab.
4. DAS ERGEBNIS
---------------------
Am Ende steht eine Tabelle mit vier Spalten.
Passing Der Test lief und war erfolgreich.
Failing Der Test lief und fand einen Fehler. Nur das ist ein Problem.
Pending Der Test hat sich selbst übersprungen. Das ist kein Fehler.
Skipped Ein Test lief nicht, weil vorher etwas abgebrochen ist.
Pending ist normal. Ein Test überspringt sich, wenn seine Voraussetzung fehlt. Beispiele: der
Punktemodus ist aus, die Frist ist nicht aktiv, die Freigabemail ist nicht erlaubt. Den Grund
schreibt der Test ins Protokoll.
Der aktuelle Sollzustand: API und Unit 84 Tests, 69 grün, 15 pending. UI 30 Tests, 18 grün,
12 pending. Kein roter Test.
5. WENN ETWAS SCHIEFGEHT
------------------------
401 bei allen Tests
Die Webinstanz und .env zeigen auf verschiedene Datenbanken. Siehe Abschnitt 2.
"Fixture reset unavailable"
Die Datenbankverbindung fehlt. Prüfe den SSH-Tunnel. Einen Schlüssel mit einem Namen ausserhalb
der OpenSSH-Standardnamen probiert SSH nie automatisch. Trage ihn in NOTEN_SSH_KEY ein.
Ein Test erwartet eine Fehlermeldung und bekommt eine andere
Die Phrase fehlt in der Datenbank. Lass system/phrasesupdate.php auf dem Server laufen.
Der nächste Lauf startet nicht
Ein Cypress-Prozess hängt noch. Beende ihn: Stop-Process -Name Cypress -Force
Ein Screenshot zeigt eine leere Seite
Bei API-Tests ist das normal. Diese Tests öffnen keine Seite.
6. KONVENTIONEN
---------------
- Keine festen Ids im Test. Die Noten kommen aus getNoten, die Regelwerte aus getCisConfig. Eine
andere Installation hat andere Zahlen.
- Warte nach jeder schreibenden Aktion auf den Request, nicht auf die Oberfläche.
- Sprich die Oberfläche über data-cy an, nie über eine CSS-Klasse.
- Setze den Zustand vor dem Test über givenBaseline oder resetNotenState. Verlass dich nie auf den
Zustand, den ein Test davor hinterlassen hat.
+5 -16
View File
@@ -27,13 +27,10 @@ export const notenApi = {
getLvForStudiengang: (studiengang_kz, sem_kurzbz) =>
apiGet("getLvForStudiengang", { studiengang_kz, sem_kurzbz }),
/**
* data is a POSITIONAL array (Noten.php:431):
* [studenten, pruefungen, DOMAIN, grades-by-uid, anwesenheiten]
*/
/** data ist POSITIONAL: [studenten, pruefungen, DOMAIN, grades-by-uid, anwesenheiten] */
getStudentenNoten: (lv_id, sem_kurzbz) => apiGet("getStudentenNoten", { lv_id, sem_kurzbz }),
/** Reads through the UNFILTERED getter, so it sees notes getStudentenNoten hides. uid -> caller. */
/** Single-student read through the unfiltered getter. uid -> caller. */
getNotenvorschlagStudent: (lv_id, sem_kurzbz, uid = null) =>
apiGet("getNotenvorschlagStudent", { lv_id, sem_kurzbz, uid }),
@@ -41,11 +38,7 @@ export const notenApi = {
saveNotenvorschlag: (lv_id, sem_kurzbz, student_uid, note, punkte = null) =>
apiPost("saveNotenvorschlag", { lv_id, sem_kurzbz, student_uid, note, punkte }),
/**
* data -> [savedPruefung, lvgesamtnote, verlauf]
* No `typ` on the wire: which attempt a Prüfung becomes is derived server-side from the
* existing Prüfungsverlauf (PruefungsverlaufLib).
*/
/** data -> [savedPruefung, lvgesamtnote, verlauf]. Kein `typ` auf der Leitung. */
saveStudentPruefung: ({
student_uid, note, punkte = null, datum, lva_id, lehreinheit_id, sem_kurzbz, pruefung_id = null,
}) =>
@@ -60,8 +53,7 @@ export const notenApi = {
getNoteByPunkte: (punkte, lv_id, sem_kurzbz) =>
apiPost("getNoteByPunkte", { punkte, lv_id, sem_kurzbz }),
// Bulk paths answer 200 and put the error string in data[uid] per row - they never fail the
// whole request (Noten.php:1600/:1656).
// Bulk-Pfade antworten 200 und melden Fehler je Zeile in data[uid]
saveNotenvorschlagBulk: (lv_id, sem_kurzbz, noten) =>
apiPost("saveNotenvorschlagBulk", { lv_id, sem_kurzbz, noten }),
@@ -78,10 +70,7 @@ export const notenApi = {
export const pruefungenOf = (data, uid) =>
(data[1] || []).filter((p) => p.student_uid === uid);
/**
* Attempts in Verlauf order. position/zaehlt/antritt_nr/terminal are derived by the server; the
* specs assert on those and never on pruefungstyp_kurzbz, which is only a legacy projection.
*/
/** Antritte in Verlaufsreihenfolge. Specs prüfen position/zaehlt/antritt_nr/terminal, nie den Typ. */
export const attemptsOf = (data, uid) =>
[...pruefungenOf(data, uid)].sort((a, b) => Number(a.position) - Number(b.position));
+24 -2
View File
@@ -24,5 +24,27 @@ Cypress.Commands.add("checkPageResources", (url) => {
});
});
// don't fail tests on uncaught app exceptions
Cypress.on("uncaught:exception", () => false);
/**
* Session-Login für die UI-Specs.
* cy.session hält es über Specs hinweg, damit nicht jeder Test neu anmeldet.
*/
Cypress.Commands.add("login", () => {
const { adminusername, adminpassword } = Cypress.env();
const auth = { username: adminusername, password: adminpassword };
const probe = () =>
cy
.request({ url: "/index.ci.php/api/frontend/v1/AuthInfo/getAuthUID", auth })
.its("status")
.should("eq", 200);
cy.session(["benotungstool-login", adminusername], probe, {
cacheAcrossSpecs: true,
validate: probe,
});
});
// Anwendungsfehler sollen den Test nicht abbrechen, aber sichtbar sein.
Cypress.on("uncaught:exception", (err) => {
Cypress.log({ name: "app error", message: err.message, consoleProps: () => ({ error: err }) });
return false;
});
+2
View File
@@ -0,0 +1,2 @@
export const waitForOk = (alias) =>
cy.wait(alias).its("response.statusCode").should("eq", 200);
@@ -0,0 +1,49 @@
/**
* Konfigurationsabhängige Specs.
*
* CIS_GESAMTNOTE_PUNKTE ist ein PHP define() in config/global.config.inc.php und damit eine
* Eigenschaft der INSTANZ, nicht des Requests - ein Testlauf kann den Modus nicht umschalten.
* Die Suite deckt deshalb beide Modi ab und überspringt jeweils den unpassenden Teil.
*
* Für die Pipeline heisst das: zwei Jobs gegen je eine Instanz. Damit ein Job nicht grün meldet,
* weil in Wahrheit alles übersprungen wurde, gibt NOTEN_PUNKTE_MODUS ('on'|'off') die Erwartung
* vor - passt die Instanz nicht dazu, scheitert der Lauf sofort und laut.
*/
/** Ist der Punktemodus auf dieser Instanz aktiv? */
export const punkteModus = (ctx) => Boolean(ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE);
/**
* In before() aufrufen: prüft die Instanz gegen NOTEN_PUNKTE_MODUS. Ohne gesetzte Variable
* (lokaler Lauf) wird nur geloggt.
*/
export const assertPunkteModus = (ctx) => {
const erwartet = String(Cypress.env("NOTEN_PUNKTE_MODUS") || "").toLowerCase();
const ist = punkteModus(ctx);
if (erwartet !== "on" && erwartet !== "off") {
cy.log(`CIS_GESAMTNOTE_PUNKTE = ${ist} (NOTEN_PUNKTE_MODUS nicht gesetzt)`);
return;
}
expect(
ist,
`NOTEN_PUNKTE_MODUS=${erwartet}, die Instanz steht aber auf CIS_GESAMTNOTE_PUNKTE=${ist}. ` +
"Der Lauf würde sonst grün melden, obwohl der halbe Umfang übersprungen wurde. " +
"Flag sitzt in config/global.config.inc.php - cis.config.inc.php wird zu spät geladen.",
).to.eq(erwartet === "on");
};
/** Skip, wenn der Punktemodus aus ist. Als erste Zeile in beforeEach(function(){...}) aufrufen. */
export const requirePunkteModus = (testContext, ctx) => {
if (punkteModus(ctx)) return;
cy.log("Übersprungen: CIS_GESAMTNOTE_PUNKTE ist aus.");
testContext.skip();
};
/** Skip, wenn der Punktemodus an ist. */
export const requireNotenModus = (testContext, ctx) => {
if (!punkteModus(ctx)) return;
cy.log("Übersprungen: CIS_GESAMTNOTE_PUNKTE ist aktiv.");
testContext.skip();
};
+31 -8
View File
@@ -1,9 +1,8 @@
/**
* Assertions for the Noten error envelope.
* Assertions für die Noten-Fehlerhülle.
*
* These errors carry no errorCode, only a localized message (terminateWithError + p->t()), so we
* match against the de/en phrase templates from system/phrasesupdate.php. Both languages accepted.
* Adding an errorCode server-side would remove this coupling.
* Die Fehler tragen keinen errorCode, nur eine lokalisierte Meldung - daher der Abgleich gegen die
* de/en-Templates aus system/phrasesupdate.php. Ein errorCode serverseitig würde das erübrigen.
*/
const PHRASES = {
@@ -31,14 +30,38 @@ const PHRASES = {
de: "Die Noteneintragungsfrist ({0}) für dieses Studiensemester ist abgelaufen. Es können keine Noten oder Prüfungen mehr eingetragen werden.",
en: "The grade entry deadline ({0}) for this study semester has passed. Grades and exams can no longer be entered.",
},
wrongPruefungType: {
de: "Prüfung für Studierenden {0} konnte nicht bearbeitet werden, {1} ist keine gültiger Prüfungstyp.",
en: "Exam for Student {0} was not saved, {1} is not a valid exam type.",
},
c4keineLvNoteEingetragen: {
de: "Keine LV Note eingetragen",
en: "No Subject Grade entered",
},
c4angerechnetKeinePruefung: {
de: "Für {0} ist die Lehrveranstaltung angerechnet. Dafür können keine Prüfungen eingetragen werden.",
en: "The course is credited for {0}. No exams can be recorded for it.",
},
c4pruefungNichtGespeichert: {
de: "Die Prüfung für {0} konnte nicht gespeichert werden.",
en: "The exam for {0} could not be saved.",
},
pruefungsdatumNachFrist: {
de: "Das Prüfungsdatum für Studierenden {0} liegt nach dem Ende der Noteneintragungsfrist ({1}). Es wurde keine Prüfung angelegt.",
en: "The exam date for student {0} is after the grade entry deadline ({1}). No exam was created.",
},
c4punkteKeineNoteErmittelt: {
de: "Für Studierenden {0} konnte aus den Punkten keine Note ermittelt werden. Die Zeile wurde übersprungen.",
en: "No grade could be derived from the points for student {0}. The row was skipped.",
},
c4noteNichtInLehre: {
de: "Die gewählte Note ist für eine Lehrveranstaltung nicht zugelassen. Die LV-Note für {0} bleibt unverändert.",
en: "The selected grade is not permitted for a course. The course grade for {0} stays unchanged.",
},
c4notenvorschlagGesperrt: {
de: "Für {0} existiert bereits eine Prüfung. Ändern Sie die LV-Note über den Prüfungstermin.",
en: "An exam already exists for {0}. Change the course grade through the exam.",
},
c4zeugnisnoteGesperrt: {
de: "Die Zeugnisnote von {0} ist für Lehrende gesperrt. Die LV-Note bleibt unverändert.",
en: "The transcript grade of {0} is locked for teachers. The course grade stays unchanged.",
},
keineBerechtigungNoten: {
de: 'Keine Berechtigung, um in der Lehrveranstaltung "{0}" im Semester {1} Noten einzutragen.',
en: 'No permission to enter grades for the course "{0}" in semester {1}.',
+14 -5
View File
@@ -1,9 +1,7 @@
/**
* Fixture reset over a direct DB connection - the only mechanism.
*
* A gated HTTP reset endpoint existed briefly and was removed: it deleted grade data, and no
* gating makes that as safe as not having it. Consequence: the runner needs DB reachability, which
* from a workstation means the SSH tunnel (cypress/tasks/sshTunnel.js).
* Fixture-Reset ausschliesslich über eine direkte DB-Verbindung - ein HTTP-Endpunkt, der
* Notendaten löscht, ist durch keine Absicherung so sicher wie sein Nichtvorhandensein.
* Von der Workstation aus braucht das den SSH-Tunnel (cypress/tasks/sshTunnel.js).
*/
let available = null;
@@ -78,6 +76,17 @@ export const performSeedPruefung = (context, studentUid, payload) =>
}),
);
export const performSeedZeugnisnote = (context, studentUid, payload) =>
authUid().then((uid) =>
cy.task("noten:db:seedZeugnisnote", {
lvId: context.lvId,
semKurzbz: context.semKurzbz,
studentUid,
mitarbeiterUid: uid,
...payload,
}),
);
export const performRead = (context, studentUid) =>
cy.task("noten:db:readLvGesamtnote", {
lvId: context.lvId,
@@ -5,13 +5,10 @@ import { expectNotenSuccess } from "./notenErrors";
import { resetNotenState, seedBaseline } from "./notenTestData";
/**
* Adds an attempt (no pruefung_id -> server runs validatePruefungAdd).
* Neuer Antritt (ohne pruefung_id -> validatePruefungAdd). Kein typ auf der Leitung.
*
* No typ: the server derives from the Verlauf which attempt this is. One call writes exactly one
* Prüfung - the former Termin1 snapshot is gone, the first attempt is created by the Freigabe.
*
* punkte stays null: with CIS_GESAMTNOTE_PUNKTE on, a punkte >= 0 makes the controller re-derive
* the note from the Notenschlüssel and override the grade under test.
* punkte bleibt null: mit CIS_GESAMTNOTE_PUNKTE würde ein punkte >= 0 die Note aus dem
* Notenschlüssel neu ableiten und die zu testende Note überschreiben.
*/
export const addPruefung = (context, student, { note, datum }) =>
notenApi.saveStudentPruefung({
+24 -14
View File
@@ -1,10 +1,8 @@
/**
* Runtime discovery + fixture helpers.
* Runtime discovery + Fixture-Helfer.
*
* No hardcoded fixture ids: the rule parameters (maxAntritte, the special note PKs) come from
* server-side config that is not in the repo, and Noten.php resolves the special notes by
* Bezeichnung. Everything is therefore read back from the API at runtime.
* Override via NOTEN_SEM / NOTEN_LV_ID.
* Keine fixen Ids: maxAntritte und die Sondernoten-PKs stehen in Server-Config, die nicht im Repo
* liegt, und werden daher zur Laufzeit über die API gelesen. Override: NOTEN_SEM / NOTEN_LV_ID.
*/
import { notenApi } from "../api/notenApi";
@@ -12,8 +10,10 @@ import { expectNotenSuccess } from "./notenErrors";
// the client rule; it now only reads back what the server derived, so a mismatch is a config bug
import { maxAntrittCount as computeMaxAntritte } from "../../../../public/js/components/Cis/Benotungstool/notenRules.js";
import {
describeFailure, performRead, performReset, performSeed, performSeedPruefung, resolveResetStrategy,
describeFailure, performRead, performReset, performSeed, performSeedPruefung,
performSeedZeugnisnote, resolveResetStrategy,
} from "./notenReset";
import { assertPunkteModus } from "./notenConfig";
const BEZ_ENTSCHULDIGT = "entschuldigt";
const BEZ_NOCH_NICHT = "Noch nicht eingetragen";
@@ -123,6 +123,9 @@ export const loadNotenContext = () => {
.then((response) => {
context.cisConfig = expectNotenSuccess(response, "getCisConfig");
context.maxAntritte = computeMaxAntritte(context.cisConfig);
// ein Lauf, der den falschen Konfigurationsmodus erwartet, soll hier scheitern und nicht
// alles stillschweigend überspringen
assertPunkteModus(context);
return notenApi.getNoten();
})
.then((response) => {
@@ -154,6 +157,9 @@ export const loadNotenContext = () => {
// administrative note the editor list excludes ("intern angerechnet" / "nicht zugelassen")
const nichtLehre = noten.find((n) => n.lehre === false);
// as a ZEUGNISnote this one locks the LV note, in the client and in the server
const nichtUeberschreibbar = noten.find((n) => n.lkt_ueberschreibbar === false);
// Anrechnungen block every Prüfung for the LV - keyed on the ZEUGNISnote
const angerechnet = byBezeichnung("angerechnet");
const internAngerechnet = byBezeichnung("intern angerechnet");
@@ -162,10 +168,14 @@ export const loadNotenContext = () => {
entschuldigt: entschuldigt.note,
nochNichtEingetragen: nochNicht.note,
nichtLehre: nichtLehre ? nichtLehre.note : null,
nichtUeberschreibbar: nichtUeberschreibbar ? nichtUeberschreibbar.note : null,
angerechnet: angerechnet ? angerechnet.note : null,
internAngerechnet: internAngerechnet ? internAngerechnet.note : null,
};
// die GUI-Specs wählen und lesen über die Bezeichnung, nicht über die PK
context.notenOptions = noten;
return notenApi.getStudentenNoten(context.lvId, context.semKurzbz);
})
.then((response) => {
@@ -189,15 +199,11 @@ export const resetNotenState = (context, studentUids) =>
performReset(context, studentUids || context.studentUids);
/**
* Seeds the Antritt-1 baseline: a released LV note PLUS the Prüfung row that represents it.
* Baseline für Antritt 1: freigegebene LV-Note PLUS die Prüfungszeile dazu - das ist, was die
* Freigabe produziert. Ohne die Zeile zählt die LV-Note nur solange gar kein Termin existiert, und
* der erste hinzugefügte Antritt würde Antritt 1 ersetzen statt Antritt 2 zu werden.
*
* The row is what the production flow produces - since the refactor the first Antritt is created
* by the password-gated Freigabe (Noten::upsertErstantritt), not as a side effect of adding a
* retake. Without it the LV note only counts while no Prüfung exists at all, so the first added
* attempt would silently replace Antritt 1 instead of being Antritt 2.
*
* `erstantritt: false` seeds the legacy shape (LV note only, no row) for tests that need it.
* `freigegeben: false` implies it too: no Freigabe, no first Antritt.
* `erstantritt: false` (bzw. `freigegeben: false`) seedet die Altdaten-Form ohne diese Zeile.
*/
export const seedBaseline = (context, studentUid, options = {}) => {
const note = options.note !== undefined ? options.note : context.gradeNotes[0];
@@ -233,6 +239,10 @@ export const seedPruefung = (context, student, { note, datum, typ }) =>
typ,
});
/** Zeugnisnote setzen. Nur die Studierendenverwaltung schreibt sie, kein Endpunkt dieses Tools. */
export const seedZeugnisnote = (context, studentUid, note) =>
performSeedZeugnisnote(context, studentUid, { note });
/** Raw row, bypassing the freigabedatum filter of getLvGesamtNoten. */
export const readLvGesamtnote = (context, studentUid) => performRead(context, studentUid);
@@ -0,0 +1,306 @@
import { waitForOk } from "../helpers/network";
/**
* Page object für das Benotungstool.
*
* Selektoren sind data-cy-Attribute aus Benotungstool.js. Die Tabelle ist Tabulator: Zeilen tragen
* data-cy="student-row-<uid>", Zellen das von Tabulator gesetzte tabulator-field, und der
* Freigabestatus steckt als data-state am Zellinhalt statt am Icon.
*
* Nach jeder schreibenden Aktion wird auf den zugehörigen Request gewartet (waitForOk), nicht auf
* eine DOM-Änderung - sonst prüfen die Specs gegen den Stand vor der Antwort.
*/
const TABLE_TIMEOUT = 60_000;
const API = "**/api/frontend/v1/Noten";
/**
* Exakter Textvergleich für Optionslisten. Nötig, weil die Notenbezeichnungen einander enthalten:
* contains("Gut") trifft zuerst "Sehr Gut", contains("Genügend") zuerst "Nicht Genügend".
*/
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}/saveNotenvorschlag` }).as("saveNotenvorschlag");
cy.intercept({ method: "POST", url: `${API}/saveNotenvorschlagBulk` }).as("saveNotenvorschlagBulk");
cy.intercept({ method: "POST", url: `${API}/saveStudentPruefung` }).as("saveStudentPruefung");
cy.intercept({ method: "POST", url: `${API}/createPruefungen` }).as("createPruefungen");
cy.intercept({ method: "POST", url: `${API}/savePruefungenBulk` }).as("savePruefungenBulk");
cy.intercept({ method: "POST", url: `${API}/saveStudentenNoten` }).as("saveStudentenNoten");
cy.intercept({ method: "POST", url: `${API}/getNoteByPunkte` }).as("getNoteByPunkte");
};
/**
* Deep-Link auf LV + Semester - die vier Dropdowns müssen dafür nicht bedient werden.
* Die Spaltenaufteilung wird vor dem Laden gesetzt: die Komponente liest den localStorage beim
* Initialisieren, und vor dem ersten visit gehört er noch about:blank.
*/
visit = (ctx, { spalten = "antritt" } = {}) =>
cy.visit(`/cis.php/Cis/Benotungstool/${ctx.lvId}/${ctx.semKurzbz}`, {
onBeforeLoad(win) {
win.localStorage.setItem("notenToolPruefungsspalten", spalten);
win.localStorage.removeItem("notenToolStickyCols");
},
});
visitAndWaitForTable = (ctx, options) => {
cy.login();
this.setupIntercepts();
this.visit(ctx, options);
waitForOk("@getStudentenNoten");
this.getTable().should("be.visible");
this.getRows().should("have.length.greaterThan", 0);
};
// --- Grundelemente ---------------------------------------------------------------------------
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, "freigegeben").find("[data-cy='freigabe-state']");
getPruefungCell = (uid, spalte) => this.getCell(uid, spalte).find("[data-cy='pruefung-cell']");
getUebernehmenButton = (uid) => this.getCell(uid, "übernehmen").find("[data-cy='btn-uebernehmen']");
getPruefungAddButton = (uid, spalte) =>
this.getCell(uid, spalte).find("[data-cy='btn-pruefung-add']");
getPruefungEditButton = (uid, spalte) =>
this.getCell(uid, spalte).find("[data-cy='btn-pruefung-edit']");
getPruefungModal = () => cy.get("[data-cy='modal-pruefung']");
getNeuePruefungModal = () => cy.get("[data-cy='modal-neue-pruefung']");
getFreigabeModal = () => cy.get("[data-cy='modal-freigabe']");
getNotenImportModal = () => cy.get("[data-cy='modal-noten-import']");
getPruefungImportModal = () => cy.get("[data-cy='modal-pruefung-import']");
getFreigabeSummaryRow = (uid) => cy.get(`[data-cy='freigabe-row-${uid}']`);
// --- Zustand prüfen --------------------------------------------------------------------------
/** offen | changed | ok */
expectFreigabeState = (uid, state) =>
this.getFreigabeState(uid).should("have.attr", "data-state", state);
expectLvNote = (uid, bezeichnung) => this.getCell(uid, "lv_note").should("contain.text", bezeichnung);
expectNotenvorschlag = (uid, bezeichnung) =>
this.getCell(uid, "note_vorschlag").should("contain.text", bezeichnung);
/** centeredTextFormatter prüft auf falsy, eine 0 rendert daher als leere Zelle. */
expectAntrittCount = (uid, count) =>
Number(count) === 0
? this.getCell(uid, "hoechsterAntritt").invoke("text").invoke("trim").should("eq", "")
: this.getCell(uid, "hoechsterAntritt").should("contain.text", String(count));
expectPruefung = (uid, spalte, { note, antritt } = {}) => {
// je Assertion neu abfragen, damit Cypress ein Re-Render der Zelle erneut versucht
if (note !== undefined) {
this.getPruefungCell(uid, spalte).should("have.attr", "data-note", String(note));
}
if (antritt !== undefined) {
this.getPruefungCell(uid, spalte).should("have.attr", "data-attempt", String(antritt));
}
};
expectKeinePruefung = (uid, spalte) =>
this.getCell(uid, spalte).find("[data-cy='pruefung-cell']").should("not.exist");
expectKeineAntrittsspalte = (nr) => cy.get(`[tabulator-field='antritt_${nr}']`).should("not.exist");
// --- Notenvorschlag --------------------------------------------------------------------------
/** Öffnet den Tabulator-Listeneditor der Vorschlagsspalte und wählt die Bezeichnung. */
setNotenvorschlag = (uid, bezeichnung) => {
this.getCell(uid, "note_vorschlag").click();
cy.get(".tabulator-edit-list-item").contains(bezeichnung).click();
};
uebernehmen = (uid) => {
this.getUebernehmenButton(uid).click();
waitForOk("@saveNotenvorschlag");
};
// --- Prüfungen -------------------------------------------------------------------------------
/** PrimeVue hängt seine Panels an den body, daher nicht innerhalb des Modals suchen. */
selectDropdownOption = (dataCy, label) => {
cy.get(`[data-cy='${dataCy}']`).click();
cy.contains(".p-dropdown-panel .p-dropdown-item", exactText(label)).click();
};
setDatum = (dataCy, ddmmyyyy) =>
cy.get(`[data-cy='${dataCy}'] input`).first().clear().type(`${ddmmyyyy}{enter}`);
/** Dialog aus der Tabellenzelle: neuer Antritt für EINEN Studenten. */
addPruefungInCell = (uid, spalte, { note, datum } = {}) => {
this.getPruefungAddButton(uid, spalte).click();
this.getPruefungModal().should("be.visible");
if (datum) this.setDatum("pruefung-datum", datum);
if (note) this.selectDropdownOption("pruefung-note", note);
cy.get("[data-cy='pruefung-submit']").click();
waitForOk("@saveStudentPruefung");
this.getPruefungModal().should("not.be.visible");
};
/** Bestehenden Antritt bearbeiten. Ohne `note` bleibt sie unverändert (Datumskorrektur). */
editPruefungInCell = (uid, spalte, { note, datum } = {}) => {
this.getPruefungEditButton(uid, spalte).click();
this.getPruefungModal().should("be.visible");
if (datum) this.setDatum("pruefung-datum", datum);
if (note) this.selectDropdownOption("pruefung-note", note);
cy.get("[data-cy='pruefung-submit']").click();
waitForOk("@saveStudentPruefung");
this.getPruefungModal().should("not.be.visible");
};
openPruefungModalForEdit = (uid, spalte) => {
this.getPruefungEditButton(uid, spalte).click();
this.getPruefungModal().should("be.visible");
};
/** Sammelanlage über die Toolbar, für mehrere Studierende auf einmal. */
addPruefungBulk = ({ uids, note, datum }) => {
cy.get("[data-cy='btn-neue-pruefung']").click();
this.getNeuePruefungModal().should("be.visible");
if (datum) this.setDatum("neue-pruefung-datum", datum);
if (note) this.selectDropdownOption("neue-pruefung-note", note);
cy.get("[data-cy='neue-pruefung-studenten']").click();
// das Label ist "uid – Nachname Vorname – Antritte: n", daher hier bewusst per Teilstring
uids.forEach((uid) => cy.contains(".p-multiselect-panel .p-multiselect-item", uid).click());
// Panel über den Trigger schliessen, NICHT mit Escape: das schliesst das Bootstrap-Modal mit
cy.get("[data-cy='neue-pruefung-studenten']").click();
cy.get(".p-multiselect-panel").should("not.exist");
cy.get("[data-cy='neue-pruefung-submit']").click();
waitForOk("@createPruefungen");
this.getNeuePruefungModal().should("not.be.visible");
};
// --- Freigabe --------------------------------------------------------------------------------
openFreigabeModal = () => {
cy.get("[data-cy='btn-freigabe']").click();
this.getFreigabeModal().should("be.visible");
};
expectFreigabeSummaryRow = (uid, releasedBezeichnung) =>
this.getFreigabeSummaryRow(uid)
.find("[data-cy='freigabe-row-released']")
.should("contain.text", releasedBezeichnung);
typeFreigabePasswort = (password) =>
cy.get("[data-cy='freigabe-passwort'] input").type(password, { log: false });
submitFreigabe = () => cy.get("[data-cy='freigabe-submit']").click();
freigeben = (password) => {
this.typeFreigabePasswort(password);
this.submitFreigabe();
waitForOk("@saveStudentenNoten");
this.getFreigabeModal().should("not.be.visible");
};
// --- Import ----------------------------------------------------------------------------------
/** rows: [[uid, note], ...] -> "uid<TAB>note" je Zeile */
importNoten = (rows) => {
cy.get("[data-cy='btn-noten-import']").click();
this.getNotenImportModal().should("be.visible");
cy.get("[data-cy='noten-import-text']").type(rows.map((r) => r.join("\t")).join("\n"));
cy.get("[data-cy='noten-import-submit']").click();
waitForOk("@saveNotenvorschlagBulk");
this.getNotenImportModal().should("not.be.visible");
};
/** rows: [[uid, "dd.MM.yyyy", note], ...] */
importPruefungen = (rows) => {
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();
waitForOk("@savePruefungenBulk");
this.getPruefungImportModal().should("not.be.visible");
};
// --- Punktemodus -----------------------------------------------------------------------------
// Die Punktespalte und die beiden Punktefelder in den Dialogen existieren nur mit
// CIS_GESAMTNOTE_PUNKTE. Die Note wird dann aus dem Notenschlüssel abgeleitet, nicht gewählt.
getPunkteCell = (uid) => this.getCell(uid, "punkte");
/**
* Punkte in die Zelle tippen; die Note holt der Client debounced über getNoteByPunkte nach.
*
* Über cy.focused(): der liveNumberEditor fokussiert sein Input in onRendered, und die Zeile
* wird beim Klick neu formatiert (cellClick -> undoSelection), sodass ein zweiter Zugriff über
* die Zelle ins Leere greifen kann.
*/
setPunkteInCell = (uid, punkte) => {
this.getPunkteCell(uid).click();
// Über cy.focused(), weil der liveNumberEditor sein Input in onRendered fokussiert und die
// Zeile beim Klick neu formatiert wird. Enter committet den Wert (success() im Editor);
// die Note holt der Client danach debounced über getNoteByPunkte nach - deren Rendern
// schliesst den Editor, ein Blur danach ginge ins Leere.
cy.focused().should("have.attr", "type", "number").clear().type(`${punkte}{enter}`);
waitForOk("@getNoteByPunkte");
};
expectPunkte = (uid, punkte) =>
this.getPunkteCell(uid).should("contain.text", String(punkte));
/** Sobald ein Termin existiert, ist die Punktespalte gesperrt (editable-Guard der Spalte). */
expectPunkteZelleGesperrt = (uid) => {
this.getPunkteCell(uid).click();
this.getPunkteCell(uid).find("input").should("not.exist");
};
/** Im Punktemodus ist die Vorschlagsspalte nicht editierbar - die Note kommt aus den Punkten. */
expectNotenvorschlagGesperrt = (uid) => {
this.getCell(uid, "note_vorschlag").click();
cy.get(".tabulator-edit-list").should("not.exist");
};
expectNoteFeldGesperrt = (dataCy) => cy.get(`[data-cy='${dataCy}']`).should("have.class", "p-disabled");
/** Punktefeld im Einzeldialog; löst die Ableitung der Note aus. */
setPruefungPunkte = (punkte) => {
cy.get("[data-cy='pruefung-punkte'] input").clear().type(String(punkte));
waitForOk("@getNoteByPunkte");
};
/** Punktefeld der Sammelanlage; dort leitet erst der Server beim Speichern ab. */
setNeuePruefungPunkte = (punkte) =>
cy.get("[data-cy='neue-pruefung-punkte'] input").clear().type(String(punkte));
// --- Hilfen ----------------------------------------------------------------------------------
/** Die Bezeichnung zu einer Noten-PK, wie sie in Dropdowns und Zellen steht. */
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}`;
};
}
export const benotungstoolPage = new BenotungstoolPage();
+46 -1
View File
@@ -56,7 +56,19 @@ const resetStudents = async (scope) => {
[studentUids, lvId, semKurzbz],
);
return { deletedPruefungen: pruefungen.rowCount, deletedLvGesamtnoten: noten.rowCount };
// 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,
};
});
};
@@ -142,6 +154,38 @@ const seedPruefung = async (scope) => {
});
};
/**
* Seeds the transcript grade. The student administration writes it, no endpoint of this tool does.
* It decides two rules: an Anrechnung blocks every exam, 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");
@@ -162,6 +206,7 @@ const registerNotenDbTasks = (on) => {
"noten:db:reset": (scope) => resetStudents(scope),
"noten:db:seedLvGesamtnote": (scope) => seedLvGesamtnote(scope),
"noten:db:seedPruefung": (scope) => seedPruefung(scope),
"noten:db:seedZeugnisnote": (scope) => seedZeugnisnote(scope),
"noten:db:readLvGesamtnote": (scope) => readLvGesamtnote(scope),
"noten:db:close": () => closeDb(),
});