diff --git a/application/config/noten.php b/application/config/noten.php index 63ad9d911..81f467a13 100644 --- a/application/config/noten.php +++ b/application/config/noten.php @@ -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) diff --git a/application/controllers/api/frontend/v1/Noten.php b/application/controllers/api/frontend/v1/Noten.php index c5a60a58f..6a7d0f640 100644 --- a/application/controllers/api/frontend/v1/Noten.php +++ b/application/controllers/api/frontend/v1/Noten.php @@ -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(); diff --git a/application/libraries/PruefungsverlaufLib.php b/application/libraries/PruefungsverlaufLib.php new file mode 100644 index 000000000..e0087e527 --- /dev/null +++ b/application/libraries/PruefungsverlaufLib.php @@ -0,0 +1,410 @@ +. + */ + +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; + } +} diff --git a/application/models/education/Notenschluesselaufteilung_model.php b/application/models/education/Notenschluesselaufteilung_model.php index f9031de47..33c8de26c 100644 --- a/application/models/education/Notenschluesselaufteilung_model.php +++ b/application/models/education/Notenschluesselaufteilung_model.php @@ -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); diff --git a/package.json b/package.json index 5337577d4..66b7246c1 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/public/js/api/factory/noten.js b/public/js/api/factory/noten.js index 55278964d..bf78a2d3b 100644 --- a/public/js/api/factory/noten.js +++ b/public/js/api/factory/noten.js @@ -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) { diff --git a/public/js/components/Cis/Benotungstool/Benotungstool.js b/public/js/components/Cis/Benotungstool/Benotungstool.js index e4f8a3b7c..a4272a597 100644 --- a/public/js/components/Cis/Benotungstool/Benotungstool.js +++ b/public/js/components/Cis/Benotungstool/Benotungstool.js @@ -68,13 +68,18 @@ export const Benotungstool = { pruefungDateMin: null, pruefungDateMax: null, distinctPruefungsDates: null, - // Spaltenaufteilung der Termine: 'antritt' (je Antrittsnummer) oder 'datum' (je Prüfungsdatum). - // null = Vorgabe aus der Konfiguration; die Wahl des Benutzers wird lokal gemerkt. + // The column layout of the exams: 'antritt' (one column for each attempt number) or 'datum' + // (one column for each exam date). null uses the default from the configuration. The browser + // keeps the choice of the user. pruefungsspalten: localStorage.getItem('notenToolPruefungsspalten'), - // Studenten der aktuellen Anlage, für die noch keine LV-Note existiert -> sie entsteht mit - // der Prüfung und wird vorher bestätigt + // The students of the current entry that have no course grade. The new exam creates the + // grade, and the dialog shows a hint before that happens. pruefungStudent: null, pruefung: null, + // Lehrende der betroffenen Lehreinheit. Die Prüfung trägt den Lektor, nicht den, der sie + // einträgt - bei mehreren Lehrenden wählt der Benutzer aus. + lehrendeDerLehreinheit: [], + selectedLehrender: null, password: '', changedNotenCounter: 0, tableVersion: 0, // incremented on table sort/filter/data changes so getStudentenOptions mirrors the table @@ -250,9 +255,8 @@ export const Benotungstool = { localStorage.setItem(this.persistenceID, JSON.stringify(state)); }, stickyClass(field) { - // the freezable identity columns always carry the sticky-col class; whether they are - // actually sticky is toggled per column via container classes (see applyStickyColumnState), - // which avoids re-running updateDefinition on columns that have reactive (Vue.computed) titles + // Klasse liegt immer an; ob wirklich sticky, schaltet applyStickyColumnState per + // container class, because updateDefinition breaks the Vue.computed titles return this.freezableColumnFields.includes(field) ? 'sticky-col' : undefined }, recomputeStickyOffsets() { @@ -314,7 +318,7 @@ export const Benotungstool = { table.on(eventName, () => this.recomputeStickyOffsets()); }); - // keep the "neue Prüfung" dropdown order in sync with the table's current sort + // the "neue Prüfung" dropdown uses the same order as the table table.on("dataSorted", () => this.tableVersion++); // renderComplete restore state logic @@ -458,9 +462,8 @@ export const Benotungstool = { } }, validatePruefungBulk(pruefungen) { - // Reihenfolge und Antrittsgrenze vorab prüfen, damit offensichtlich unmögliche Zeilen gar - // nicht erst gesendet werden. Eine fehlende LV-Note ist kein Hindernis: liegt für den - // Studenten noch keine Leistungsfeststellung vor, entsteht sie mit der importierten Note. + // Do not send a row that cannot work. A missing course grade is no problem, because the + // imported grade creates it. const validatedPruefungen = [] pruefungen.forEach( p => { const student = this.studenten.find(s => s.uid === p.uid) @@ -572,33 +575,28 @@ export const Benotungstool = { this.loading = true this.$api.call(ApiNoten.saveNotenvorschlagBulk(this.lv_id, this.sem_kurzbz, notenbulk)).then(res => { if(res.meta.status === 'success') { + // the answer uses the uid as the key: a course grade or an error message for each row + let errorList = '' + + Object.keys(res.data ?? {}).forEach(uid => { + const lvn = res.data[uid] + const s = this.studenten.find(s => s.uid === uid) + if(!s) return + + if(typeof lvn === 'string') { errorList += lvn + '\n'; return } + + s.note_vorschlag = lvn.note + this.applyLvGesamtnote(s, lvn) + }) + + if(errorList !== '') this.$fhcAlert.alertError(errorList) + this.$fhcAlert.alertDefault( 'success', 'Info', this.$capitalize(this.$p.t('benotungstool/notenImportSuccessAlert')), true ) - const lvNoten = res.data - - - lvNoten.forEach(lvn => { - // 1.) get relevant student row by uid - const s = this.studenten.find(s => s.uid === lvn.student_uid) - s.note_vorschlag = lvn.note // TODO: check if note_vorschlag should be changed by import - - s.lv_note = lvn.note - if(this.config?.CIS_GESAMTNOTE_PUNKTE) { - s.punkte = lvn.punkte - } - - this.teilnoten[s.uid].note_lv = lvn.note - // recalculate freigabestatus - s.freigabedatum = this.parseDate(lvn['freigabedatum']) - s.benotungsdatum = this.parseDate(lvn['benotungsdatum']) - - s.freigegeben = this.checkFreigabe(s.freigabedatum, s.benotungsdatum, s.uid); - }) - } this.$refs.notenTable.tabulator.redraw(true) @@ -644,20 +642,17 @@ export const Benotungstool = { const student = this.studenten.find(s => s.uid == entry.uid) if(!student) return - // a per-row backend rejection returns a localized error string (or produced nothing) -> - // skip it here; callers surface those messages to the user separately. - // savedPruefung ist der Erfolgsnachweis, nicht verlauf: letzterer beschreibt nur den - // Stand und wäre auch bei einem fehlgeschlagenen Insert vorhanden. + // savedPruefung is the proof of success, not verlauf. The server sends verlauf also + // after a failed insert. The caller shows the error messages. if(!rowResult?.savedPruefung || !rowResult?.verlauf) return - // gleiche Nachbereitung wie im Dialog aus der Tabelle: erst LV-Note, dann Verlauf this.applyLvGesamtnote(student, rowResult.lvgesamtnote) this.applyVerlauf(student, rowResult.verlauf) }) this.loading = false - // Scrollstand halten: nach einer Bulk-Anlage soll der Benutzer seine Zeilen weiter sehen + // keep the scroll position, so that the user still sees the same rows after a bulk entry this.preserveScroll(() => { this.applyPruefungColumns() const loaded = this.$refs.notenTable.tabulator.setData(this.studenten); @@ -665,16 +660,7 @@ export const Benotungstool = { return loaded }) }, - /** - * Zeile aus dem vom Server gelieferten Prüfungsverlauf neu aufbauen. Positionen, Antrittszahl - * und Grenzen kommen ausschliesslich von dort - der Client führt keine eigene Buchhaltung über - * Termine, damit die Regeln nicht in zwei Implementierungen auseinanderlaufen. - */ - /** - * LV-Note einer Zeile aus der Serverantwort übernehmen: Note, Punkte und Freigabestatus. - * Beide Eingabewege (Dialog aus der Tabelle und Sammelanlage) verwenden dasselbe, sonst - * zeigt die Tabelle nach einer Sammelanlage die alte Note. - */ + /** Takes the course grade from the server answer. All entry paths use this method. */ applyLvGesamtnote(student, lvgesamtnote) { if(!student || !lvgesamtnote) return @@ -685,7 +671,7 @@ export const Benotungstool = { if(this.config?.CIS_GESAMTNOTE_PUNKTE) student.punkte = lvgesamtnote.punkte - // teilnoten ist die Quelle für changedNoten (Zähler und Liste der Freigabe) + // teilnoten is the source of changedNoten (the counter and the list of the release) if(this.teilnoten && this.teilnoten[student.uid]) { this.teilnoten[student.uid].note_lv = lvgesamtnote.note this.teilnoten[student.uid].punkte_lv = lvgesamtnote.punkte @@ -695,12 +681,12 @@ export const Benotungstool = { this.changedNotenCounter++ // computed changedNoten neu auswerten }, + /** Builds the row again from the server history. The client counts no attempts itself. */ applyVerlauf(student, verlauf) { if(!verlauf) return - // die Kennzahlen vollständig übernehmen. Nicht einzeln abschreiben: fehlende Felder - // (angerechnet, hatLvNote) sind danach undefined und die Oberfläche urteilt falsch, - // bis die Lehrveranstaltung neu geladen wird. + // Take the full object. If you copy single fields, the other fields are absent (for + // example angerechnet and hatLvNote) and the user interface is wrong until the next load. student.verlauf = {...verlauf} delete student.verlauf.pruefungen @@ -709,14 +695,13 @@ export const Benotungstool = { ;(verlauf.pruefungen ?? []).forEach(p => { p.dateObj = this.parseISODate(p.datum) - // der abschliessende Termin steht in einer eigenen, immer letzten Spalte - if(p.terminal) student['kommPruef'] = p + // the final exam has its own column, and that column is always the last one + if(p.kommissionell) student['kommPruef'] = p else student.pruefungen.push(p) }) - // die flache Liste über alle Studenten mitziehen: sie entscheidet, ob Notenvorschlag und - // Punkte noch bearbeitbar sind (siehe getColumnsDefinition), und war bisher bis zum - // nächsten Laden veraltet + // Update the flat list too. It decides if the user can edit the grade proposal and the + // points (see getColumnsDefinition). this.pruefungen = (this.pruefungen ?? []).filter(p => p.student_uid !== student.uid) ;(verlauf.pruefungen ?? []).forEach(p => this.pruefungen.push(p)) @@ -727,21 +712,18 @@ export const Benotungstool = { this.recalculateSelectable(student) this.reformatStudentRow(student) }, - /** Datumsspalten aus allen Zeilen neu ableiten (nur im Spaltenmodus 'datum' sichtbar). */ + /** Derives the date columns from all rows. They are visible in the 'datum' mode only. */ syncDistinctPruefungsDates() { const dates = new Set() this.studenten?.forEach(s => (s.pruefungen ?? []).forEach(p => dates.add(p.datum))) this.distinctPruefungsDates = [...dates].sort() }, - /** Feldname der Spalte, in der ein Termin dargestellt wird. */ + /** The field name of the column that shows one exam. */ pruefungField(pruefung, index) { - if(pruefung.terminal) return 'kommPruef' + if(pruefung.kommissionell) return 'kommPruef' return this.pruefungsspaltenModus === 'antritt' ? ('antritt_' + (index + 1)) : pruefung.datum }, - /** - * Termine als Spaltenwerte an der Zeile ablegen. Vorherige Zuordnungen werden zuvor entfernt, - * damit ein Moduswechsel oder ein korrigiertes Prüfungsdatum keine verwaiste Spalte hinterlässt. - */ + /** Puts the exams into the column fields. Remove the old fields first, or they stay behind. */ indexPruefungen(student) { (student._pruefungFields ?? []).forEach(f => { delete student[f] }) @@ -754,11 +736,9 @@ export const Benotungstool = { student._pruefungFields = fields }, - /** einheitliche Definition einer Prüfungsspalte, unabhängig vom Spaltenmodus */ + /** One definition for an exam column. It is the same in both column modes. */ pruefungColumnDef(field, title) { - // Die Zelle hat feste Spuren (siehe .pruefung-cell): Badge 32 + Note + Aktion 6.5rem, - // im Antrittsmodus zusätzlich das Datum 4.75rem. Die Mindestbreite muss die festen - // Spuren plus lesbaren Notentext fassen, sonst wird die Note sofort abgeschnitten. + // must hold the fixed tracks of .pruefung-cell and a readable grade text const minWidth = this.pruefungsspaltenModus === 'antritt' ? 320 : 250 return { @@ -778,18 +758,19 @@ export const Benotungstool = { } }, /** - * Die Prüfungsspalten im aktiven Modus: - * 'antritt' - eine Spalte je Antrittsnummer, das Datum steht in der Zelle. Robust auch dann, - * wenn jeder Student sein eigenes Prüfungsdatum hat. - * 'datum' - eine Spalte je Prüfungsdatum. Kompakt, wenn ganze Jahrgänge am selben Tag - * antreten; der erste Antritt ist hier eine Datumsspalte wie jede andere. + * 'antritt' makes one column for each attempt number and puts the date into the cell. Use it + * if each student has an own exam date. 'datum' makes one column for each exam date. Use it + * if the students share the exam dates. */ buildPruefungColumns() { if(this.pruefungsspaltenModus === 'antritt') { let count = 0 this.studenten?.forEach(s => { - // eine Spalte mehr, solange bei dieser Zeile noch ein Antritt angelegt werden darf - const needed = (s.pruefungen?.length ?? 0) + (this.canAddPruefung(s) ? 1 : 0) + // Add one more column while this row can get one more attempt. The kommissionelle + // attempt has its own column, so it needs no attempt column. + const kommissionellFaellig = s.verlauf?.naechsteRolle === 'kommissionell' + const frei = this.canAddPruefung(s) && !kommissionellFaellig ? 1 : 0 + const needed = (s.pruefungen?.length ?? 0) + frei if(needed > count) count = needed }) @@ -803,12 +784,8 @@ export const Benotungstool = { ) }, /** - * Prüfungsspalten neu setzen: Basisspalten, dann die Termine im aktiven Modus, zuletzt der - * abschliessende Termin. Einzige Stelle, an der die Spalten gebaut werden. - * - * setColumns baut die gesamte Tabelle neu auf (und setzt dabei den Scrollstand zurück), - * deshalb nur aufrufen, wenn sich die Spalten wirklich geändert haben: beim Bearbeiten einer - * Prüfung oder beim Anlegen auf einem bereits vorhandenen Termin bleiben sie gleich. + * The only place that builds the columns. setColumns builds the full table again, therefore + * call it only if the set of columns is different. */ applyPruefungColumns(force = false) { const table = this.$refs.notenTable?.tabulator @@ -829,19 +806,24 @@ export const Benotungstool = { if(kommCol) cols.push(kommCol) // abschliessender Termin bleibt die letzte Spalte table.setColumns(this.restoreColumnLayout(cols)) + this.applyKommPruefSpalte() + }, + /** Blendet die kommissionelle Spalte ein oder aus. Nach jedem setColumns nötig. */ + applyKommPruefSpalte() { + if(!this.config?.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) return + + const col = this.$refs.notenTable?.tabulator?.getColumn("kommPruef") + if(!col) return + + if(this.kommPruefSpalteNoetig) col.show() + else col.hide() }, /** - * Führt eine Tabellenoperation aus, die neu rendert (setColumns / setData / redraw), und - * stellt den Scrollstand danach wieder her - sonst springt die Tabelle nach jedem Speichern - * nach ganz oben links und der Benutzer verliert seine Zeile aus dem Blick. + * Keeps the scroll position while a table operation renders the table again. * - * Gelesen und gesetzt wird direkt am .tabulator-tableholder. Tabulators interne - * rowManager.scrollLeft/scrollTop werden nicht in allen Renderpfaden nachgeführt - vor allem - * horizontal nicht, daher sprang die Tabelle bisher trotz Wiederherstellung nach links. - * - * Wiederhergestellt wird zusätzlich in den nächsten beiden Frames: Tabulator rendert Spalten - * und Zeilen teilweise erst danach und setzt den Scrollstand dabei erneut zurück. Vue.nextTick - * läuft auf der Microtask-Queue und damit zu früh. + * There are two traps. Read and write .tabulator-tableholder directly, because Tabulator does + * not update rowManager.scrollLeft horizontally. Set the position again in the next two + * frames, because Tabulator renders later and resets the position. Vue.nextTick is too early. */ preserveScroll(operation) { const holder = this.$refs.notenTable?.tabulator?.element?.querySelector('.tabulator-tableholder') @@ -851,7 +833,7 @@ export const Benotungstool = { const result = operation() - // nichts wiederherzustellen -> auch nicht gegen ein legitimes Scrollen nach oben arbeiten + // nothing to restore, and do not work against a scroll to the top that the user wants if(!holder || (!left && !top)) return result const restore = () => { @@ -862,15 +844,14 @@ export const Benotungstool = { restore() requestAnimationFrame(() => { restore(); requestAnimationFrame(restore) }) - // setData liefert in Tabulator ein Promise -> nach dem Laden noch einmal + // setData gives a promise in Tabulator, therefore set the position again after the load if(result && typeof result.then === 'function') result.then(() => requestAnimationFrame(restore)) return result }, /** - * Gespeicherte Breite/Sichtbarkeit/Reihenfolge auf frisch gebaute Spalten anwenden, bevor - * Tabulator sie übernimmt - sonst geraten die internen Definitionen mit den Vue-Reactives - * aneinander. + * Apply the layout that the browser kept BEFORE Tabulator takes the columns. After that the + * internal definitions and the Vue reactives are in conflict. */ restoreColumnLayout(cols) { const saved = this.loadState() @@ -879,7 +860,7 @@ export const Benotungstool = { const colMap = new Map(cols.map(c => [c.field, c])) const restored = [] - // Spalten in der GESPEICHERTEN Reihenfolge, mit gespeicherter Breite/Sichtbarkeit + // the columns in the SAVED order, with the saved width and visibility saved.columns.forEach(savedCol => { const originalDef = colMap.get(savedCol.field) if(originalDef) { @@ -893,7 +874,7 @@ export const Benotungstool = { this.colLayoutRestored = true return restored }, - /** Spaltenmodus umschalten; die Wahl bleibt lokal gespeichert. */ + /** Changes the column mode. The browser keeps the choice. */ setPruefungsspalten(modus) { if(modus !== 'antritt' && modus !== 'datum') return @@ -902,7 +883,7 @@ export const Benotungstool = { this.studenten?.forEach(s => this.indexPruefungen(s)) - // vertikale Position halten - die Spalten wechseln komplett, die Zeile bleibt dieselbe + // keep the vertical position: all columns change, but the row stays the same this.preserveScroll(() => { this.applyPruefungColumns(true) const loaded = this.$refs.notenTable?.tabulator?.setData(this.studenten) @@ -921,7 +902,7 @@ export const Benotungstool = { rowComponent.reformat() }, importPruefungen() { - // Prüfungsimport: every row must carry a date: "UID/Matrikelnr Datum Note". + // Exam import: each row must have a date: "UID/Matrikelnr Datum Note". // Each row creates a dated exam attempt. const rows = this.importString.split('\n') const bulk = [] @@ -1006,7 +987,7 @@ export const Benotungstool = { }, selectableCheck(row, e) { - // auswählbar ist, wer überhaupt noch einen Antritt bekommen darf + // a student is selectable if the student can get one more attempt return this.canAddPruefung(row.getData()); }, getColumnsDefinition() { @@ -1071,8 +1052,8 @@ export const Benotungstool = { field: 'selectCol', title: '' }) - // Mindestbreiten: Spalten mit Kopffilter oder Editor brauchen mehr Platz als ihr Titel, - // sonst starten sie so schmal, dass Filterfeld und Werte abgeschnitten sind. + // Minimum widths: a column with a header filter or an editor needs more space than its + // title. If not, the column starts too narrow and cuts the filter field and the values. columns.push({title: 'UID', field: 'uid', tooltip: false, topCalc: this.sumCalcFunc, formatter: centeredTextFormatter, minWidth: 110, cssClass: this.stickyClass('uid')}) columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4mail'))), field: 'email', formatter: this.mailFormatter, tooltip: false, visible: false, minWidth: 170, variableHeight: true}) columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4antrittCountv2'))), field: 'hoechsterAntritt', formatter: centeredTextFormatter, tooltip: false, minWidth: 100}) @@ -1161,7 +1142,7 @@ export const Benotungstool = { }, headerFilterFunc: this.notenFilterFunc }) - columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4kommPruef'))), + columns.push({title: Vue.computed(() => this.kommPruefSpaltentitel), field: 'kommPruef', widthGrow: 1, formatter: this.pruefungFormatter, sorter: this.pruefungSorter, @@ -1213,9 +1194,12 @@ export const Benotungstool = { }, fixTabulatorSelectionFormatter(row) { // if a row is not selectable, remove the checkbox from the dom - + const data = row.getData() - + + // test hook: you can address a row by its uid (see tests/cypress/support/pages) + row.getElement().setAttribute('data-cy', 'student-row-' + data.uid) + if(!this.canAddPruefung(data)) { const el = row.getElement() el.children[0]?.children[0]?.remove() @@ -1301,8 +1285,8 @@ export const Benotungstool = { const value = cell.getValue() const field = cell.getField() let style = 'display: flex; justify-content: start; align-items: center; height: 100%;'; - // Wenn sich die Zeugnisnote von der von Ihnen freigegebenen Note unterscheidet, - // wird erstere rot umrandet markiert. + // If the transcript grade is different from the released grade, the transcript grade gets + // a red border. const data = cell.getData() @@ -1318,20 +1302,21 @@ export const Benotungstool = { }, freigabeFormatter(cell) { const value = cell.getValue() - + let style = 'display: flex; justify-content: center; align-items: center; height: 100%;' - + + // data-state makes the state testable, and the test does not depend on the icon + const wrap = (icon) => + '
' + icon + '
' + if(value === 'ok') { - return '
' + - '
' + return wrap('') } else if (value === 'offen') { - return '
' + - '
' + return wrap('') } else if (value === 'changed') { - return '
' + - '
' + return wrap('') } - + return value }, async saveNote(e, cell) { // Notenvorschlag freigeben @@ -1339,46 +1324,24 @@ export const Benotungstool = { const data = row.getData() if(!data.note_vorschlag) return - - // if vorschlag is the same as lv_note do nothing if(data.note_vorschlag == data.lv_note) return - // if the student already has pruefungen disable this part + // as soon as exams exist, the grade belongs to the exam history if(data.pruefungen.length) return - // confirm before writing the LV-Gesamtnote - const noteOption = this.notenOptions.find(opt => opt.note == data.note_vorschlag) - const noteBezeichnung = noteOption ? noteOption.bezeichnung : data.note_vorschlag - const dateLocale = this.$p.user_language?.value === 'English' ? 'en-GB' : 'de-AT' - const benotungsdatum = new Date().toLocaleString(dateLocale) - - // if(await this.$fhcAlert.confirm({ - // message: this.$p.t('benotungstool/c4notenvorschlagUebernehmenConfirm', [noteBezeichnung, data.vorname, data.nachname, data.uid, benotungsdatum]), - // acceptClass: 'p-button-primary', - // rejectClass: 'p-button-secondary' - // }) === false) { - // return - // } - this.loading = true this.$api.call(ApiNoten.saveNotenvorschlag(this.lv_id, this.sem_kurzbz, data.uid, data.note_vorschlag, data.punkte)) .then((res) => { if (res.meta.status === 'success') { const s = this.studenten.find(s => s.uid === data.uid) - this.teilnoten[s.uid].note_lv = data.note_vorschlag - s.freigabedatum = this.parseDate(res.data[0]['freigabedatum']) - s.benotungsdatum = this.parseDate(res.data[0]['benotungsdatum']) - s.freigegeben = this.checkFreigabe(s.freigabedatum, s.benotungsdatum, s.uid); - - row.update({ lv_note: data.note_vorschlag, freigegeben: 'changed' }) - // row.update({ freigegeben: 'changed' }) + // the same path as after an exam, so that the release state is the same + this.applyLvGesamtnote(s, res.data[0]) + + row.update({ lv_note: s.lv_note, freigegeben: s.freigegeben }) row.reformat() // trigger reformat of arrow - this.changedNotenCounter++; } }).finally(()=>this.loading = false) - - }, teilnotenFormatter(cell) { const val = cell.getValue() @@ -1387,14 +1350,12 @@ export const Benotungstool = { return '
'+val+'
' }, - /** Ob für diese Zeile überhaupt noch ein Antritt angelegt werden darf (Serverstand). */ + /** Tells you if this row can get one more attempt. The value comes from the server. */ canAddPruefung(student) { return NotenRules.canAddPruefung(student, this.config) }, hasLaterPruefung(student, pruefung) { - // Die Note eines Termins ist gesperrt, sobald im Verlauf ein späterer Termin steht (das - // Datum darf weiterhin korrigiert werden). Die Position kommt vom Server, es wird also - // nicht mehr über den Termintyp verglichen. + // an exam with a later date locks the grade; you can still correct the date if(!pruefung) return false const alle = [...(student.pruefungen ?? [])] @@ -1402,31 +1363,25 @@ export const Benotungstool = { return alle.some(p => p.pruefung_id != pruefung.pruefung_id && p.position > pruefung.position) }, - /** - * Zelle einer Prüfungsspalte. Termintyp-agnostisch: welcher Antritt in der Zelle steht, ergibt - * sich aus der Spaltenzuordnung (siehe indexPruefungen); Antrittsnummer und ob der Termin - * zählt, kommen aus dem Verlauf vom Server. - */ + /** The cell of an exam column. indexPruefungen decides which attempt the cell shows. */ pruefungFormatter(cell) { const data = cell.getData() - // Angerechnet: die Zeile bleibt sichtbar (die Person gehört zur Lehrveranstaltung), es gibt - // aber keine Prüfungen dazu - weder anlegen noch ändern. + // credited: the row is visible, but it gets no exams if(data.verlauf?.angerechnet) return '' - // Eine bereits im Zeugnis stehende, nicht überschreibbare Note sperrt die Terminspalten. - // Ohne Zeugnisnote bleiben sie offen: dort wird der erste Antritt überhaupt erst erfasst. + // A transcript grade that you cannot overwrite locks the exam columns. Without a transcript + // grade the columns stay open, because the first attempt starts there. const noteDef = data.note ? this.notenOptions.find(n => n.note == data.note) : null if(noteDef && !noteDef.lkt_ueberschreibbar) return '' const field = cell.getColumn().getField() const studentPruefung = data[field] - // im Antrittsmodus trägt die Spalte kein Datum -> es steht in der Zelle + // in the attempt mode the column has no date, therefore the cell shows it const antrittModus = this.pruefungsspaltenModus === 'antritt' - // feste Aufteilung Note | Datum | Aktion (siehe .pruefung-cell), damit Datum und Button - // über alle Zeilen hinweg fluchten, egal wie lang die Notenbezeichnung ist + // fixed tracks grade | date | action, so that they align in all rows const rowDiv = document.createElement('div') rowDiv.className = antrittModus ? 'pruefung-cell mit-datum' : 'pruefung-cell' @@ -1441,10 +1396,11 @@ export const Benotungstool = { return div } - const addButton = (label, title, onClick) => { + const addButton = (label, title, onClick, cy) => { const button = document.createElement('button') button.className = 'btn btn-outline-secondary' button.textContent = label + button.dataset.cy = cy if(title) button.title = title button.addEventListener('click', onClick) @@ -1452,21 +1408,25 @@ export const Benotungstool = { } if(studentPruefung) { - // Antrittsnummer als Badge; nicht zählende Termine (entschuldigt, nicht beurteilt) - // tragen keine Nummer und werden neutral dargestellt - const attemptLabel = studentPruefung.terminal + // The badge shows the attempt number. A kommissionelle exam shows K, because its number + // follows from the position anyway. An exam that does not count (excused, not assessed) + // has no number and gets a neutral style. + const attemptLabel = studentPruefung.kommissionell ? 'K' : (studentPruefung.antritt_nr ? String(studentPruefung.antritt_nr) : '–') - const attemptClass = studentPruefung.terminal + const attemptClass = studentPruefung.kommissionell ? 'attempt-k' : (studentPruefung.zaehlt ? ('attempt-t' + Math.min(studentPruefung.antritt_nr, 3)) : 'attempt-none') rowDiv.classList.add('pruefung-badge', attemptClass) rowDiv.setAttribute('data-attempt', attemptLabel) + rowDiv.setAttribute('data-cy', 'pruefung-cell') + rowDiv.setAttribute('data-note', studentPruefung.note) - // der abschliessende Termin wird in einem anderen Tool gepflegt -> keine Aktion, und - // der dafür reservierte Platz gehört der Notenbezeichnung - if(studentPruefung.terminal) rowDiv.classList.add('ohne-aktion') + // The student administration owns the escalation (zusKommPruef), therefore that cell has + // no action button and the space belongs to the grade name. + const nurAnzeige = studentPruefung.kommissionell && !studentPruefung.zaehlt + if(nurAnzeige) rowDiv.classList.add('ohne-aktion') const noteDefEntry = this.notenOptions.find(n => n.note == studentPruefung.note) const bezeichnung = noteDefEntry?.bezeichnung || '' @@ -1474,37 +1434,44 @@ export const Benotungstool = { if(antrittModus) addSlot('pruefung-datum', this.formatDatumDMY(studentPruefung.datum)) - if(studentPruefung.terminal) return rowDiv + if(nurAnzeige) return rowDiv - // sobald ein späterer Termin existiert ist die NOTE gesperrt, das Datum darf aber noch - // korrigiert werden -> Button bleibt aktiv, die Note wird im Modal gesperrt + // An exam with a later date locks the GRADE, but you can still correct the date. The + // button stays active, and the dialog locks the grade field. const noteLocked = this.hasLaterPruefung(data, studentPruefung) addButton( this.$capitalize(this.$p.t('benotungstool/changePruefungButtonText')), noteLocked ? this.$capitalize(this.$p.t('benotungstool/pruefungNoteLockedHint')) : null, - () => this.openPruefungModal(data, studentPruefung, field) + () => this.openPruefungModal(data, studentPruefung, field), + 'btn-pruefung-edit' ) return rowDiv } - // leere Zelle: Anlegen nur, wenn überhaupt noch ein Antritt möglich ist und die Spalte - // chronologisch nach allen bereits eingetragenen Terminen liegt - if(field === 'kommPruef' || !this.canAddPruefung(data)) return '' + // An empty cell gets an add button only if one more attempt is possible and if the column + // is after all exams that exist. + if(!this.canAddPruefung(data)) return '' - if(antrittModus) { - // nur die unmittelbar nächste freie Antrittsspalte darf einen Neuanlage-Button zeigen + // The last attempt is kommissionell, therefore only the kommPruef column may take it. All + // other columns stay empty at that point. + const kommissionellFaellig = data.verlauf?.naechsteRolle === 'kommissionell' + if(field === 'kommPruef' ? !kommissionellFaellig : kommissionellFaellig) return '' + + if(antrittModus && field !== 'kommPruef') { + // only the next free attempt column shows an add button if(field !== ('antritt_' + ((data.pruefungen?.length ?? 0) + 1))) return '' - } else { - // keine Neuanlage vor einem bereits eingetragenen Termin + } else if(field !== 'kommPruef') { + // no new exam before an exam that exists if((data.pruefungen ?? []).some(p => p.datum >= field)) return '' } addButton( this.$capitalize(this.$p.t('benotungstool/addPruefungButtonText')), null, - () => this.openPruefungModal(data, null, field) + () => this.openPruefungModal(data, null, field), + 'btn-pruefung-add' ) return rowDiv @@ -1528,7 +1495,10 @@ export const Benotungstool = { getPruefungDateBounds(student, pruefung, fallbackDate) { // the exam date must stay strictly between the dates of the chronologically adjacent // pruefungen so the attempt order is preserved. Returns inclusive datepicker bounds. - const refDate = (pruefung?.datum ?? fallbackDate ?? '').slice(0, 10) + // A column name that is not a date ('antritt_2', 'kommPruef') means a new exam after all + // existing ones. Use a date in the far future, so every existing exam is a lower bound. + const raw = (pruefung?.datum ?? fallbackDate ?? '').slice(0, 10) + const refDate = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? raw : '9999-12-31' let lower = null, upper = null; (student.pruefungen ?? []).forEach(p => { if(pruefung && p.pruefung_id != null && pruefung.pruefung_id != null && p.pruefung_id === pruefung.pruefung_id) return @@ -1542,11 +1512,41 @@ export const Benotungstool = { max: upper ? this.addDays(this.parseISODate(upper), -1) : null } }, + /** + * Lädt die Lehrenden einer Lehreinheit für den Dialog. Bei genau einem Lehrenden bleibt die + * Auswahl leer; der Server setzt ihn dann selbst ein. + */ + ladeLehrende(lehreinheitIds) { + this.lehrendeDerLehreinheit = [] + this.selectedLehrender = null + + const eindeutig = [...new Set((lehreinheitIds ?? []).filter(id => id != null))] + if(eindeutig.length !== 1) return + + this.$api.call(ApiNoten.getLehrendeFuerLehreinheit(eindeutig[0], this.lv_id, this.sem_kurzbz)) + .then(res => { + if(res?.meta?.status !== 'success') return + this.lehrendeDerLehreinheit = (res.data ?? []).map(l => ({ + ...l, + anzeigename: `${l.vorname ?? ''} ${l.nachname ?? ''}`.trim() || l.mitarbeiter_uid + })) + + // nur eine Auswahl anbieten, wenn es etwas zu wählen gibt + if(this.lehrendeDerLehreinheit.length > 1) { + const bestehend = this.pruefung?.mitarbeiter_uid + this.selectedLehrender = this.lehrendeDerLehreinheit + .some(l => l.mitarbeiter_uid === bestehend) + ? bestehend + : this.lehrendeDerLehreinheit[0].mitarbeiter_uid + } + }) + }, openPruefungModal(student, pruefung = null, field) { this.pruefungStudent = student this.pruefung = pruefung + this.ladeLehrende([student?.lehreinheit_id]) - // Im Antrittsmodus trägt die Spalte kein Datum -> beim Anlegen mit heute vorbelegen. + // In the attempt mode the column has no date, therefore a new exam starts with today. const dateStr = this.pruefung?.datum ?? (/^\d{4}-\d{2}-\d{2}$/.test(field) ? field : null) this.selectedPruefungDate = dateStr ? this.parseISODate(dateStr) : new Date() @@ -1590,6 +1590,7 @@ export const Benotungstool = { const button = document.createElement('button'); button.className = 'btn btn-outline-secondary'; + button.dataset.cy = 'btn-uebernehmen'; button.textContent = this.$capitalize(this.$p.t('benotungstool/c4notenvorschlagUebernehmen')); return button; @@ -1632,7 +1633,7 @@ export const Benotungstool = { this.pruefungen = data[1] ?? [] this.domain = data[2] - // contains notenvorschläge from moodle, lv_note und den Prüfungsverlauf je Student + // holds the grade proposals from Moodle, lv_note and the exam history of each student this.teilnoten = data[3] ?? [] this.distinctPruefungsDates = [] @@ -1643,8 +1644,8 @@ export const Benotungstool = { p.dateObj = this.parseISODate(p.datum) - // der abschliessende Termin steht in einer eigenen, immer letzten Spalte - if(p.terminal) { + // the final exam has its own column, and that column is always the last one + if(p.kommissionell) { student['kommPruef'] = p } else { student.pruefungen.push(p) @@ -1655,10 +1656,10 @@ export const Benotungstool = { this.distinctPruefungsDates.sort() this.studenten?.forEach(s => { - // der Server liefert die Termine bereits in Verlaufsreihenfolge; hier nur absichern + // the server sends the exams in the order of the history; this only makes it sure s.pruefungen.sort((p1, p2) => (p1.position ?? 0) - (p2.position ?? 0)) - // Antrittszahl, Grenzen und die nächste mögliche Rolle kommen vom Server + // the attempt count, the limits and the next possible role come from the server s.verlauf = this.teilnoten[s.uid]?.verlauf ?? null this.indexPruefungen(s) @@ -1687,7 +1688,7 @@ export const Benotungstool = { this.recalculateSelectable(s) }) - // frisch geladene LV -> Spalten in jedem Fall neu aufbauen, Scrollstand bewusst zurücksetzen + // a new course: always build the columns again, and reset the scroll position this.applyPruefungColumns(true) this.$refs.notenTable.tabulator.setData(this.studenten); @@ -1696,7 +1697,7 @@ export const Benotungstool = { // reflect the sticky-column selection on the freshly (re)built columns this.applyStickyColumnState() - // refresh the "neue Prüfung" dropdown options for the freshly loaded data + // build the options of the "neue Prüfung" dropdown again for the new data this.tableVersion++ // keep the loading overlay up until the browser has actually painted the rebuilt table @@ -1740,9 +1741,8 @@ export const Benotungstool = { ? this.lehrveranstaltungen.find(lva => lva.lehrveranstaltung_id == preselectLvId) ?? null : null }, - // Decides how the LV dropdown is fed for the current user: Assistenzen pick a Studiengang first - // (LVs scoped by entitlement), teachers get their assigned LVs directly. Also loads the - // entitled Studiengänge for the Studiengang dropdown when in Assistenz mode. + // An assistant selects a degree programme first, and then sees the courses of that + // entitlement. A teacher sees the own courses directly. setupLvSource(sem_kurzbz, preselectLvId = null) { return this.$api.call(ApiNoten.getBenotungstoolContext(sem_kurzbz, preselectLvId)).then(res => { this.isAssistenz = !!res.data?.isAssistenz @@ -1949,7 +1949,8 @@ export const Benotungstool = { this.lv_id, this.pruefungStudent.lehreinheit_id, this.sem_kurzbz, - this.pruefung?.pruefung_id ?? null // null = adding a new pruefung, otherwise edit of this record + this.pruefung?.pruefung_id ?? null, // null = adding a new pruefung, otherwise edit of this record + this.selectedLehrender )).then(res => { if(res.meta.status === 'success') { //'Prüfung für Student ' + this.pruefungStudent.uid + ' bearbeitet oder angelegt' this.$fhcAlert.alertDefault( @@ -1962,9 +1963,7 @@ export const Benotungstool = { this.applyLvGesamtnote(s, res.data[1]) - // Zeile komplett aus dem Serververlauf neu aufbauen: das deckt Anlegen und - // Bearbeiten gleichermassen ab. Scrollstand halten, damit die bearbeitete Zeile - // im Blick bleibt. + // build the full row again from the server history; this covers a new exam and an edit this.colLayoutRestored = false this.preserveScroll(() => { this.applyVerlauf(s, res.data[2]) @@ -1992,7 +1991,7 @@ export const Benotungstool = { enumerable: true, configurable: true }) - // a student's selectability may have changed -> refresh the "neue Prüfung" dropdown options + // a student can be selectable now, therefore build the "neue Prüfung" options again this.tableVersion++ }, saveNoteneingabe() { @@ -2010,13 +2009,25 @@ export const Benotungstool = { res.data.forEach(d => { const s = this.studenten.find(s => s.uid === d.uid) + if(!s) return + s.freigabedatum = this.parseDate(d.freigabedatum) s.benotungsdatum = this.parseDate(d.benotungsdatum) s.freigegeben = this.checkFreigabe(s.freigabedatum, s.benotungsdatum, s.uid); + + // die Freigabe legt den ersten Antritt an; ohne den Verlauf bliebe die Zeile bis + // zum nächsten Laden leer + if(d.verlauf) this.applyVerlauf(s, d.verlauf) }) this.changedNotenCounter++; - this.$refs.notenTable.tabulator.redraw(true) + // die neuen Termine brauchen unter Umständen eine zusätzliche Spalte + this.preserveScroll(() => { + this.applyPruefungColumns() + const loaded = this.$refs.notenTable.tabulator.setData(this.studenten) + this.$refs.notenTable.tabulator.redraw(true) + return loaded + }) }).finally(() => { this.loading = false }) @@ -2027,10 +2038,12 @@ export const Benotungstool = { this.$refs.modalContainerNotenSpeichern.show() }, openNewPruefungsdatumModal() { - // gleiche Eingaben wie im Dialog aus der Tabelle -> beide starten leer, ohne Note + // the same fields as the dialog in the table: both start empty and without a grade this.selectedPruefungNote = null this.selectedPruefungPunkte = null this.selectedPruefungDate = new Date() + // nur bei einer gemeinsamen Lehreinheit gibt es eine sinnvolle Lektorenauswahl + this.ladeLehrende((this.selectedUids ?? []).map(s => s.lehreinheit_id)) this.$refs.modalContainerNeuesPruefungsdatum.show() }, openPruefungImportModal() { @@ -2078,8 +2091,8 @@ export const Benotungstool = { if(!uids.length) return - // eine fehlende LV-Note blockiert nicht: sie entsteht serverseitig mit der Note dieser - // Prüfung. Der Hinweis darauf steht im Dialog, siehe pruefungOhneLvNote. + // A missing course grade blocks nothing. The server creates it from the grade of this + // exam. The dialog shows a hint, see pruefungOhneLvNote. this.loading = true; this.$api.call(ApiNoten.createPruefungen( uids, @@ -2087,7 +2100,8 @@ export const Benotungstool = { this.lv_id, this.sem_kurzbz, this.selectedPruefungNote?.note ?? null, - this.selectedPruefungPunkte ?? null + this.selectedPruefungPunkte ?? null, + this.selectedLehrender )).then(res => { if(res.meta.status === "success") { @@ -2137,6 +2151,12 @@ export const Benotungstool = { }, watch: { selectedUids(newVal, oldVal) { + // im offenen Sammeldialog ändert das Multiselect die Auswahl; die Lektorenliste hängt + // an der Lehreinheit und muss dann neu geladen werden + if(this.neuesPruefungsdatumModalVisible) { + this.ladeLehrende((newVal ?? []).map(s => s.lehreinheit_id)) + } + const table = this.$refs.notenTable?.tabulator if (!table) return; @@ -2176,15 +2196,8 @@ export const Benotungstool = { this.selectedLehreinheit = null } }, - getKommPruefCount(newVal) { - if(!this.config.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) return 0 - if(this.$refs.notenTable?.tabulator && newVal > 0) { - const kommPruefCol = this.$refs.notenTable?.tabulator.getColumn("kommPruef") - kommPruefCol.show() - } else if(this.$refs.notenTable?.tabulator && newVal == 0) { - const kommPruefCol = this.$refs.notenTable?.tabulator.getColumn("kommPruef") - kommPruefCol.hide() - } + kommPruefSpalteNoetig() { + this.applyKommPruefSpalte() }, selectedPruefungNote(newVal, oldVal) { if (!newVal || !this.pruefungStudent) return @@ -2197,7 +2210,7 @@ export const Benotungstool = { const limit = limitMap[note] if (limit == null) return - // alle Termine des Verlaufs; der abschliessende steht separat an der Zeile + // all exams of the history; the row holds the final exam separately const allPruefungen = [...this.pruefungStudent.pruefungen] if (this.pruefungStudent.kommPruef) allPruefungen.push(this.pruefungStudent.kommPruef) @@ -2231,16 +2244,15 @@ export const Benotungstool = { + this.$p.t('global/gesamt') + ': ' + (this.studenten?.length || 0) + ''; }, - // ausgewählte Studenten, für die noch keine LV-Note existiert: sie entsteht mit dieser - // Prüfung. Nur ein Hinweis im Dialog - blockiert nichts und braucht keine Bestätigung. + // a hint for the dialog: for these students the exam creates the course grade pruefungOhneLvNote() { return (this.selectedUids ?? []).filter(s => NotenRules.brauchtNeueLvNote(s)) }, - // derselbe Hinweis im Dialog aus der Tabelle: beide Dialoge zeigen dasselbe an + // the same hint in the dialog of the table: both dialogs show the same text pruefungStudentOhneLvNote() { return !this.pruefung && !!this.pruefungStudent && NotenRules.brauchtNeueLvNote(this.pruefungStudent) }, - // aktive Spaltenaufteilung: Wahl des Benutzers, sonst die Vorgabe aus der Konfiguration + // the active column layout: the choice of the user, or the default from the configuration pruefungsspaltenModus() { return this.pruefungsspalten ?? this.config?.CIS_GESAMTNOTE_PRUEFUNGSSPALTEN ?? 'antritt' }, @@ -2262,10 +2274,8 @@ export const Benotungstool = { } }, getStudentenOptions() { - // the "neue Prüfung" multiselect mirrors the table: same row order (current sort + filter) - // and the same selectable set. tableVersion is bumped on sort/filter/data changes so this - // recomputes whenever the table order changes. - const _ = this.tableVersion // reactive dependency + // the multiselect mirrors the table: the same order after the sort and the filter + const _ = this.tableVersion // reactive dependency, wird bei Sort/Filter/Daten erhöht const table = this.$refs.notenTable?.tabulator if(!table) { @@ -2279,9 +2289,37 @@ export const Benotungstool = { }, getKommPruefCount(){ let counter = 0 - this.studenten?.forEach(s => {if(s['kommPruef']){counter++}}) + this.studenten?.forEach(s => {if(s['kommPruef']){counter++}}) return counter }, + /** + * Titel der kommissionellen Spalte. Im Datumsmodus tragen alle anderen Spalten ihr Datum im + * Kopf, daher bekommt diese es auch - aber nur, wenn alle Termine darin auf denselben Tag + * fallen. Bei verschiedenen Daten steht das Datum weiterhin in der Zelle. + */ + kommPruefSpaltentitel() { + const titel = this.$capitalize(this.$p.t('benotungstool/c4kommPruef')) + if(this.pruefungsspaltenModus !== 'datum') return titel + + const daten = new Set( + (this.studenten ?? []) + .map(s => (s.kommPruef?.datum ?? '').slice(0, 10)) + .filter(d => d !== '') + ) + if(daten.size !== 1) return titel + + return `${titel} - ${this.formatDatumDMY([...daten][0])}` + }, + /** + * Die kommissionelle Spalte wird gebraucht, sobald ein Termin darin steht ODER bei einer + * Zeile der letzte Antritt fällig ist - sonst fehlt der Platz zum Anlegen. + */ + kommPruefSpalteNoetig() { + if(!this.config?.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) return false + if(this.getKommPruefCount > 0) return true + + return (this.studenten ?? []).some(s => s.verlauf?.naechsteRolle === 'kommissionell') + }, getSaveBtnClass() { return this.changedNoten?.length ? "btn btn-primary ml-2" : "btn btn-secondary ml-2" }, @@ -2307,11 +2345,8 @@ export const Benotungstool = { }, []) : [] return cs }, - // Row-by-row preview for the Noten-freigeben modal, over the exact set that - // saveStudentenNoten will release (changedNoten). Freigabe releases the LV-Note - // (lv_note); it does not itself write the Zeugnisnote (that is a separate übernahme). - // So we compare what is currently on record in the Zeugnis (note) against the - // LV-Note that is about to be freigegeben (lv_note), and flag any mismatch. + // The preview of the release dialog, over changedNoten. The release writes the course grade + // only, not the transcript grade. Therefore the dialog compares the two. freigabeSummary() { return this.changedNoten.map(s => { const zeugnisOpt = this.notenOptions?.find(opt => opt.note == s.note) @@ -2351,7 +2386,7 @@ export const Benotungstool = { this.setupMounted() }, template: ` - + - + - - + - + @@ -2662,7 +2720,7 @@ export const Benotungstool = { -
+
- - - - diff --git a/public/js/components/Cis/Benotungstool/NotenlisteLinks.js b/public/js/components/Cis/Benotungstool/NotenlisteLinks.js index 58d16c41b..2576118bf 100644 --- a/public/js/components/Cis/Benotungstool/NotenlisteLinks.js +++ b/public/js/components/Cis/Benotungstool/NotenlisteLinks.js @@ -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); } diff --git a/public/js/components/Cis/Benotungstool/notenRules.js b/public/js/components/Cis/Benotungstool/notenRules.js index 1360f9692..942f596e1 100644 --- a/public/js/components/Cis/Benotungstool/notenRules.js +++ b/public/js/components/Cis/Benotungstool/notenRules.js @@ -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"; }; diff --git a/system/phrasesupdate.php b/system/phrasesupdate.php index 41d955d0e..c3b0523e0 100644 --- a/system/phrasesupdate.php +++ b/system/phrasesupdate.php @@ -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 ------------------------------------------------------------------------------------ ); diff --git a/system/seeders/017_extension_grants.sql b/system/seeders/017_extension_grants.sql deleted file mode 100644 index 46ee620ce..000000000 --- a/system/seeders/017_extension_grants.sql +++ /dev/null @@ -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 $$; diff --git a/system/seeders/017_schema_grants.sql b/system/seeders/017_schema_grants.sql new file mode 100644 index 000000000..6b8582c8a --- /dev/null +++ b/system/seeders/017_schema_grants.sql @@ -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 $$; diff --git a/tests/cypress/e2e/specs/api/noten/noten.access.cy.js b/tests/cypress/e2e/specs/api/noten/noten.access.cy.js index 5ea5ca4ea..d59587893 100644 --- a/tests/cypress/e2e/specs/api/noten/noten.access.cy.js +++ b/tests/cypress/e2e/specs/api/noten/noten.access.cy.js @@ -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", () => { diff --git a/tests/cypress/e2e/specs/api/noten/noten.bulk.cy.js b/tests/cypress/e2e/specs/api/noten/noten.bulk.cy.js index 3d2285e07..5cfb87555 100644 --- a/tests/cypress/e2e/specs/api/noten/noten.bulk.cy.js +++ b/tests/cypress/e2e/specs/api/noten/noten.bulk.cy.js @@ -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; + }); + }); + }); }); }); diff --git a/tests/cypress/e2e/specs/api/noten/noten.freigabe.cy.js b/tests/cypress/e2e/specs/api/noten/noten.freigabe.cy.js index dc92b538a..a2191e8d0 100644 --- a/tests/cypress/e2e/specs/api/noten/noten.freigabe.cy.js +++ b/tests/cypress/e2e/specs/api/noten/noten.freigabe.cy.js @@ -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) => { diff --git a/tests/cypress/e2e/specs/api/noten/noten.frist.cy.js b/tests/cypress/e2e/specs/api/noten/noten.frist.cy.js index 0af22643f..cff99a353 100644 --- a/tests/cypress/e2e/specs/api/noten/noten.frist.cy.js +++ b/tests/cypress/e2e/specs/api/noten/noten.frist.cy.js @@ -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. }); diff --git a/tests/cypress/e2e/specs/api/noten/noten.notenschluessel.cy.js b/tests/cypress/e2e/specs/api/noten/noten.notenschluessel.cy.js index 1859a34ee..567c6d28c 100644 --- a/tests/cypress/e2e/specs/api/noten/noten.notenschluessel.cy.js +++ b/tests/cypress/e2e/specs/api/noten/noten.notenschluessel.cy.js @@ -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]); diff --git a/tests/cypress/e2e/specs/api/noten/noten.notenvorschlag.cy.js b/tests/cypress/e2e/specs/api/noten/noten.notenvorschlag.cy.js index 8e75911f6..d55538482 100644 --- a/tests/cypress/e2e/specs/api/noten/noten.notenvorschlag.cy.js +++ b/tests/cypress/e2e/specs/api/noten/noten.notenvorschlag.cy.js @@ -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]); diff --git a/tests/cypress/e2e/specs/api/noten/noten.pruefungsantritte.cy.js b/tests/cypress/e2e/specs/api/noten/noten.pruefungsantritte.cy.js index 4a20046f0..224c12db7 100644 --- a/tests/cypress/e2e/specs/api/noten/noten.pruefungsantritte.cy.js +++ b/tests/cypress/e2e/specs/api/noten/noten.pruefungsantritte.cy.js @@ -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); diff --git a/tests/cypress/e2e/specs/api/noten/noten.pruefungstermin.cy.js b/tests/cypress/e2e/specs/api/noten/noten.pruefungstermin.cy.js index ff3d07f2d..d66732f95 100644 --- a/tests/cypress/e2e/specs/api/noten/noten.pruefungstermin.cy.js +++ b/tests/cypress/e2e/specs/api/noten/noten.pruefungstermin.cy.js @@ -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); diff --git a/tests/cypress/e2e/specs/api/noten/noten.ueberschreiben.cy.js b/tests/cypress/e2e/specs/api/noten/noten.ueberschreiben.cy.js index 6119053b9..509b2a4ed 100644 --- a/tests/cypress/e2e/specs/api/noten/noten.ueberschreiben.cy.js +++ b/tests/cypress/e2e/specs/api/noten/noten.ueberschreiben.cy.js @@ -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( diff --git a/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.freigabe.cy.js b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.freigabe.cy.js new file mode 100644 index 000000000..0d88bbe24 --- /dev/null +++ b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.freigabe.cy.js @@ -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); + }); + }); +}); diff --git a/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.import.cy.js b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.import.cy.js new file mode 100644 index 000000000..e2c1f2d8f --- /dev/null +++ b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.import.cy.js @@ -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: "UIDNote" je Zeile, schreibt nur die LV-Note. + * Prüfungsimport: "UIDdd.MM.yyyyNote" 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])); + }); + }); +}); diff --git a/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.notenvorschlag.cy.js b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.notenvorschlag.cy.js new file mode 100644 index 000000000..88c9fb433 --- /dev/null +++ b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.notenvorschlag.cy.js @@ -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"); + }); +}); diff --git a/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.pruefung.cy.js b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.pruefung.cy.js new file mode 100644 index 000000000..dae56411a --- /dev/null +++ b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.pruefung.cy.js @@ -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); + }); +}); diff --git a/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.punkte.cy.js b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.punkte.cy.js new file mode 100644 index 000000000..b47c60115 --- /dev/null +++ b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.punkte.cy.js @@ -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)); + }); + }); + }); +}); diff --git a/tests/cypress/e2e/specs/unit/notenRules.cy.js b/tests/cypress/e2e/specs/unit/notenRules.cy.js new file mode 100644 index 000000000..e6b01f92a --- /dev/null +++ b/tests/cypress/e2e/specs/unit/notenRules.cy.js @@ -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"); + }); + }); +}); diff --git a/tests/cypress/suites/readme_benotungstool.txt b/tests/cypress/suites/readme_benotungstool.txt new file mode 100644 index 000000000..310417a81 --- /dev/null +++ b/tests/cypress/suites/readme_benotungstool.txt @@ -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. diff --git a/tests/cypress/support/api/notenApi.js b/tests/cypress/support/api/notenApi.js index 9dc9993ef..69de8a1e4 100644 --- a/tests/cypress/support/api/notenApi.js +++ b/tests/cypress/support/api/notenApi.js @@ -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)); diff --git a/tests/cypress/support/commands.js b/tests/cypress/support/commands.js index f0fce73f8..69f2b641c 100644 --- a/tests/cypress/support/commands.js +++ b/tests/cypress/support/commands.js @@ -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; +}); diff --git a/tests/cypress/support/helpers/network.js b/tests/cypress/support/helpers/network.js new file mode 100644 index 000000000..7a9a7b2cb --- /dev/null +++ b/tests/cypress/support/helpers/network.js @@ -0,0 +1,2 @@ +export const waitForOk = (alias) => + cy.wait(alias).its("response.statusCode").should("eq", 200); diff --git a/tests/cypress/support/helpers/notenConfig.js b/tests/cypress/support/helpers/notenConfig.js new file mode 100644 index 000000000..685f68888 --- /dev/null +++ b/tests/cypress/support/helpers/notenConfig.js @@ -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(); +}; diff --git a/tests/cypress/support/helpers/notenErrors.js b/tests/cypress/support/helpers/notenErrors.js index 4edd80fca..a4049083e 100644 --- a/tests/cypress/support/helpers/notenErrors.js +++ b/tests/cypress/support/helpers/notenErrors.js @@ -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}.', diff --git a/tests/cypress/support/helpers/notenReset.js b/tests/cypress/support/helpers/notenReset.js index ecb8b7ee3..af3ff1c06 100644 --- a/tests/cypress/support/helpers/notenReset.js +++ b/tests/cypress/support/helpers/notenReset.js @@ -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, diff --git a/tests/cypress/support/helpers/notenScenario.js b/tests/cypress/support/helpers/notenScenario.js index 63df308e0..67e3acb2a 100644 --- a/tests/cypress/support/helpers/notenScenario.js +++ b/tests/cypress/support/helpers/notenScenario.js @@ -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({ diff --git a/tests/cypress/support/helpers/notenTestData.js b/tests/cypress/support/helpers/notenTestData.js index 57cbf9318..882bba9e3 100644 --- a/tests/cypress/support/helpers/notenTestData.js +++ b/tests/cypress/support/helpers/notenTestData.js @@ -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); diff --git a/tests/cypress/support/pages/benotungstool.po.js b/tests/cypress/support/pages/benotungstool.po.js new file mode 100644 index 000000000..0535183cc --- /dev/null +++ b/tests/cypress/support/pages/benotungstool.po.js @@ -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-", 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], ...] -> "uidnote" 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(); diff --git a/tests/cypress/tasks/notenDb.js b/tests/cypress/tasks/notenDb.js index 366eacd49..7df57326a 100644 --- a/tests/cypress/tasks/notenDb.js +++ b/tests/cypress/tasks/notenDb.js @@ -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(), });