mirror of
https://github.com/FH-Complete/FHC-Core.git
synced 2026-10-12 15:04:04 +00:00
refactored the core pruefungslogic to revolve around PruefungsverlaufLib.php instead of tbl_pruefungstyp which was highly inflexible for any adjustments. could be reused in Stv/Pruefungen aswell since there invalid inserts are very much possible; typen are still being used but are irrelevant for sequencing the attempts correctly; incorporated kommissionelle Pruefung into Gesamtnoteneingabe as the last possible antritt a student makes; updated cypress suites accordingly and added some ui tests aswell; documented usage in a readme_benotungstool.txt for the suite;
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if (defined('CIS_GESAMTNOTE_GEWICHTUNG') && CIS_GESAMTNOTE_GEWICHTUNG) {
|
||||
// Lehreinheitsgewichtung
|
||||
$punkte_vorschlag = round($punktesumme_gewichtet / $gewichtsumme, 2);
|
||||
$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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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,7 +1473,7 @@ 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 = [];
|
||||
@@ -1442,16 +1481,17 @@ class Noten extends FHCAPI_Controller
|
||||
foreach ($pruefungen as $pruefung) {
|
||||
|
||||
if(CIS_GESAMTNOTE_PUNKTE) {
|
||||
$result = $this->NotenschluesselaufteilungModel->getNote($pruefung->punkte, $lv_id, $sem_kurzbz);
|
||||
// $this->addMeta($pruefung->uid."result", $result);
|
||||
$pruefung->note = $this->getDataOrTerminateWithError($result);
|
||||
// $this->addMeta($pruefung->uid."note", $pruefung->note);
|
||||
$note = $this->noteAusPunkten($pruefung->punkte, $lv_id, $sem_kurzbz, $pruefung->uid);
|
||||
// no grade can be derived: skip the row, but do not stop the full request
|
||||
if(is_string($note)) { $ret[$pruefung->uid] = $note; continue; }
|
||||
$pruefung->note = $note;
|
||||
}
|
||||
|
||||
// identisch zum Dialog aus der Tabelle, nur je Importzeile
|
||||
// the same as the dialog in the table, but for each import row
|
||||
$ret[$pruefung->uid] = $this->savePruefungFuerStudent(
|
||||
null, $pruefung->uid, $lv_id, $sem_kurzbz, $pruefung->lehreinheit_id,
|
||||
$pruefung->note, $pruefung->punkte, $pruefung->datum
|
||||
$pruefung->note, $pruefung->punkte, $pruefung->datum,
|
||||
property_exists($pruefung, 'mitarbeiter_uid') ? $pruefung->mitarbeiter_uid : null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1461,23 +1501,35 @@ class Noten extends FHCAPI_Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktuelle LV-Gesamtnote eines Studenten oder null. In den Bulk-Pfaden ist sie zugleich die
|
||||
* ursprüngliche Note, weil dort - anders als in saveStudentPruefung - vorher nichts
|
||||
* überschrieben wird.
|
||||
* Creates or updates the first attempt. The release calls it.
|
||||
*
|
||||
* The date is the benotungsdatum of the course grade, which is the moment the teacher recorded
|
||||
* the assessment. The examination rules ask for the date of the last performance (§8), but the
|
||||
* system does not hold that date, because many courses have no exam date at all. The old tool
|
||||
* used the benotungsdatum for the same row, so the value stays comparable with the old data.
|
||||
*
|
||||
* It applies only while one exam or no exam exists. After that the course grade holds the last
|
||||
* grade, and it must not overwrite attempt 1. A credited grade changes nothing.
|
||||
*/
|
||||
/**
|
||||
* Legt den ersten Prüfungstermin an oder aktualisiert ihn (Upsert).
|
||||
* The grade from the points for ONE bulk row. It gives the grade, or a translated error message
|
||||
* if no grade can be derived. The caller then skips the row and continues with the other rows.
|
||||
*
|
||||
* Aufgerufen bei der Notenfreigabe: erst dort wird die Note verbindlich, deshalb entsteht genau
|
||||
* dann der erste Antritt - und nicht beiläufig beim Anlegen einer Prüfung. Datiert auf das
|
||||
* Freigabedatum.
|
||||
*
|
||||
* Greift nur, solange höchstens ein Termin existiert: sobald Wiederholungen erfasst sind, führt
|
||||
* die LV-Note die zuletzt erreichte Note und dürfte den ersten Antritt nicht mehr überschreiben.
|
||||
* Bei einer Anrechnung passiert nichts.
|
||||
*
|
||||
* @return void
|
||||
* @return mixed|string
|
||||
*/
|
||||
private function noteAusPunkten($punkte, $lv_id, $sem_kurzbz, $uid)
|
||||
{
|
||||
$result = $this->NotenschluesselaufteilungModel->getNote($punkte, $lv_id, $sem_kurzbz);
|
||||
if(isError($result)) return getError($result);
|
||||
|
||||
$note = getData($result);
|
||||
if($note === null || $note === '') {
|
||||
return $this->p->t('benotungstool', 'c4punkteKeineNoteErmittelt', [$uid]);
|
||||
}
|
||||
|
||||
return $note;
|
||||
}
|
||||
|
||||
private function upsertErstantritt($lva_id, $student_uid, $stsem, $note, $punkte, $datum)
|
||||
{
|
||||
if($this->VerlaufLib->istAnrechnungsnote($this->getZeugnisnote($lva_id, $student_uid, $stsem))) return;
|
||||
@@ -1503,7 +1555,7 @@ class Noten extends FHCAPI_Controller
|
||||
return;
|
||||
}
|
||||
|
||||
// lehreinheit_id serverseitig auflösen statt sie vom Client zu übernehmen
|
||||
// the server finds the lehreinheit_id; it does not use the value from the client
|
||||
$resLe = $this->LehrveranstaltungModel->getLeByStudent($student_uid, $stsem, $lva_id);
|
||||
if(isError($resLe) || !hasData($resLe)) return;
|
||||
$le = current(getData($resLe));
|
||||
@@ -1512,7 +1564,7 @@ class Noten extends FHCAPI_Controller
|
||||
array(
|
||||
'lehreinheit_id' => $le->lehreinheit_id,
|
||||
'student_uid' => $student_uid,
|
||||
'mitarbeiter_uid' => getAuthUID(),
|
||||
'mitarbeiter_uid' => $this->benotenderMitarbeiter($le->lehreinheit_id),
|
||||
'note' => $note,
|
||||
'punkte' => $punkte,
|
||||
'pruefungstyp_kurzbz' => $this->VerlaufLib->legacyTypFuerAntritt(1),
|
||||
@@ -1530,11 +1582,70 @@ class Noten extends FHCAPI_Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeugnisnote eines Studenten in einer LV, oder null. Anrechnungen werden dort geführt (nicht in
|
||||
* der LV-Note) und entscheiden darüber, ob überhaupt Prüfungen möglich sind.
|
||||
* The teachers of one Lehreinheit, from lehre.tbl_lehreinheitmitarbeiter.
|
||||
*
|
||||
* @return mixed|null
|
||||
* @return array
|
||||
*/
|
||||
private function lehrendeDerLehreinheit($lehreinheit_id)
|
||||
{
|
||||
if(!$lehreinheit_id) return array();
|
||||
|
||||
$this->load->model('education/Lehreinheitmitarbeiter_model', 'LehreinheitmitarbeiterModel');
|
||||
$result = $this->LehreinheitmitarbeiterModel->getLektorenByLe($lehreinheit_id);
|
||||
if(isError($result) || !hasData($result)) return array();
|
||||
|
||||
$lehrende = getData($result);
|
||||
usort($lehrende, function($a, $b) {
|
||||
return strcmp($a->mitarbeiter_uid, $b->mitarbeiter_uid);
|
||||
});
|
||||
|
||||
return $lehrende;
|
||||
}
|
||||
|
||||
/**
|
||||
* The uid of the person who gave the grade. It goes into lehre.tbl_pruefung and into
|
||||
* campus.tbl_lvgesamtnote.
|
||||
*
|
||||
* Historically only teachers used this tool, so the caller was always right. An assistant may
|
||||
* do the data entry too, but the grade still comes from the teacher. The row must therefore
|
||||
* carry the teacher of the Lehreinheit, not the person who types. insertvon and updatevon keep
|
||||
* the caller, because they answer a different question.
|
||||
*
|
||||
* Order: a valid selection wins, then the caller if the caller teaches this Lehreinheit, then
|
||||
* the only teacher. With several teachers and no selection the first one wins, because an
|
||||
* assistant is never the correct answer. Without any teacher the caller stays.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function benotenderMitarbeiter($lehreinheit_id, $gewaehlt = null)
|
||||
{
|
||||
$uids = array();
|
||||
foreach($this->lehrendeDerLehreinheit($lehreinheit_id) as $lehrend) $uids[] = $lehrend->mitarbeiter_uid;
|
||||
|
||||
if(count($uids) === 0) return getAuthUID();
|
||||
if($gewaehlt !== null && $gewaehlt !== '' && in_array($gewaehlt, $uids)) return $gewaehlt;
|
||||
if(in_array(getAuthUID(), $uids)) return getAuthUID();
|
||||
|
||||
return $uids[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* The same answer for a course grade, which has no Lehreinheit of its own. The Lehreinheit of
|
||||
* the student decides.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function benotenderMitarbeiterFuerStudent($lva_id, $student_uid, $stsem, $gewaehlt = null)
|
||||
{
|
||||
$lehreinheit_id = null;
|
||||
|
||||
$resLe = $this->LehrveranstaltungModel->getLeByStudent($student_uid, $stsem, $lva_id);
|
||||
if(!isError($resLe) && hasData($resLe)) $lehreinheit_id = current(getData($resLe))->lehreinheit_id;
|
||||
|
||||
return $this->benotenderMitarbeiter($lehreinheit_id, $gewaehlt);
|
||||
}
|
||||
|
||||
/** The transcript grade or null. Credited grades are there, not in the course grade. @return mixed|null */
|
||||
private function getZeugnisnote($lva_id, $student_uid, $stsem)
|
||||
{
|
||||
$this->load->model('education/Zeugnisnote_model', 'ZeugnisnoteModel');
|
||||
@@ -1548,26 +1659,26 @@ class Noten extends FHCAPI_Controller
|
||||
return (!isError($res) && hasData($res)) ? getData($res)[0]->note : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the course grade WITHOUT the filter. getLvGesamtNoten() uses 'freigabedatum < NOW()'
|
||||
* and therefore hides a new grade. In this tool this wrapper is always the correct one.
|
||||
*/
|
||||
private function getLvGesamtnoteRow($lva_id, $student_uid, $stsem)
|
||||
{
|
||||
// ACHTUNG: getLvGesamtNoten filtert auf 'freigabedatum < NOW()' und liefert daher NUR
|
||||
// freigegebene Noten. Für die Frage "existiert eine LV-Note?" ist das falsch - eine gerade
|
||||
// erst angelegte Note hat noch kein Freigabedatum und wäre unsichtbar (Endlos-Fehler
|
||||
// "keine LV-Note eingetragen" bzw. doppelter Insert). Deshalb hier der ungefilterte Zugriff.
|
||||
$res = $this->LvgesamtnoteModel->getLvGesamtNoteVorschlag($lva_id, $student_uid, $stsem);
|
||||
return (!isError($res) && hasData($res)) ? getData($res)[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt eine LV-Gesamtnote an. Die Zeugnisnote bleibt unberührt - dieses Tool liest sie nur,
|
||||
* die Übernahme in das Zeugnis passiert in der Studierendenverwaltung.
|
||||
*
|
||||
* @return stdClass|null
|
||||
*/
|
||||
private function createLvGesamtnote($lva_id, $student_uid, $stsem, $note, $punkte)
|
||||
/** Creates a course grade. The student administration writes it to the transcript. @return stdClass|null */
|
||||
private function createLvGesamtnote($lva_id, $student_uid, $stsem, $note, $punkte, $lehreinheit_id = null, $mitarbeiter_uid = null)
|
||||
{
|
||||
$jetzt = date("Y-m-d H:i:s");
|
||||
|
||||
// the caller knows the Lehreinheit here, so no second lookup is needed
|
||||
$benotender = $lehreinheit_id
|
||||
? $this->benotenderMitarbeiter($lehreinheit_id, $mitarbeiter_uid)
|
||||
: $this->benotenderMitarbeiterFuerStudent($lva_id, $student_uid, $stsem, $mitarbeiter_uid);
|
||||
|
||||
$id = $this->LvgesamtnoteModel->insert(
|
||||
array(
|
||||
'student_uid' => $student_uid,
|
||||
@@ -1575,7 +1686,7 @@ class Noten extends FHCAPI_Controller
|
||||
'studiensemester_kurzbz' => $stsem,
|
||||
'note' => $note,
|
||||
'punkte' => $punkte,
|
||||
'mitarbeiter_uid' => getAuthUID(),
|
||||
'mitarbeiter_uid' => $benotender,
|
||||
'benotungsdatum' => $jetzt,
|
||||
'freigabedatum' => null,
|
||||
'freigabevon_uid' => null,
|
||||
@@ -1618,6 +1729,35 @@ class Noten extends FHCAPI_Controller
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* GET METHOD
|
||||
* expects 'lehreinheit_id', 'lv_id', 'sem_kurzbz'
|
||||
* The teachers of one Lehreinheit. The dialog needs them when the Lehreinheit has more than one,
|
||||
* because the exam must carry the teacher and not the person who types.
|
||||
*/
|
||||
public function getLehrendeFuerLehreinheit() {
|
||||
$lehreinheit_id = $this->input->get('lehreinheit_id');
|
||||
$lv_id = $this->input->get('lv_id');
|
||||
$sem_kurzbz = $this->input->get('sem_kurzbz');
|
||||
|
||||
if(!$lehreinheit_id || !$lv_id || !$sem_kurzbz) {
|
||||
$this->terminateWithError($this->p->t('global', 'missingParameters'), 'general');
|
||||
}
|
||||
|
||||
$this->assertLvAccess($lv_id, $sem_kurzbz);
|
||||
|
||||
$lehrende = array();
|
||||
foreach($this->lehrendeDerLehreinheit($lehreinheit_id) as $l) {
|
||||
$lehrende[] = array(
|
||||
'mitarbeiter_uid' => $l->mitarbeiter_uid,
|
||||
'vorname' => $l->vorname,
|
||||
'nachname' => $l->nachname
|
||||
);
|
||||
}
|
||||
|
||||
$this->terminateWithSuccess($lehrende);
|
||||
}
|
||||
|
||||
public function getNoteByPunkte() {
|
||||
$result = $this->getPostJSON();
|
||||
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
<?php
|
||||
/**
|
||||
* Copyright (C) 2026 fhcomplete.org
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
if (! defined('BASEPATH')) exit('No direct script access allowed');
|
||||
|
||||
/**
|
||||
* The exam history of one student in one course: the order of the attempts, the attempt count
|
||||
* and the limits. This class is the only source of the examination rules. The controller and the
|
||||
* client only read the result.
|
||||
*/
|
||||
class PruefungsverlaufLib
|
||||
{
|
||||
/** The first attempt: the original assessment, which is the transcript grade itself. */
|
||||
const ROLLE_ERSTANTRITT = 'erstantritt';
|
||||
|
||||
/** Each attempt after the first one. */
|
||||
const ROLLE_PRUEFUNG = 'pruefung';
|
||||
|
||||
/** The last attempt. The examination rules require a commission for it. */
|
||||
const ROLLE_KOMMISSIONELL = 'kommissionell';
|
||||
|
||||
/** Maps an attempt number to the old exam type. Kept for backward compatibility. */
|
||||
private static $_legacyTypen = [1 => 'Termin1', 2 => 'Termin2', 3 => 'Termin3'];
|
||||
|
||||
private $_ci;
|
||||
private $_noteCache = [];
|
||||
private $_specialNotes = null;
|
||||
private $_typen = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->_ci =& get_instance();
|
||||
$this->_ci->load->model('education/LePruefung_model', 'LePruefungModel');
|
||||
$this->_ci->load->model('education/Note_model', 'NoteModel');
|
||||
$this->_ci->load->model('education/Pruefungstyp_model', 'PruefungstypModel');
|
||||
$this->_ci->load->config('noten');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $lvNote counts as the implicit first attempt while no exam counts
|
||||
* @param mixed $zeugnisNote a credited grade makes all exams impossible
|
||||
* @return stdClass
|
||||
*/
|
||||
public function getVerlauf($student_uid, $lv_id, $sem_kurzbz, $lvNote = null, $zeugnisNote = null)
|
||||
{
|
||||
return $this->buildVerlauf($this->getPruefungen($student_uid, $lv_id, $sem_kurzbz), $lvNote, $zeugnisNote);
|
||||
}
|
||||
|
||||
/** All exams of one student in one course, in chronological order. @return array */
|
||||
public function getPruefungen($student_uid, $lv_id, $sem_kurzbz)
|
||||
{
|
||||
$result = $this->_ci->LePruefungModel->getPruefungenByUidTypLvStudiensemester($student_uid, null, $lv_id, $sem_kurzbz);
|
||||
if (isError($result) || !hasData($result)) return [];
|
||||
|
||||
return $this->sortPruefungen(getData($result));
|
||||
}
|
||||
|
||||
/** Sort keys: datum, then tbl_pruefungstyp.sort (old rows without a date), then pruefung_id. */
|
||||
public function sortPruefungen($pruefungen)
|
||||
{
|
||||
$pruefungen = array_values($pruefungen);
|
||||
$typen = $this->getTypen();
|
||||
|
||||
usort($pruefungen, function ($a, $b) use ($typen) {
|
||||
$da = substr((string) $a->datum, 0, 10);
|
||||
$db = substr((string) $b->datum, 0, 10);
|
||||
|
||||
if ($da !== $db) {
|
||||
if ($da === '') return -1;
|
||||
if ($db === '') return 1;
|
||||
return ($da < $db) ? -1 : 1;
|
||||
}
|
||||
|
||||
$sa = isset($typen[$a->pruefungstyp_kurzbz]) ? (int) $typen[$a->pruefungstyp_kurzbz]->sort : 0;
|
||||
$sb = isset($typen[$b->pruefungstyp_kurzbz]) ? (int) $typen[$b->pruefungstyp_kurzbz]->sort : 0;
|
||||
if ($sa !== $sb) return ($sa < $sb) ? -1 : 1;
|
||||
|
||||
return ((int) $a->pruefung_id < (int) $b->pruefung_id) ? -1 : 1;
|
||||
});
|
||||
|
||||
return $pruefungen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the history from a list of exams. Each exam gets: position, zaehlt, antritt_nr (null
|
||||
* if the exam does not count) and terminal.
|
||||
*
|
||||
* @return stdClass
|
||||
*/
|
||||
public function buildVerlauf($pruefungen, $lvNote = null, $zeugnisNote = null)
|
||||
{
|
||||
$pruefungen = $this->sortPruefungen($pruefungen);
|
||||
|
||||
$antrittCount = 0;
|
||||
$terminal = false;
|
||||
$eintraege = [];
|
||||
|
||||
foreach ($pruefungen as $i => $p) {
|
||||
$zaehlt = $this->zaehltAlsAntritt($p->note, $p->pruefungstyp_kurzbz);
|
||||
if ($zaehlt) $antrittCount++;
|
||||
|
||||
// A kommissionelle exam closes the chain, also if its grade uses no attempt. A student
|
||||
// with a kommissionelle Prüfung gets no further exam from this tool.
|
||||
$kommissionell = $this->istKommissionell($p->pruefungstyp_kurzbz);
|
||||
if ($kommissionell) $terminal = true;
|
||||
|
||||
$eintrag = clone $p;
|
||||
$eintrag->position = $i + 1;
|
||||
$eintrag->zaehlt = $zaehlt;
|
||||
$eintrag->antritt_nr = $zaehlt ? $antrittCount : null;
|
||||
$eintrag->kommissionell = $kommissionell;
|
||||
$eintraege[] = $eintrag;
|
||||
}
|
||||
|
||||
// if no exam counts, the course grade itself is the first attempt
|
||||
$implizit = ($antrittCount === 0 && $lvNote !== null && $this->zaehltAlsAntritt($lvNote));
|
||||
if ($implizit) $antrittCount = 1;
|
||||
|
||||
$maxAntritte = $this->getMaxAntritte();
|
||||
|
||||
// credited: the row stays visible, but you cannot enter anything
|
||||
$angerechnet = $this->istAnrechnungsnote($zeugnisNote);
|
||||
|
||||
$verlauf = new stdClass();
|
||||
$verlauf->pruefungen = $eintraege;
|
||||
$verlauf->antrittCount = $antrittCount;
|
||||
$verlauf->maxAntritte = $maxAntritte;
|
||||
$verlauf->terminal = $terminal;
|
||||
$verlauf->angerechnet = $angerechnet;
|
||||
$verlauf->impliziterErstantritt = $implizit;
|
||||
$verlauf->canAdd = !$terminal && !$angerechnet && $antrittCount < $maxAntritte;
|
||||
|
||||
// after the first exam each new entry is a repeat attempt
|
||||
$verlauf->erstantrittMoeglich = !$angerechnet && (count($eintraege) === 0);
|
||||
|
||||
// The last attempt of the chain is kommissionell (§17 Abs 1). The role therefore follows
|
||||
// from the position, and the user interface offers no type to select.
|
||||
if (count($eintraege) === 0 && !$implizit) {
|
||||
$verlauf->naechsteRolle = self::ROLLE_ERSTANTRITT;
|
||||
} elseif (($antrittCount + 1) >= $maxAntritte) {
|
||||
$verlauf->naechsteRolle = self::ROLLE_KOMMISSIONELL;
|
||||
} else {
|
||||
$verlauf->naechsteRolle = self::ROLLE_PRUEFUNG;
|
||||
}
|
||||
|
||||
return $verlauf;
|
||||
}
|
||||
|
||||
/** 'angerechnet' or 'intern angerechnet'. The TRANSCRIPT grade decides. @return bool */
|
||||
public function istAnrechnungsnote($note)
|
||||
{
|
||||
if ($note === null || $note === '') return false;
|
||||
return in_array($note, $this->getSpecialNotes()['anrechnung']);
|
||||
}
|
||||
|
||||
/**
|
||||
* All attempts that count, the kommissionelle attempt included. The explicit configuration
|
||||
* wins. Without it the number comes from the old flags: the original assessment, one attempt
|
||||
* for each active repeat, and the kommissionelle attempt.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function getMaxAntritte()
|
||||
{
|
||||
$max = $this->_ci->config->item('CIS_GESAMTNOTE_MAX_ANTRITTE');
|
||||
if (is_numeric($max) && (int) $max > 0) return (int) $max;
|
||||
|
||||
$max = 1;
|
||||
if (defined('CIS_GESAMTNOTE_PRUEFUNG_TERMIN2') && CIS_GESAMTNOTE_PRUEFUNG_TERMIN2) $max++;
|
||||
if (defined('CIS_GESAMTNOTE_PRUEFUNG_TERMIN3') && CIS_GESAMTNOTE_PRUEFUNG_TERMIN3) $max++;
|
||||
if (defined('CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF') && CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) $max++;
|
||||
return $max;
|
||||
}
|
||||
|
||||
/**
|
||||
* The NOTE decides, therefore the same type can occur more than one time. The type decides in
|
||||
* one case only: a type in PRUEFUNG_TYPEN_OHNE_ANTRITT never uses an attempt.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function zaehltAlsAntritt($note, $typ = null)
|
||||
{
|
||||
if ($typ !== null && in_array($typ, $this->getTypenOhneAntritt())) return false;
|
||||
if ($note === null || $note === '') return false;
|
||||
if (in_array($note, $this->getSpecialNotes()['ohneAntritt'])) return false;
|
||||
|
||||
// a credited grade is not an assessment, therefore it uses no attempt
|
||||
if ($this->istAnrechnungsnote($note)) return false;
|
||||
|
||||
$noteRow = $this->getNote($note);
|
||||
return $noteRow ? (bool) $noteRow->lehre : false;
|
||||
}
|
||||
|
||||
/** Tells you if an exam takes place before a commission. @return bool */
|
||||
public function istKommissionell($typ)
|
||||
{
|
||||
$typen = $this->_ci->config->item('PRUEFUNG_KOMMISSIONELL_TYPEN');
|
||||
if (!is_array($typen)) $typen = ['kommPruef', 'zusKommPruef'];
|
||||
return in_array($typ, $typen);
|
||||
}
|
||||
|
||||
/** The exam types that never use an attempt. @return array */
|
||||
public function getTypenOhneAntritt()
|
||||
{
|
||||
$typen = $this->_ci->config->item('PRUEFUNG_TYPEN_OHNE_ANTRITT');
|
||||
return is_array($typen) ? $typen : ['zusKommPruef'];
|
||||
}
|
||||
|
||||
/**
|
||||
* The type for an attempt number. The student administration still reads this column, so the
|
||||
* value must be correct. The last attempt gets the kommissionelle type, the attempts before it
|
||||
* get the old Termin types.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function legacyTypFuerAntritt($antrittNr)
|
||||
{
|
||||
$antrittNr = max(1, (int) $antrittNr);
|
||||
$typen = $this->getTypen();
|
||||
|
||||
$maxAntritte = $this->getMaxAntritte();
|
||||
$kommissionell = $this->_ci->config->item('PRUEFUNG_TYP_KOMMISSIONELL');
|
||||
if (!is_string($kommissionell) || $kommissionell === '') $kommissionell = 'kommPruef';
|
||||
|
||||
if ($maxAntritte > 1 && $antrittNr >= $maxAntritte && isset($typen[$kommissionell])) {
|
||||
return $kommissionell;
|
||||
}
|
||||
|
||||
for ($n = $antrittNr; $n >= 1; $n--) {
|
||||
if (isset(self::$_legacyTypen[$n]) && isset($typen[self::$_legacyTypen[$n]])) {
|
||||
return self::$_legacyTypen[$n];
|
||||
}
|
||||
}
|
||||
|
||||
return self::$_legacyTypen[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* The old type for a new repeat attempt. It is never lower than Termin2, also if the first
|
||||
* exam does not use an attempt.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function legacyTypFuerWiederholung($verlauf)
|
||||
{
|
||||
return $this->legacyTypFuerAntritt(max(2, $verlauf->antrittCount + 1));
|
||||
}
|
||||
|
||||
/** @return array{entschuldigt: mixed, ohneAntritt: array, anrechnung: array, limitMap: array} */
|
||||
public function getSpecialNotes()
|
||||
{
|
||||
if ($this->_specialNotes !== null) return $this->_specialNotes;
|
||||
|
||||
$cfgEntschuldigt = $this->_ci->config->item('NOTE_ENTSCHULDIGT');
|
||||
$cfgOhneAntritt = $this->_ci->config->item('NOTEN_OHNE_ANTRITT');
|
||||
$cfgLimitMap = $this->_ci->config->item('NOTEN_OCCURANCE_LIMIT_MAP');
|
||||
$bezeichnungen = $this->_ci->config->item('NOTEN_OHNE_ANTRITT_BEZEICHNUNGEN');
|
||||
if (!is_array($cfgOhneAntritt)) $cfgOhneAntritt = [];
|
||||
if (!is_array($cfgLimitMap)) $cfgLimitMap = [];
|
||||
if (!is_array($bezeichnungen)) $bezeichnungen = [];
|
||||
|
||||
$resEnt = $this->_ci->NoteModel->getEntschuldigtNote();
|
||||
$entschuldigt = (!isError($resEnt) && hasData($resEnt)) ? getData($resEnt)[0]->note : $cfgEntschuldigt;
|
||||
|
||||
// The name wins against the configured key. Do NOT merge the two sets: the same key has a
|
||||
// different meaning in each installation (17 is 'entschuldigt' here, but 'nicht zugelassen'
|
||||
// in the standard data).
|
||||
$ohneAntritt = [];
|
||||
foreach ($bezeichnungen as $bezeichnung) {
|
||||
$note = $this->getNoteByBezeichnung($bezeichnung);
|
||||
if ($note !== null && !in_array($note, $ohneAntritt)) $ohneAntritt[] = $note;
|
||||
}
|
||||
if (count($ohneAntritt) === 0) $ohneAntritt = $cfgOhneAntritt;
|
||||
if ($entschuldigt !== null && !in_array($entschuldigt, $ohneAntritt)) $ohneAntritt[] = $entschuldigt;
|
||||
|
||||
// move the configured limit for 'entschuldigt' to the key that was found
|
||||
$limitMap = [];
|
||||
foreach ($cfgLimitMap as $k => $v) {
|
||||
$limitMap[($k == $cfgEntschuldigt) ? $entschuldigt : $k] = $v;
|
||||
}
|
||||
|
||||
$this->_specialNotes = [
|
||||
'entschuldigt' => $entschuldigt,
|
||||
'ohneAntritt' => array_values($ohneAntritt),
|
||||
'anrechnung' => $this->resolveNoten('NOTEN_ANRECHNUNG_BEZEICHNUNGEN', 'NOTEN_ANRECHNUNG'),
|
||||
'limitMap' => $limitMap
|
||||
];
|
||||
return $this->_specialNotes;
|
||||
}
|
||||
|
||||
/**
|
||||
* The occurrence limit from NOTEN_OCCURANCE_LIMIT_MAP. $excludePruefungId skips the record
|
||||
* that the user edits at this moment.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function ueberschreitetNotenLimit($pruefungen, $note, $excludePruefungId = null)
|
||||
{
|
||||
$limitMap = $this->getSpecialNotes()['limitMap'];
|
||||
|
||||
$limit = null;
|
||||
foreach ($limitMap as $limitNote => $limitVal) {
|
||||
if ($limitNote == $note) { $limit = $limitVal; break; }
|
||||
}
|
||||
if ($limit === null) return false;
|
||||
|
||||
$count = 0;
|
||||
foreach ($pruefungen as $p) {
|
||||
if ($excludePruefungId !== null && $p->pruefung_id == $excludePruefungId) continue;
|
||||
if ($p->note == $note) $count++;
|
||||
}
|
||||
|
||||
return ($count + 1) > $limit;
|
||||
}
|
||||
|
||||
/** An exam with a later date locks the grade. You can still correct the date. @return bool */
|
||||
public function hatSpaeterenTermin($verlauf, $pruefung_id)
|
||||
{
|
||||
$current = null;
|
||||
foreach ($verlauf->pruefungen as $p) {
|
||||
if ($p->pruefung_id == $pruefung_id) { $current = $p; break; }
|
||||
}
|
||||
if ($current === null) return false;
|
||||
|
||||
foreach ($verlauf->pruefungen as $p) {
|
||||
if ($p->pruefung_id == $current->pruefung_id) continue;
|
||||
if ($p->position > $current->position) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* tbl_pruefungstyp as a map. It sorts old rows, and it limits the legacy type to the types
|
||||
* that exist. A type that does not exist breaks the foreign key. Termin3 is absent from the
|
||||
* standard data.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function getTypen()
|
||||
{
|
||||
if ($this->_typen !== null) return $this->_typen;
|
||||
|
||||
$this->_typen = [];
|
||||
$result = $this->_ci->PruefungstypModel->load();
|
||||
if (!isError($result) && hasData($result)) {
|
||||
foreach (getData($result) as $typ) {
|
||||
$this->_typen[$typ->pruefungstyp_kurzbz] = $typ;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->_typen;
|
||||
}
|
||||
|
||||
/** Finds the grades by their name. The configured keys are the fallback only. @return array */
|
||||
private function resolveNoten($bezeichnungKey, $pkKey)
|
||||
{
|
||||
$bezeichnungen = $this->_ci->config->item($bezeichnungKey);
|
||||
if (!is_array($bezeichnungen)) $bezeichnungen = [];
|
||||
|
||||
$noten = [];
|
||||
foreach ($bezeichnungen as $bezeichnung) {
|
||||
$note = $this->getNoteByBezeichnung($bezeichnung);
|
||||
if ($note !== null && !in_array($note, $noten)) $noten[] = $note;
|
||||
}
|
||||
|
||||
if (count($noten) === 0) {
|
||||
$fallback = $this->_ci->config->item($pkKey);
|
||||
if (is_array($fallback)) $noten = $fallback;
|
||||
}
|
||||
|
||||
return array_values($noten);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return stdClass|null
|
||||
*/
|
||||
private function getNote($note)
|
||||
{
|
||||
if (array_key_exists($note, $this->_noteCache)) return $this->_noteCache[$note];
|
||||
|
||||
$result = $this->_ci->NoteModel->load($note);
|
||||
$this->_noteCache[$note] = (!isError($result) && hasData($result)) ? getData($result)[0] : null;
|
||||
return $this->_noteCache[$note];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return mixed|null
|
||||
*/
|
||||
private function getNoteByBezeichnung($bezeichnung)
|
||||
{
|
||||
$result = $this->_ci->NoteModel->loadWhere(['bezeichnung' => $bezeichnung]);
|
||||
return (!isError($result) && hasData($result)) ? getData($result)[0]->note : null;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ class Notenschluesselaufteilung_model extends DB_Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert die Note zu Punkten einer Lehrveranstaltung
|
||||
* Gives the grade for the points of a course.
|
||||
*
|
||||
* @param number $points
|
||||
* @param integer $lehrveranstaltung_id
|
||||
@@ -23,6 +23,12 @@ class Notenschluesselaufteilung_model extends DB_Model
|
||||
*/
|
||||
public function getNote($points, $lehrveranstaltung_id, $studiensemester_kurzbz)
|
||||
{
|
||||
// Without a valid points value you cannot derive a grade. Without this guard the query
|
||||
// builder makes a bad statement from "punkte <=" => null ('punkte' < 'IS' 'NULL'). The
|
||||
// request then fails with HTTP 500 instead of an answer that says "no grade".
|
||||
if (!is_numeric($points))
|
||||
return success(null);
|
||||
|
||||
$this->load->model('education/Notenschluesselzuordnung_model', 'NotenschluesselzuordnungModel');
|
||||
$notenschluessel_kurzbz = $this->NotenschluesselzuordnungModel->getKurzbzForLv($lehrveranstaltung_id, $studiensemester_kurzbz);
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -51,19 +51,28 @@ export default {
|
||||
},
|
||||
// kein Termintyp mehr: welchen Antritt die Prüfung darstellt, leitet der Server aus dem
|
||||
// bestehenden Prüfungsverlauf des Studenten in dieser LV und diesem Semester ab
|
||||
saveStudentPruefung(student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, pruefung_id = null){
|
||||
// mitarbeiter_uid: der benotende Lektor. Nur nötig, wenn die Lehreinheit mehrere hat - sonst
|
||||
// löst der Server ihn selbst auf.
|
||||
saveStudentPruefung(student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, pruefung_id = null, mitarbeiter_uid = null){
|
||||
return {
|
||||
method: 'post',
|
||||
url: '/api/frontend/v1/Noten/saveStudentPruefung',
|
||||
params: { student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, pruefung_id }
|
||||
params: { student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, pruefung_id, mitarbeiter_uid }
|
||||
};
|
||||
},
|
||||
// note/punkte optional: ohne Auswahl wird der Termin als "noch nicht eingetragen" angelegt
|
||||
createPruefungen(uids, datum, lva_id, sem_kurzbz, note = null, punkte = null){
|
||||
createPruefungen(uids, datum, lva_id, sem_kurzbz, note = null, punkte = null, mitarbeiter_uid = null){
|
||||
return {
|
||||
method: 'post',
|
||||
url: '/api/frontend/v1/Noten/createPruefungen',
|
||||
params: { uids, datum, lva_id, sem_kurzbz, note, punkte }
|
||||
params: { uids, datum, lva_id, sem_kurzbz, note, punkte, mitarbeiter_uid }
|
||||
};
|
||||
},
|
||||
getLehrendeFuerLehreinheit(lehreinheit_id, lv_id, sem_kurzbz) {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/Noten/getLehrendeFuerLehreinheit',
|
||||
params: { lehreinheit_id, lv_id, sem_kurzbz }
|
||||
};
|
||||
},
|
||||
saveNotenvorschlagBulk(lv_id, sem_kurzbz, noten) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,9 +13,9 @@ export const NotenlisteLinks = {
|
||||
return LehreinheitenModule;
|
||||
},
|
||||
lehreinheiten() {
|
||||
// reuse the already-loaded LE options from the shared module
|
||||
// use the teaching unit options that the shared module loaded before
|
||||
const all = LehreinheitenModule.options ?? [];
|
||||
// when a single Lehreinheit is selected, restrict the list to just that one
|
||||
// if the user selected one teaching unit, show only that one
|
||||
if (this.selectedLehreinheit?.lehreinheit_id != null) {
|
||||
return all.filter(le => le.lehreinheit_id === this.selectedLehreinheit.lehreinheit_id);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
/**
|
||||
* Prüfungsordnung §1 rules as the client applies them.
|
||||
* The examination rules as the client applies them.
|
||||
*
|
||||
* Die Regeln selbst liegen im Server (PruefungsverlaufLib): er liefert je Student einen `verlauf`
|
||||
* mit Antrittszahl, Grenze und der nächsten möglichen Rolle, und je Termin dessen Position sowie
|
||||
* ob er einen Antritt verbraucht. Hier wird das nur noch ausgewertet - damit gibt es keine zweite
|
||||
* Implementierung der Regeln mehr, die auseinanderlaufen kann.
|
||||
* PruefungsverlaufLib sends one `verlauf` for each student. It contains the attempt count, the
|
||||
* limit and the next possible role. For each exam it contains the position and a flag that tells
|
||||
* you if the exam uses an attempt.
|
||||
*
|
||||
* Der lokale Fallback greift nur, solange der Verlauf für eine Zeile noch nicht geladen ist.
|
||||
* The local fallback applies only while the client has no `verlauf` for a row.
|
||||
*/
|
||||
|
||||
/** Maximale Anzahl zählender Antritte in diesem Tool (serverseitig abgeleitet). */
|
||||
/** The maximum number of attempts that count in this tool. The server derives this value. */
|
||||
export const maxAntrittCount = (config) => config?.CIS_GESAMTNOTE_MAX_ANTRITTE ?? 1;
|
||||
|
||||
/**
|
||||
* Zählende Prüfungsantritte. Gezählt wird über die NOTE, nicht über den Termintyp: Noten in
|
||||
* NOTEN_OHNE_ANTRITT (entschuldigt, nicht beurteilt, noch nicht eingetragen) verbrauchen keinen
|
||||
* Antritt. Ohne zählenden Termin gilt die LV-Note selbst als erster Antritt, sofern sie zählt.
|
||||
* The attempts that count. The NOTE decides, not the exam type: the grades in NOTEN_OHNE_ANTRITT
|
||||
* (excused, not assessed, not entered yet) use no attempt. If no exam counts, the course grade
|
||||
* itself is the first attempt, but only if that grade counts.
|
||||
*/
|
||||
export const antrittCountStudent = (student, config, notenOptions) => {
|
||||
if (student.verlauf) return student.verlauf.antrittCount;
|
||||
@@ -34,21 +33,25 @@ export const antrittCountStudent = (student, config, notenOptions) => {
|
||||
return count;
|
||||
};
|
||||
|
||||
/** Ob für den Studenten überhaupt noch ein Antritt angelegt werden darf. */
|
||||
/** Tells you if the student can get one more attempt. */
|
||||
export const canAddPruefung = (student, config) => {
|
||||
if (student.verlauf) return student.verlauf.canAdd;
|
||||
return !student.kommPruef && antrittCountStudent(student, config) < maxAntrittCount(config);
|
||||
};
|
||||
|
||||
/**
|
||||
* Ob mit der neuen Prüfung auch erst die LV-Note entsteht. Kommt aus dem Verlauf und nicht aus
|
||||
* `lv_note`: letzteres kennt nur FREIGEGEBENE Noten, eine erfasste aber nicht freigegebene LV-Note
|
||||
* existiert trotzdem. Nur für den Hinweis in der Oberfläche.
|
||||
* Tells you if the new exam also creates the course grade. The value comes from the `verlauf` and
|
||||
* not from `lv_note`, because `lv_note` contains RELEASED grades only. An entered grade that is
|
||||
* not released still exists. The user interface uses this for a hint only.
|
||||
*/
|
||||
export const brauchtNeueLvNote = (student) => student.verlauf ? !student.verlauf.hatLvNote : !student.lv_note;
|
||||
|
||||
/** Grade state from the two timestamps. */
|
||||
/**
|
||||
* The grade state from the two timestamps: offen = no grade entered (an empty row), changed = a
|
||||
* grade is entered but not released, ok = released and not changed since the release.
|
||||
*/
|
||||
export const checkFreigabe = (freigabedatum, benotungsdatum) => {
|
||||
if (!freigabedatum) return "offen";
|
||||
if (!benotungsdatum) return "offen";
|
||||
if (!freigabedatum) return "changed";
|
||||
return benotungsdatum > freigabedatum ? "changed" : "ok";
|
||||
};
|
||||
|
||||
@@ -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 ------------------------------------------------------------------------------------
|
||||
);
|
||||
|
||||
|
||||
@@ -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 $$;
|
||||
@@ -0,0 +1,40 @@
|
||||
-- Vollzugriff auf ALLE Anwendungsschemata für alle DB-Rollen.
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_grantee text;
|
||||
v_target text;
|
||||
v_schema text;
|
||||
v_grantees text[] := ARRAY['web', 'vilesci', 'wawi', 'admin', 'PUBLIC'];
|
||||
v_done integer := 0;
|
||||
BEGIN
|
||||
FOREACH v_grantee IN ARRAY v_grantees
|
||||
LOOP
|
||||
IF v_grantee = 'PUBLIC' THEN
|
||||
v_target := 'PUBLIC';
|
||||
ELSE
|
||||
CONTINUE WHEN NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = v_grantee);
|
||||
v_target := quote_ident(v_grantee);
|
||||
END IF;
|
||||
|
||||
FOR v_schema IN
|
||||
SELECT nspname
|
||||
FROM pg_namespace
|
||||
WHERE nspname NOT LIKE 'pg\_%' AND nspname <> 'information_schema'
|
||||
ORDER BY nspname
|
||||
LOOP
|
||||
EXECUTE format('GRANT ALL ON SCHEMA %I TO %s', v_schema, v_target);
|
||||
EXECUTE format('GRANT ALL ON ALL TABLES IN SCHEMA %I TO %s', v_schema, v_target);
|
||||
EXECUTE format('GRANT ALL ON ALL SEQUENCES IN SCHEMA %I TO %s', v_schema, v_target);
|
||||
EXECUTE format('GRANT ALL ON ALL FUNCTIONS IN SCHEMA %I TO %s', v_schema, v_target);
|
||||
|
||||
EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT ALL ON TABLES TO %s', v_schema, v_target);
|
||||
EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT ALL ON SEQUENCES TO %s', v_schema, v_target);
|
||||
EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT ALL ON FUNCTIONS TO %s', v_schema, v_target);
|
||||
|
||||
v_done := v_done + 1;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE 'Grants auf % Schema/Rollen-Kombinationen vergeben.', v_done;
|
||||
END $$;
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Access control (P2) - assertLvAccess scoping (Noten.php:243).
|
||||
* Access control (P2) - das Scoping von assertLvAccess.
|
||||
*
|
||||
* There is no impersonation in this API, so testing teacher scoping needs that teacher's real
|
||||
* credentials: NOTEN_TEACHER_USER / NOTEN_TEACHER_PASSWORD / NOTEN_FOREIGN_LV_ID.
|
||||
* Diese API kennt keine Impersonation, der Lektoren-Scope braucht daher echte Zugangsdaten:
|
||||
* NOTEN_TEACHER_USER / NOTEN_TEACHER_PASSWORD / NOTEN_FOREIGN_LV_ID.
|
||||
*/
|
||||
|
||||
import { notenApi } from "../../../../support/api/notenApi";
|
||||
@@ -81,10 +81,7 @@ describe("Noten API - access control", () => {
|
||||
password: Cypress.env("NOTEN_TEACHER_PASSWORD"),
|
||||
});
|
||||
|
||||
/**
|
||||
* Without this the denials below pass on a 401 from the LOGIN, never reaching assertLvAccess -
|
||||
* a scoping suite that is green because the second account cannot log in at all.
|
||||
*/
|
||||
/** Ohne das wären die Ablehnungen unten grün, weil der zweite Account gar nicht erst reinkommt. */
|
||||
it("authenticates as the configured teacher", () => {
|
||||
cy.request({
|
||||
method: "GET",
|
||||
@@ -158,9 +155,8 @@ describe("Noten API - access control", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// getLvForStudiengang's refusal is NOT covered: the configured user is an admin, so
|
||||
// isBerechtigt('admin') short-circuits the entitlement check and every call succeeds. It needs
|
||||
// the same non-admin login as the teacher tests above.
|
||||
// getLvForStudiengang ist NICHT abgedeckt: als Admin greift isBerechtigt('admin') und jeder
|
||||
// Aufruf gelingt. Braucht denselben Nicht-Admin-Login wie die Lektorentests oben.
|
||||
|
||||
describe("getBenotungstoolContext shape", () => {
|
||||
it("returns the role-determining payload", () => {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
/**
|
||||
* Bulk / import paths (P2).
|
||||
*
|
||||
* Same validatePruefungAdd guards as the single save, but reported differently: HTTP 200 with the
|
||||
* error string in data[uid] per row (Noten.php:1600/:1656). createPruefungen is the main vehicle
|
||||
* because it always writes "Noch nicht eingetragen" and never touches punkte.
|
||||
* Bulk- und Importpfade (P2). Dieselben Guards wie beim einzelnen Speichern, aber je Zeile
|
||||
* gemeldet: HTTP 200 mit der Fehlermeldung in data[uid].
|
||||
*/
|
||||
|
||||
import { notenApi } from "../../../../support/api/notenApi";
|
||||
@@ -114,21 +111,18 @@ describe("Noten API - bulk paths", () => {
|
||||
});
|
||||
|
||||
describe("savePruefungenBulk", () => {
|
||||
beforeEach(function () {
|
||||
if (ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) {
|
||||
// With punkte mode on, this endpoint unconditionally re-derives every row's note from
|
||||
// the Notenschlüssel (Noten.php:1638-1643), so a row would need valid punkte rather than
|
||||
// a grade. Covering that needs a points-based fixture; skip rather than assert nonsense.
|
||||
cy.log(
|
||||
"Skipped: CIS_GESAMTNOTE_PUNKTE is enabled, so savePruefungenBulk grades by points " +
|
||||
"and this grade-based scenario does not apply.",
|
||||
);
|
||||
this.skip();
|
||||
}
|
||||
beforeEach(() => {
|
||||
requireDbReset();
|
||||
});
|
||||
|
||||
it("applies the §1 rules per row", () => {
|
||||
it("applies the §1 rules per row", function () {
|
||||
if (ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) {
|
||||
// im Punktemodus leitet der Endpunkt die Note aus den Punkten ab; die Regelprüfung
|
||||
// deckt der punktebasierte Test unten ab
|
||||
cy.log("Skipped: CIS_GESAMTNOTE_PUNKTE ist aktiv, siehe den punktebasierten Test.");
|
||||
this.skip();
|
||||
}
|
||||
|
||||
const atCap = ctx.students[0];
|
||||
const fresh = ctx.students[1];
|
||||
|
||||
@@ -165,10 +159,88 @@ describe("Noten API - bulk paths", () => {
|
||||
expectBulkRowAccepted(data, fresh.uid);
|
||||
});
|
||||
});
|
||||
|
||||
// Punktemodus: die Note kommt aus dem Notenschlüssel, die Zeile liefert nur Punkte.
|
||||
describe("Punktemodus", () => {
|
||||
beforeEach(function () {
|
||||
if (!ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) {
|
||||
cy.log("Skipped: CIS_GESAMTNOTE_PUNKTE ist aus.");
|
||||
this.skip();
|
||||
}
|
||||
});
|
||||
|
||||
it("leitet die Note aus den Punkten ab", () => {
|
||||
const student = ctx.students[1];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student.uid, { freigegeben: true });
|
||||
|
||||
// erwartete Note zur Laufzeit erfragen, damit der Notenschlüssel nicht hardcodiert ist
|
||||
notenApi.getNoteByPunkte(100, ctx.lvId, ctx.semKurzbz).then((response) => {
|
||||
const erwartet = expectNotenSuccess(response, "getNoteByPunkte(100)");
|
||||
expect(erwartet, "100 Punkte müssen eine Note ergeben").to.not.be.null;
|
||||
|
||||
notenApi
|
||||
.savePruefungenBulk(ctx.lvId, ctx.semKurzbz, [
|
||||
{
|
||||
uid: student.uid,
|
||||
note: null,
|
||||
punkte: 100,
|
||||
datum: attemptDate(ctx, 1),
|
||||
lehreinheit_id: student.lehreinheit_id,
|
||||
},
|
||||
])
|
||||
.then((bulkResponse) => {
|
||||
const data = expectNotenSuccess(bulkResponse, "savePruefungenBulk mit Punkten");
|
||||
expectBulkRowAccepted(data, student.uid);
|
||||
expect(String(data[student.uid].savedPruefung[0].note)).to.eq(String(erwartet));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("überspringt eine Zeile ohne Punkte und schreibt die übrigen", () => {
|
||||
const ohnePunkte = ctx.students[0];
|
||||
const mitPunkten = ctx.students[1];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, ohnePunkte.uid, { freigegeben: true });
|
||||
seedBaseline(ctx, mitPunkten.uid, { freigegeben: true });
|
||||
|
||||
notenApi
|
||||
.savePruefungenBulk(ctx.lvId, ctx.semKurzbz, [
|
||||
{
|
||||
uid: ohnePunkte.uid,
|
||||
note: null,
|
||||
punkte: null,
|
||||
datum: attemptDate(ctx, 1),
|
||||
lehreinheit_id: ohnePunkte.lehreinheit_id,
|
||||
},
|
||||
{
|
||||
uid: mitPunkten.uid,
|
||||
note: null,
|
||||
punkte: 100,
|
||||
datum: attemptDate(ctx, 1),
|
||||
lehreinheit_id: mitPunkten.lehreinheit_id,
|
||||
},
|
||||
])
|
||||
.then((response) => {
|
||||
// eine kaputte Zeile darf den ganzen Import nicht abbrechen
|
||||
const data = expectNotenSuccess(response, "savePruefungenBulk mit Luecke");
|
||||
expectBulkRowError(data, ohnePunkte.uid, "c4punkteKeineNoteErmittelt");
|
||||
expectBulkRowAccepted(data, mitPunkten.uid);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveNotenvorschlagBulk", () => {
|
||||
it("writes an LV note for every row", () => {
|
||||
it("writes an LV note for every row", function () {
|
||||
if (ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) {
|
||||
// im Punktemodus kommt die Note aus dem Notenschlüssel, siehe den Punktemodus-Block
|
||||
cy.log("Skipped: CIS_GESAMTNOTE_PUNKTE ist aktiv.");
|
||||
this.skip();
|
||||
}
|
||||
|
||||
const [a, b] = ctx.students;
|
||||
|
||||
resetNotenState(ctx);
|
||||
@@ -179,8 +251,10 @@ describe("Noten API - bulk paths", () => {
|
||||
{ uid: b.uid, note: ctx.gradeNotes[1], punkte: null },
|
||||
])
|
||||
.then((response) => {
|
||||
// nach uid geschlüsselt: je Zeile die LV-Note oder eine Fehlermeldung
|
||||
const data = expectNotenSuccess(response, "saveNotenvorschlagBulk");
|
||||
expect(data, "one lvgesamtnote row per input row").to.be.an("array").and.have.length(2);
|
||||
expectBulkRowAccepted(data, a.uid);
|
||||
expectBulkRowAccepted(data, b.uid);
|
||||
});
|
||||
|
||||
readLvGesamtnote(ctx, a.uid).then((row) => {
|
||||
@@ -193,5 +267,59 @@ describe("Noten API - bulk paths", () => {
|
||||
expect(String(row.note)).to.eq(String(ctx.gradeNotes[1]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Punktemodus", () => {
|
||||
beforeEach(function () {
|
||||
if (!ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) {
|
||||
cy.log("Skipped: CIS_GESAMTNOTE_PUNKTE ist aus.");
|
||||
this.skip();
|
||||
}
|
||||
});
|
||||
|
||||
it("schreibt die aus den Punkten abgeleitete Note", () => {
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
|
||||
notenApi.getNoteByPunkte(100, ctx.lvId, ctx.semKurzbz).then((response) => {
|
||||
const erwartet = expectNotenSuccess(response, "getNoteByPunkte(100)");
|
||||
expect(erwartet, "100 Punkte müssen eine Note ergeben").to.not.be.null;
|
||||
|
||||
notenApi
|
||||
.saveNotenvorschlagBulk(ctx.lvId, ctx.semKurzbz, [
|
||||
{ uid: student.uid, note: null, punkte: 100 },
|
||||
])
|
||||
.then((bulk) => expectBulkRowAccepted(expectNotenSuccess(bulk, "Notenimport"), student.uid))
|
||||
.then(() => readLvGesamtnote(ctx, student.uid))
|
||||
.then((row) => {
|
||||
expect(String(row.note), "Note kommt aus dem Notenschlüssel").to.eq(String(erwartet));
|
||||
expect(Number(row.punkte), "die Punkte werden mitgeschrieben").to.eq(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("überspringt eine Zeile ohne Punkte und schreibt die übrigen", () => {
|
||||
const ohnePunkte = ctx.students[0];
|
||||
const mitPunkten = ctx.students[1];
|
||||
|
||||
resetNotenState(ctx);
|
||||
|
||||
notenApi
|
||||
.saveNotenvorschlagBulk(ctx.lvId, ctx.semKurzbz, [
|
||||
{ uid: ohnePunkte.uid, note: null, punkte: null },
|
||||
{ uid: mitPunkten.uid, note: null, punkte: 100 },
|
||||
])
|
||||
.then((response) => {
|
||||
// eine kaputte Zeile darf den ganzen Import nicht abbrechen
|
||||
const data = expectNotenSuccess(response, "Notenimport mit Luecke");
|
||||
expectBulkRowError(data, ohnePunkte.uid, "c4punkteKeineNoteErmittelt");
|
||||
expectBulkRowAccepted(data, mitPunkten.uid);
|
||||
});
|
||||
|
||||
readLvGesamtnote(ctx, ohnePunkte.uid).then((row) => {
|
||||
expect(row, "die übersprungene Zeile bleibt ungeschrieben").to.be.null;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Notenfreigabe (P1, cases 12-14).
|
||||
*
|
||||
* A successful saveStudentenNoten ALWAYS sends the Notenfreigabe mail (Noten.php:585, not behind a
|
||||
* config flag), so the happy path is opt-in via NOTEN_FREIGABE_ENABLED. The wrong-password test
|
||||
* needs no gate: the check runs before any write and before the mail.
|
||||
* Ein erfolgreiches saveStudentenNoten verschickt IMMER die Freigabemail (kein Config-Flag),
|
||||
* deshalb ist der Happy Path opt-in über NOTEN_FREIGABE_ENABLED. Der Passworttest braucht das
|
||||
* nicht: die Prüfung läuft vor jedem Schreibzugriff.
|
||||
*/
|
||||
|
||||
import { notenApi } from "../../../../support/api/notenApi";
|
||||
@@ -109,6 +109,15 @@ describe("Noten API - Notenfreigabe", () => {
|
||||
const entry = data.find((r) => r.uid === student.uid);
|
||||
expect(entry, `${student.uid} must be reported as freigegeben`).to.exist;
|
||||
expect(entry.freigabedatum, "freigabedatum is stamped").to.exist;
|
||||
|
||||
// Die Freigabe legt den ersten Antritt an. Ohne den Verlauf in der Antwort
|
||||
// zeigt die Tabelle den neuen Termin erst nach einem Reload.
|
||||
expect(entry.verlauf, "der Verlauf kommt mit der Antwort zurück").to.exist;
|
||||
expect(entry.verlauf.pruefungen, "inklusive der Termine").to.be.an("array");
|
||||
expect(
|
||||
entry.verlauf.pruefungen.length,
|
||||
"der erste Antritt ist dabei",
|
||||
).to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
readState(ctx).then((data) => {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
/**
|
||||
* Prüfungsordnung §1 - Noteneintragungsfrist (P0, cases 8-9).
|
||||
*
|
||||
* enforceNoteneintragungsfrist runs after assertLvAccess but before any write, so a request naming
|
||||
* a past-deadline semester is rejected without touching a row. The message carries the derived
|
||||
* deadline, which doubles as a check of computeNoteneintragungsfrist
|
||||
* (SS -> 15.11.yyyy, WS -> 15.05.yyyy+1).
|
||||
* Die Prüfung läuft vor jedem Schreibzugriff, ein abgelaufenes Semester wird also abgelehnt ohne
|
||||
* eine Zeile anzufassen. Die Fehlermeldung nennt die Frist und prüft damit zugleich deren
|
||||
* Ableitung (SS -> 15.11.yyyy, WS -> 15.05.yyyy+1).
|
||||
*/
|
||||
|
||||
import { notenApi } from "../../../../support/api/notenApi";
|
||||
@@ -34,6 +33,42 @@ describe("Noten API - Noteneintragungsfrist (Prüfungsordnung §1)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// §7 und §11: nicht nur der Zeitpunkt der Eingabe zählt, sondern auch das Prüfungsdatum selbst.
|
||||
// Die kommissionelle Prüfung muss bis zur Frist STATTFINDEN, sonst verliert der Student ein
|
||||
// Semester. Das ist eine andere Frage als "darf jetzt noch eingetragen werden".
|
||||
describe("Prüfungsdatum nach der Frist", () => {
|
||||
beforeEach(function () {
|
||||
if (!enforced) {
|
||||
cy.log("Übersprungen: CIS_GESAMTNOTE_NOTENEINTRAGUNGSFRIST ist aus.");
|
||||
this.skip();
|
||||
}
|
||||
requireDbReset();
|
||||
});
|
||||
|
||||
it("lehnt einen Termin ab, der nach der Frist liegt", () => {
|
||||
const student = ctx.students[0];
|
||||
const jahr = Number(ctx.semKurzbz.slice(2, 6));
|
||||
// die Frist des laufenden Semesters liegt in der Zukunft, ein Datum dahinter ist ungültig
|
||||
const nachDerFrist = `${jahr + 1}-12-31`;
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: nachDerFrist }).then((response) => {
|
||||
expectNotenError(response, "pruefungsdatumNachFrist");
|
||||
});
|
||||
});
|
||||
|
||||
it("nimmt einen Termin vor der Frist an", () => {
|
||||
const student = ctx.students[1];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 1) }).then(
|
||||
(response) => expectNotenSuccess(response, "Termin innerhalb der Frist"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deadline has passed", () => {
|
||||
// A rejected request writes nothing, so these need no DB reset.
|
||||
[
|
||||
@@ -93,7 +128,7 @@ describe("Noten API - Noteneintragungsfrist (Prüfungsordnung §1)", () => {
|
||||
|
||||
it("accepts grade entry in a semester whose deadline has not passed", function () {
|
||||
if (enforced && fristHasPassed(ctx.semKurzbz)) {
|
||||
// writing is impossible here; the whole mutating suite would be blocked too
|
||||
// hier ist kein Schreiben möglich, damit wäre die gesamte mutierende Suite blockiert
|
||||
throw new Error(
|
||||
`Noteneintragungsfrist enforcement is ON and the deadline for the test semester ` +
|
||||
`${ctx.semKurzbz} (${expectedFristString(ctx.semKurzbz)}) has already passed, so no ` +
|
||||
@@ -114,7 +149,6 @@ describe("Noten API - Noteneintragungsfrist (Prüfungsordnung §1)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The SS/WS derivation is asserted against the SERVER in the two "deadline has passed" tests,
|
||||
// which compare the deadline it names in the error message. A local check of expectedFristString()
|
||||
// against constants only tested the helper, so it was removed.
|
||||
// Die SS/WS-Ableitung wird gegen den SERVER geprüft (die Frist in der Fehlermeldung oben) -
|
||||
// ein lokaler Vergleich von expectedFristString() gegen Konstanten testet nur den Helfer.
|
||||
});
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/**
|
||||
* Notenschlüssel - points to grade (P0, case 10). Read-only.
|
||||
* Notenschlüssel - Punkte zu Note (P0, case 10). Read-only.
|
||||
*
|
||||
* getNote() picks the aufteilung row with the highest punkte <= input, and returns null when the LV
|
||||
* has no Notenschlüssel. Rather than re-implementing that lookup, this sweeps the range and asserts
|
||||
* the properties a correct scale must have: total, monotonic, exact at each boundary.
|
||||
* Statt die Zuordnung nachzubauen, wird der Bereich abgetastet und auf die Eigenschaften einer
|
||||
* korrekten Skala geprüft: lückenlos, monoton, exakt an jeder Grenze.
|
||||
*/
|
||||
|
||||
import { notenApi } from "../../../../support/api/notenApi";
|
||||
@@ -47,8 +46,8 @@ describe("Noten API - Notenschlüssel (getNoteByPunkte)", () => {
|
||||
`for every value in 0..${MAX_PUNKTE}. Pin NOTEN_LV_ID to an LV that has one.`,
|
||||
).to.be.greaterThan(0);
|
||||
|
||||
// (4) below the lowest threshold the model answers null, and that region must be a
|
||||
// contiguous prefix - a null appearing *after* a graded value would mean a gap in the scale
|
||||
// unter der niedrigsten Schwelle antwortet das Modell null; das muss ein zusammenhängender
|
||||
// Anfang sein, ein null NACH einer Note wäre eine Lücke in der Skala
|
||||
const firstGradedIndex = observed.findIndex((e) => e.note !== null && e.note !== undefined);
|
||||
observed.slice(firstGradedIndex).forEach((entry) => {
|
||||
expect(
|
||||
@@ -57,7 +56,7 @@ describe("Noten API - Notenschlüssel (getNoteByPunkte)", () => {
|
||||
).to.not.be.oneOf([null, undefined]);
|
||||
});
|
||||
|
||||
// (2) monotonic: grade PKs run 1 (best) .. 5 (worst), so the number must never increase
|
||||
// monoton: die PKs laufen 1 (beste) .. 5, die Zahl darf also nie steigen
|
||||
for (let i = 1; i < graded.length; i += 1) {
|
||||
expect(
|
||||
Number(graded[i].note),
|
||||
@@ -65,7 +64,7 @@ describe("Noten API - Notenschlüssel (getNoteByPunkte)", () => {
|
||||
).to.be.at.most(Number(graded[i - 1].note));
|
||||
}
|
||||
|
||||
// (3) exact boundaries: report each step so a shifted threshold is visible in the output
|
||||
// jede Stufe einzeln melden, damit eine verschobene Schwelle in der Ausgabe sichtbar wird
|
||||
const boundaries = [];
|
||||
for (let i = 1; i < graded.length; i += 1) {
|
||||
if (Number(graded[i].note) !== Number(graded[i - 1].note)) {
|
||||
@@ -109,8 +108,7 @@ describe("Noten API - Notenschlüssel (getNoteByPunkte)", () => {
|
||||
});
|
||||
|
||||
it("reports no grade for an LV without a Notenschlüssel", () => {
|
||||
// A non-existent LV id can have no Notenschlüssel assigned, so the model's success(null)
|
||||
// contract is observable without depending on a particular fixture.
|
||||
// eine nicht existierende LV hat garantiert keinen Schlüssel - fixturefrei prüfbar
|
||||
notenApi.getNoteByPunkte(50, 0, ctx.semKurzbz).then((response) => {
|
||||
const note = expectNotenSuccess(response, "getNoteByPunkte for an LV without a schluessel");
|
||||
expect(note, "no Notenschlüssel -> no grade").to.be.oneOf([null, undefined]);
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
/**
|
||||
* Notenvorschlag - LV note write and resulting state (P1, case 11).
|
||||
* Notenvorschlag - LV-Note schreiben und der resultierende Status (P1, case 11).
|
||||
*
|
||||
* State comes from two timestamps (Benotungstool.js::checkFreigabe): no freigabedatum -> offen,
|
||||
* benotungsdatum > freigabedatum -> changed, else freigegeben. The "changed" case is produced by
|
||||
* seeding an already-freigegebene baseline, which keeps this spec free of the LDAP password check
|
||||
* and the Freigabe mail.
|
||||
*
|
||||
* Caveat: getLvGesamtNoten() (behind getStudentenNoten) filters `freigabedatum < NOW()`, and
|
||||
* NULL < NOW() is NULL - so offene notes are dropped, while saveNotenvorschlag writes through the
|
||||
* unfiltered getLvGesamtNoteVorschlag(). The tests below separate write from read for that reason.
|
||||
* Status aus zwei Zeitstempeln (notenRules.js::checkFreigabe). Der "changed nach Freigabe"-Fall
|
||||
* wird über eine bereits freigegebene Baseline erzeugt, damit dieser Spec ohne LDAP-Passwort und
|
||||
* ohne Freigabemail auskommt.
|
||||
*/
|
||||
|
||||
import { notenApi } from "../../../../support/api/notenApi";
|
||||
@@ -59,7 +54,7 @@ describe("Noten API - Notenvorschlag", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("persists the row with freigabedatum NULL (state: offen)", () => {
|
||||
it("persists the row with freigabedatum NULL (not freigegeben)", () => {
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
@@ -73,8 +68,6 @@ describe("Noten API - Notenvorschlag", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The unfiltered getter behind getNotenvorschlagStudent sees the same row fine - which is the
|
||||
// getter finding 1 says getStudentenNoten should be using.
|
||||
it("reports the offen note through getNotenvorschlagStudent", () => {
|
||||
const student = ctx.students[0];
|
||||
|
||||
@@ -88,8 +81,8 @@ describe("Noten API - Notenvorschlag", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// EXPECTED TO FAIL while getLvGesamtNoten filters `freigabedatum < NOW()`: the row exists
|
||||
// (test above proves it) but the read model drops it until the note is freigegeben.
|
||||
// getStudentenNoten liest seit dem Wechsel auf den ungefilterten Getter auch noch nicht
|
||||
// freigegebene Noten - sonst sind LV-Note und Freigabestatus nach einem Reload leer.
|
||||
it("reports the offen note back through getStudentenNoten", () => {
|
||||
const student = ctx.students[0];
|
||||
|
||||
@@ -109,6 +102,9 @@ describe("Noten API - Notenvorschlag", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// `erstantritt: false` ist die Altdatenform: freigegebene LV-Note ohne Prüfungszeile. Nur dort
|
||||
// ist ein direktes Umbenoten erlaubt - existiert eine Prüfung, lehnt der Server ab
|
||||
// (validateNotenvorschlag, siehe noten.ueberschreiben).
|
||||
describe("re-grading an already freigegebene note", () => {
|
||||
it("moves the state from freigegeben to changed", () => {
|
||||
const student = ctx.students[1];
|
||||
@@ -117,6 +113,7 @@ describe("Noten API - Notenvorschlag", () => {
|
||||
seedBaseline(ctx, student.uid, {
|
||||
note: ctx.gradeNotes[0],
|
||||
freigegeben: true,
|
||||
erstantritt: false,
|
||||
benotungsdatum: baselineBenotungsdatum(ctx),
|
||||
});
|
||||
|
||||
@@ -154,7 +151,11 @@ describe("Noten API - Notenvorschlag", () => {
|
||||
const student = ctx.students[1];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: true });
|
||||
seedBaseline(ctx, student.uid, {
|
||||
note: ctx.gradeNotes[0],
|
||||
freigegeben: true,
|
||||
erstantritt: false,
|
||||
});
|
||||
|
||||
notenApi.saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.gradeNotes[1]);
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
/**
|
||||
* Prüfungsordnung §1 - Prüfungsantritte.
|
||||
*
|
||||
* Rules fire in order A (max Antritte) -> B (chronology) -> C (occurrence limit), so each test
|
||||
* builds a state where exactly one can fire; otherwise an earlier rule masks the one under test.
|
||||
* Die Regeln greifen in der Reihenfolge A (Maximum) -> B (Chronologie) -> C (Vorkommenslimit).
|
||||
* Jeder Test baut einen Zustand, in dem genau eine davon feuern kann, sonst maskiert die frühere.
|
||||
*
|
||||
* Bookkeeping since the Prüfungsverlauf refactor: one add writes one row. Antritt 1 is a real
|
||||
* Prüfung (created by the Freigabe, seeded by givenBaseline). Attempts are counted by NOTE, not by
|
||||
* type: "entschuldigt", "Noch nicht eingetragen" and "Nicht beurteilt" never count towards the cap.
|
||||
* Gezählt wird über die NOTE: entschuldigt, "Noch nicht eingetragen" und "Nicht beurteilt" zählen
|
||||
* nie mit. Antritt 1 ist eine echte Prüfung (von givenBaseline geseedet).
|
||||
*/
|
||||
|
||||
import { notenApi } from "../../../../support/api/notenApi";
|
||||
@@ -65,9 +64,8 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
|
||||
return attemptDate(ctx, firstIndex + ctx.maxAntritte - 1);
|
||||
};
|
||||
|
||||
// Every rule below is keyed on these PKs. getSpecialNotes() resolves them from tbl_note by
|
||||
// Bezeichnung and only falls back to the config values - on demo data entschuldigt is 14, not the
|
||||
// 17 in application/config/noten.php. If the fallback ever wins, the rules silently stop matching.
|
||||
// Alle Regeln hängen an diesen PKs. Auf Demodaten ist entschuldigt 14, nicht die 17 aus
|
||||
// application/config/noten.php - gewinnt der Fallback, greifen die Regeln stillschweigend nicht mehr.
|
||||
it("resolves the special notes from tbl_note, not from config", () => {
|
||||
notenApi.getCisConfig().then((response) => {
|
||||
const config = expectNotenSuccess(response, "getCisConfig");
|
||||
@@ -119,8 +117,7 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
|
||||
});
|
||||
|
||||
describe("Rule B - attempts are taken in chronological order", () => {
|
||||
// "Noch nicht eingetragen" occupies a date without counting towards the cap, so Rule A
|
||||
// cannot fire first and mask Rule B.
|
||||
// "Noch nicht eingetragen" belegt ein Datum ohne zu zählen, damit Regel A nicht maskiert
|
||||
const givenOpenAttemptOn = (student, datum) => {
|
||||
givenBaseline(ctx, student);
|
||||
addPruefung(ctx, student, { note: ctx.notes.nochNichtEingetragen, datum }).then((response) => {
|
||||
@@ -240,7 +237,61 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("kommPruef is the terminal attempt", () => {
|
||||
describe("die kommissionelle Prüfung ist der letzte Antritt", () => {
|
||||
// §17 Abs 1: die zweite Wiederholung ist kommissionell. Der Server leitet den Typ aus der
|
||||
// Position ab und schreibt ihn in pruefungstyp_kurzbz, weil die Studierendenverwaltung
|
||||
// diese Spalte weiterhin liest.
|
||||
it("schreibt den letzten Antritt als kommPruef", () => {
|
||||
const student = studentFor(3);
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
// bis zum vorletzten Antritt auffüllen
|
||||
for (let i = 1; i < ctx.maxAntritte - 1; i += 1) {
|
||||
addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, i) }).then(
|
||||
(response) => expectNotenSuccess(response, `Antritt ${i + 1}`),
|
||||
);
|
||||
}
|
||||
|
||||
addPruefung(ctx, student, {
|
||||
note: ctx.gradeNotes[0],
|
||||
datum: attemptDate(ctx, ctx.maxAntritte),
|
||||
}).then((response) => {
|
||||
const [saved] = expectNotenSuccess(response, "letzter Antritt");
|
||||
expect(saved.pruefungstyp_kurzbz, "der letzte Antritt ist kommissionell").to.eq("kommPruef");
|
||||
});
|
||||
|
||||
readState(ctx).then((data) => {
|
||||
const verlauf = verlaufOfStudent(data, student.uid);
|
||||
expect(verlauf.antrittCount, "die kommissionelle zählt als Antritt").to.eq(ctx.maxAntritte);
|
||||
expect(verlauf.canAdd, "danach ist kein Antritt mehr möglich").to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
// Auch eine kommissionelle Prüfung ohne zählende Note schliesst die Kette. Sonst liesse sich
|
||||
// nach einer noch unbenoteten kommissionellen ein weiterer Antritt anlegen.
|
||||
it("sperrt weitere Antritte auch bei einer kommPruef ohne zählende Note", () => {
|
||||
const student = studentFor(1);
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
seedPruefung(ctx, student, {
|
||||
note: ctx.notes.nochNichtEingetragen,
|
||||
datum: attemptDate(ctx, 1),
|
||||
typ: "kommPruef",
|
||||
});
|
||||
|
||||
readState(ctx).then((data) => {
|
||||
const verlauf = verlaufOfStudent(data, student.uid);
|
||||
expect(verlauf.terminal, "die Kette ist geschlossen").to.be.true;
|
||||
expect(verlauf.canAdd, "kein weiterer Antritt möglich").to.be.false;
|
||||
});
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 2) }).then(
|
||||
(response) => expectNotenError(response, "maxAntritteReached"),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a further attempt once a kommPruef exists", () => {
|
||||
const student = studentFor(0);
|
||||
|
||||
@@ -295,8 +346,7 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// the students of this LV carry their Zeugnisnote from the fixture; pick one that has an
|
||||
// Anrechnung, otherwise there is nothing to assert here
|
||||
// braucht einen Studenten, dessen Fixture-Zeugnisnote eine Anrechnung ist
|
||||
readState(ctx).then((data) => {
|
||||
const target = (data[0] || []).find((s) => String(s.note) === String(angerechnet));
|
||||
if (!target) {
|
||||
@@ -316,10 +366,7 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
|
||||
});
|
||||
|
||||
describe("edit guards", () => {
|
||||
/**
|
||||
* Builds: Antritt 1 (baseline date) < excused (attempt 1) < real grade (attempt 2)
|
||||
* and yields the excused row, which now has both an earlier and a later neighbour.
|
||||
*/
|
||||
/** Antritt 1 < entschuldigt < echte Note; liefert die entschuldigte Zeile (Nachbarn beidseits). */
|
||||
const givenThreeAttempts = (student) => {
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
@@ -371,9 +418,8 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The add-time limit is covered by Rule C; this is the edit-time re-check. Its converse -
|
||||
// re-saving the excused row itself, which the exclusion must let through - is the date-only
|
||||
// correction above.
|
||||
// Regel C beim Bearbeiten; die Gegenprobe (die entschuldigte Zeile selbst neu speichern)
|
||||
// ist die reine Datumskorrektur oben
|
||||
it("rejects an edit that would exceed the entschuldigt limit", () => {
|
||||
const student = studentFor(0);
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
/**
|
||||
* savePruefungFuerStudent / savePruefungstermin - the write path shared by saveStudentPruefung,
|
||||
* createPruefungen and savePruefungenBulk. The other specs cover the validators; this one covers
|
||||
* the writer.
|
||||
* Der Schreibpfad (savePruefungFuerStudent), den saveStudentPruefung, createPruefungen und
|
||||
* savePruefungenBulk teilen. Die Validatoren decken die anderen Specs ab.
|
||||
*
|
||||
* Core invariant since the Prüfungsverlauf refactor: one action writes exactly one Prüfung. The
|
||||
* former Termin1 snapshot is gone - Antritt 1 is created by the password-gated Freigabe.
|
||||
* Invariante: eine Aktion schreibt genau eine Prüfung; Antritt 1 entsteht bei der Freigabe.
|
||||
*/
|
||||
|
||||
import { expectNotenSuccess } from "../../../../support/helpers/notenErrors";
|
||||
@@ -107,8 +105,7 @@ describe("Noten API - Prüfungstermin (write path)", () => {
|
||||
});
|
||||
|
||||
it("appends a new row per add instead of overwriting the previous one", () => {
|
||||
// The old writer re-derived its target as "the first non-excused Termin2" and updated it.
|
||||
// Now only an explicit pruefung_id updates; an add always inserts.
|
||||
// nur eine explizite pruefung_id aktualisiert, ein Add fügt immer ein
|
||||
const student = studentFor(0);
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
@@ -171,9 +168,8 @@ describe("Noten API - Prüfungstermin (write path)", () => {
|
||||
});
|
||||
|
||||
it("adds an attempt while the LV note is still offen", () => {
|
||||
// getLvGesamtNoten filters `freigabedatum < NOW()`. The writer must read the LV note through
|
||||
// the UNFILTERED getter, otherwise it takes the INSERT branch against an existing primary key
|
||||
// and answers "keine LV-Note eingetragen" forever.
|
||||
// liest der Schreibpfad gefiltert, sieht er die Note nicht, versucht ein INSERT gegen den
|
||||
// bestehenden PK und antwortet für immer "keine LV-Note eingetragen"
|
||||
const student = studentFor(3);
|
||||
|
||||
givenBaseline(ctx, student, { freigegeben: false });
|
||||
@@ -212,8 +208,7 @@ describe("Noten API - Prüfungstermin (write path)", () => {
|
||||
});
|
||||
|
||||
it("creates the LV note when a student has none yet", () => {
|
||||
// Previously this answered c4keineLvNoteEingetragen. The bulk path now runs the same core as
|
||||
// the single dialog, which writes the LV note before the Prüfung.
|
||||
// der Bulk-Pfad läuft durch denselben Kern und schreibt die LV-Note vor dem Termin
|
||||
const student = studentFor(0);
|
||||
|
||||
resetNotenState(ctx);
|
||||
|
||||
@@ -1,26 +1,19 @@
|
||||
/**
|
||||
* Overwrite rules for the Notenvorschlag column - all of them client-side only.
|
||||
* Overwrite-Regeln der Notenvorschlag-Spalte.
|
||||
*
|
||||
* CIS_GESAMTNOTE_UEBERSCHREIBEN is exposed by getCisConfig but read by nobody in CIS4; only the
|
||||
|
||||
* What the CIS4 tool enforces sits in Benotungstool.js and never reaches the server:
|
||||
* - the editor list offers lehre notes only (:1925), so 'nicht zugelassen' can never be picked
|
||||
* - the column is non-editable once the student has any Prüfung (:1017)
|
||||
*
|
||||
* saveNotenvorschlag applies neither, so both tests are expected RED: a direct API call bypasses
|
||||
* the rules. Assertions read the row back through the DB, since getLvGesamtNoten hides
|
||||
* un-freigegebene notes.
|
||||
*
|
||||
* A third rule - lkt_ueberschreibbar on the student's Zeugnisnote (:1019) - is not covered here:
|
||||
* it needs a seeded lehre.tbl_zeugnisnote row and the test DB role has no rights on that table.
|
||||
* Drei Regeln: die Editorliste bietet nur lehre-Noten an, die Spalte ist gesperrt, sobald eine
|
||||
* Prüfung existiert, und eine Zeugnisnote mit lkt_ueberschreibbar = false sperrt sie ebenfalls.
|
||||
* Alle drei stehen in Benotungstool.js UND in Noten::validateNotenvorschlag - der direkte
|
||||
* API-Aufruf und der CSV-Import erreichen den Client nie.
|
||||
*/
|
||||
|
||||
import { expectNotenSuccess } from "../../../../support/helpers/notenErrors";
|
||||
import { expectBulkRowError, expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
|
||||
import {
|
||||
attemptDate,
|
||||
loadNotenContext,
|
||||
readLvGesamtnote,
|
||||
requireDbReset,
|
||||
seedZeugnisnote,
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
import { addPruefung, givenBaseline } from "../../../../support/helpers/notenScenario";
|
||||
import { notenApi } from "../../../../support/api/notenApi";
|
||||
@@ -44,10 +37,12 @@ describe("Noten API - Notenvorschlag overwrite rules", () => {
|
||||
|
||||
const student = studentFor(0);
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
// ohne Erstantritt: sonst greift die Prüfungsregel und die Notenregel bliebe ungeprüft
|
||||
givenBaseline(ctx, student, { erstantritt: false });
|
||||
|
||||
notenApi
|
||||
.saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.notes.nichtLehre)
|
||||
.then((response) => expectNotenError(response, "c4noteNichtInLehre"))
|
||||
.then(() => readLvGesamtnote(ctx, student.uid))
|
||||
.then((row) => {
|
||||
expect(String(row.note), "a non-lehre note must not become the LV note").to.eq(
|
||||
@@ -68,6 +63,7 @@ describe("Noten API - Notenvorschlag overwrite rules", () => {
|
||||
// the grade now belongs to the attempt history; a direct edit bypasses the Antritt rules
|
||||
notenApi
|
||||
.saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.gradeNotes[0])
|
||||
.then((response) => expectNotenError(response, "c4notenvorschlagGesperrt"))
|
||||
.then(() => readLvGesamtnote(ctx, student.uid))
|
||||
.then((row) => {
|
||||
expect(String(row.note), "the attempt's grade must survive the call").to.eq(
|
||||
@@ -76,20 +72,52 @@ describe("Noten API - Notenvorschlag overwrite rules", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Same hole through the CSV import, which is the path an Assistenz actually drives.
|
||||
// saveNotenvorschlagBulk validates nothing at all, so these are RED for the same reason.
|
||||
it("refuses to overwrite a locked Zeugnisnote", function () {
|
||||
if (ctx.notes.nichtUeberschreibbar === null) {
|
||||
this.skip(); // every active note allows the teacher to overwrite it on this instance
|
||||
}
|
||||
|
||||
const student = studentFor(4);
|
||||
|
||||
givenBaseline(ctx, student, { erstantritt: false });
|
||||
seedZeugnisnote(ctx, student.uid, ctx.notes.nichtUeberschreibbar);
|
||||
|
||||
notenApi
|
||||
.saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.gradeNotes[1])
|
||||
.then((response) => expectNotenError(response, "c4zeugnisnoteGesperrt"))
|
||||
.then(() => readLvGesamtnote(ctx, student.uid))
|
||||
.then((row) => {
|
||||
expect(String(row.note), "the LV note must not change").to.eq(String(ctx.gradeNotes[0]));
|
||||
});
|
||||
});
|
||||
|
||||
// Derselbe Weg über den CSV-Import, den eine Assistenz tatsächlich fährt. Der Bulk-Endpunkt
|
||||
// antwortet 200 und meldet die abgelehnte Zeile in data[uid].
|
||||
describe("saveNotenvorschlagBulk", () => {
|
||||
beforeEach(function () {
|
||||
if (ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) {
|
||||
// im Punktemodus leitet der Import die Note aus den Punkten ab und verwirft eine
|
||||
// Zeile ohne Punkte, bevor eine dieser Regeln greift
|
||||
cy.log("Skipped: CIS_GESAMTNOTE_PUNKTE ist aktiv.");
|
||||
this.skip();
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a note the editor list never offers", function () {
|
||||
if (ctx.notes.nichtLehre === null) this.skip();
|
||||
|
||||
const student = studentFor(2);
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
givenBaseline(ctx, student, { erstantritt: false });
|
||||
|
||||
notenApi
|
||||
.saveNotenvorschlagBulk(ctx.lvId, ctx.semKurzbz, [
|
||||
{ uid: student.uid, note: ctx.notes.nichtLehre, punkte: null },
|
||||
])
|
||||
.then((response) => {
|
||||
const data = expectNotenSuccess(response, "saveNotenvorschlagBulk");
|
||||
expectBulkRowError(data, student.uid, "c4noteNichtInLehre");
|
||||
})
|
||||
.then(() => readLvGesamtnote(ctx, student.uid))
|
||||
.then((row) => {
|
||||
expect(String(row.note), "a non-lehre note must not become the LV note").to.eq(
|
||||
@@ -98,6 +126,30 @@ describe("Noten API - Notenvorschlag overwrite rules", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to overwrite a locked Zeugnisnote", function () {
|
||||
if (ctx.notes.nichtUeberschreibbar === null) this.skip();
|
||||
|
||||
const student = studentFor(5);
|
||||
|
||||
givenBaseline(ctx, student, { erstantritt: false });
|
||||
seedZeugnisnote(ctx, student.uid, ctx.notes.nichtUeberschreibbar);
|
||||
|
||||
notenApi
|
||||
.saveNotenvorschlagBulk(ctx.lvId, ctx.semKurzbz, [
|
||||
{ uid: student.uid, note: ctx.gradeNotes[1], punkte: null },
|
||||
])
|
||||
.then((response) => {
|
||||
const data = expectNotenSuccess(response, "saveNotenvorschlagBulk");
|
||||
expectBulkRowError(data, student.uid, "c4zeugnisnoteGesperrt");
|
||||
})
|
||||
.then(() => readLvGesamtnote(ctx, student.uid))
|
||||
.then((row) => {
|
||||
expect(String(row.note), "the LV note must not change").to.eq(
|
||||
String(ctx.gradeNotes[0]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to change the Notenvorschlag once a Prüfung exists", () => {
|
||||
const student = studentFor(3);
|
||||
|
||||
@@ -113,6 +165,10 @@ describe("Noten API - Notenvorschlag overwrite rules", () => {
|
||||
.saveNotenvorschlagBulk(ctx.lvId, ctx.semKurzbz, [
|
||||
{ uid: student.uid, note: ctx.gradeNotes[0], punkte: null },
|
||||
])
|
||||
.then((response) => {
|
||||
const data = expectNotenSuccess(response, "saveNotenvorschlagBulk");
|
||||
expectBulkRowError(data, student.uid, "c4notenvorschlagGesperrt");
|
||||
})
|
||||
.then(() => readLvGesamtnote(ctx, student.uid))
|
||||
.then((row) => {
|
||||
expect(String(row.note), "the attempt's grade must survive the import").to.eq(
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
|
||||
import {
|
||||
loadNotenContext,
|
||||
readLvGesamtnote,
|
||||
requireDbReset,
|
||||
resetNotenState,
|
||||
seedBaseline,
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
|
||||
/**
|
||||
* Notenfreigabe über den Modal-Dialog mit Passwort.
|
||||
*
|
||||
* Der Dialog listet genau die Zeilen, die freigegeben werden (changedNoten), und verlangt das
|
||||
* LDAP-Passwort. Danach muss die Statusspalte ohne Reload von changed auf ok springen.
|
||||
*
|
||||
* Ein erfolgreiches Speichern verschickt IMMER die Freigabemail - dieselbe Sperre wie in
|
||||
* noten.freigabe: opt-in über NOTEN_FREIGABE_ENABLED. Dialogansicht und Passwortablehnung
|
||||
* schreiben nichts und laufen daher immer.
|
||||
*/
|
||||
const freigabeEnabled = () => String(Cypress.env("NOTEN_FREIGABE_ENABLED")).toLowerCase() === "true";
|
||||
const freigabePassword = () => Cypress.env("NOTEN_FREIGABE_PASSWORD") || Cypress.env("adminpassword");
|
||||
|
||||
context("Benotungstool UI - Notenfreigabe", () => {
|
||||
let ctx;
|
||||
let bezeichnung;
|
||||
|
||||
before(() => {
|
||||
requireDbReset();
|
||||
loadNotenContext().then((context) => {
|
||||
ctx = context;
|
||||
bezeichnung = (note) => page.bezeichnungOf(ctx, note);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dialog", () => {
|
||||
it("listet die noch nicht freigegebene Note mit ihrer Zielnote", () => {
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: false });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
page.expectFreigabeState(student.uid, "changed");
|
||||
|
||||
page.openFreigabeModal();
|
||||
page.expectFreigabeSummaryRow(student.uid, bezeichnung(ctx.gradeNotes[0]));
|
||||
});
|
||||
|
||||
it("meldet eine leere Auswahl, wenn nichts freizugeben ist", () => {
|
||||
resetNotenState(ctx);
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
page.openFreigabeModal();
|
||||
|
||||
cy.get("[data-cy='freigabe-summary-empty']").should("be.visible");
|
||||
});
|
||||
|
||||
it("lehnt ein falsches Passwort ab und lässt den Status unverändert", () => {
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: false });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
page.openFreigabeModal();
|
||||
|
||||
page.typeFreigabePasswort("definitely-not-the-password");
|
||||
page.submitFreigabe();
|
||||
cy.wait("@saveStudentenNoten");
|
||||
|
||||
readLvGesamtnote(ctx, student.uid).then((rowData) => {
|
||||
expect(rowData.freigabedatum, "eine abgelehnte Freigabe stempelt nichts").to.be.null;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Freigabe (verschickt Mail - opt in über NOTEN_FREIGABE_ENABLED)", () => {
|
||||
beforeEach(function () {
|
||||
if (!freigabeEnabled()) {
|
||||
cy.log(
|
||||
"Skipped: die Freigabe verschickt die Notenfreigabe-Mail. NOTEN_FREIGABE_ENABLED=true " +
|
||||
"nur auf einer Umgebung setzen, auf der das harmlos ist.",
|
||||
);
|
||||
this.skip();
|
||||
}
|
||||
});
|
||||
|
||||
it("gibt frei und schaltet die Statusspalte auf ok", () => {
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: false });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
page.expectFreigabeState(student.uid, "changed");
|
||||
|
||||
page.openFreigabeModal();
|
||||
page.freigeben(freigabePassword());
|
||||
|
||||
page.expectFreigabeState(student.uid, "ok");
|
||||
page.expectLvNote(student.uid, bezeichnung(ctx.gradeNotes[0]));
|
||||
});
|
||||
|
||||
it("legt mit der Freigabe den ersten Antritt an", () => {
|
||||
const student = ctx.students[1];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: false });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
page.openFreigabeModal();
|
||||
page.freigeben(freigabePassword());
|
||||
|
||||
// upsertErstantritt schreibt die Prüfungszeile; sichtbar wird sie beim nächsten Laden
|
||||
page.visitAndWaitForTable(ctx);
|
||||
page.expectPruefung(student.uid, "antritt_1", { note: ctx.gradeNotes[0], antritt: 1 });
|
||||
page.expectAntrittCount(student.uid, 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
|
||||
import {
|
||||
attemptDate,
|
||||
loadNotenContext,
|
||||
requireDbReset,
|
||||
resetNotenState,
|
||||
seedBaseline,
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
|
||||
/**
|
||||
* Die beiden Importe aus der Toolbar.
|
||||
*
|
||||
* Notenimport: "UID<TAB>Note" je Zeile, schreibt nur die LV-Note.
|
||||
* Prüfungsimport: "UID<TAB>dd.MM.yyyy<TAB>Note" je Zeile, legt zusätzlich den Termin an.
|
||||
*
|
||||
* Beide sind über CIS_GESAMTNOTE_NOTENIMPORT / CIS_GESAMTNOTE_PRUEFUNGSIMPORT konfigurierbar - ist
|
||||
* der Import aus, existiert der Button nicht und der Block wird übersprungen.
|
||||
*/
|
||||
context("Benotungstool UI - Import", () => {
|
||||
let ctx;
|
||||
let bezeichnung;
|
||||
|
||||
before(() => {
|
||||
requireDbReset();
|
||||
loadNotenContext().then((context) => {
|
||||
ctx = context;
|
||||
bezeichnung = (note) => page.bezeichnungOf(ctx, note);
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(function () {
|
||||
if (ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) {
|
||||
// im Punktemodus erwarten beide Importe Punkte statt einer Note
|
||||
cy.log("Skipped: CIS_GESAMTNOTE_PUNKTE ist aktiv.");
|
||||
this.skip();
|
||||
}
|
||||
});
|
||||
|
||||
describe("Notenimport", () => {
|
||||
beforeEach(function () {
|
||||
if (!ctx.cisConfig.CIS_GESAMTNOTE_NOTENIMPORT) {
|
||||
cy.log("Skipped: CIS_GESAMTNOTE_NOTENIMPORT ist aus, der Button existiert nicht.");
|
||||
this.skip();
|
||||
}
|
||||
});
|
||||
|
||||
it("schreibt die LV-Note für jede Zeile", () => {
|
||||
const [a, b] = ctx.students;
|
||||
|
||||
resetNotenState(ctx);
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.importNoten([
|
||||
[a.uid, ctx.gradeNotes[0]],
|
||||
[b.uid, ctx.gradeNotes[1]],
|
||||
]);
|
||||
|
||||
page.expectLvNote(a.uid, bezeichnung(ctx.gradeNotes[0]));
|
||||
page.expectLvNote(b.uid, bezeichnung(ctx.gradeNotes[1]));
|
||||
|
||||
// erfasst, aber nicht freigegeben
|
||||
page.expectFreigabeState(a.uid, "changed");
|
||||
page.expectFreigabeState(b.uid, "changed");
|
||||
});
|
||||
|
||||
it("legt dabei keine Prüfung an", () => {
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.importNoten([[student.uid, ctx.gradeNotes[0]]]);
|
||||
|
||||
// die LV-Note zählt als impliziter erster Antritt, ohne dass eine Zeile entsteht
|
||||
page.expectAntrittCount(student.uid, 1);
|
||||
page.expectKeinePruefung(student.uid, "antritt_1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Prüfungsimport", () => {
|
||||
beforeEach(function () {
|
||||
if (!ctx.cisConfig.CIS_GESAMTNOTE_PRUEFUNGSIMPORT) {
|
||||
cy.log("Skipped: CIS_GESAMTNOTE_PRUEFUNGSIMPORT ist aus, der Button existiert nicht.");
|
||||
this.skip();
|
||||
}
|
||||
});
|
||||
|
||||
it("legt je Zeile einen datierten Antritt an", () => {
|
||||
const [a, b] = ctx.students;
|
||||
const datum = page.toDDMMYYYY(attemptDate(ctx, 1));
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, a.uid, { note: ctx.gradeNotes[0], freigegeben: true });
|
||||
seedBaseline(ctx, b.uid, { note: ctx.gradeNotes[0], freigegeben: true });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.importPruefungen([
|
||||
[a.uid, datum, ctx.gradeNotes[1]],
|
||||
[b.uid, datum, ctx.gradeNotes[1]],
|
||||
]);
|
||||
|
||||
[a, b].forEach((student) => {
|
||||
page.expectPruefung(student.uid, "antritt_2", { note: ctx.gradeNotes[1], antritt: 2 });
|
||||
page.expectAntrittCount(student.uid, 2);
|
||||
page.expectLvNote(student.uid, bezeichnung(ctx.gradeNotes[1]));
|
||||
});
|
||||
});
|
||||
|
||||
it("legt für einen Studenten ohne LV-Note beides an", () => {
|
||||
const student = ctx.students[2];
|
||||
|
||||
resetNotenState(ctx);
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.importPruefungen([[student.uid, page.toDDMMYYYY(attemptDate(ctx, 1)), ctx.gradeNotes[0]]]);
|
||||
|
||||
page.expectPruefung(student.uid, "antritt_1", { note: ctx.gradeNotes[0], antritt: 1 });
|
||||
page.expectLvNote(student.uid, bezeichnung(ctx.gradeNotes[0]));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
|
||||
import {
|
||||
loadNotenContext,
|
||||
requireDbReset,
|
||||
resetNotenState,
|
||||
seedBaseline,
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
|
||||
/**
|
||||
* Notenvorschlag eintragen und übernehmen.
|
||||
*
|
||||
* Die Regeln dahinter prüft noten.notenvorschlag; hier geht es darum, dass die Tabelle den
|
||||
* Serverzustand ohne Reload korrekt nachführt: LV-Note, Freigabestatus und das Verschwinden des
|
||||
* Übernehmen-Buttons.
|
||||
*/
|
||||
context("Benotungstool UI - Notenvorschlag", () => {
|
||||
let ctx;
|
||||
let bezeichnung;
|
||||
|
||||
before(() => {
|
||||
requireDbReset();
|
||||
loadNotenContext().then((context) => {
|
||||
ctx = context;
|
||||
bezeichnung = (note) => page.bezeichnungOf(ctx, note);
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(function () {
|
||||
if (ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) {
|
||||
// im Punktemodus ist die Vorschlagsspalte gesperrt, die Note kommt aus den Punkten
|
||||
cy.log("Skipped: CIS_GESAMTNOTE_PUNKTE ist aktiv.");
|
||||
this.skip();
|
||||
}
|
||||
});
|
||||
|
||||
it("trägt einen Vorschlag über den Zelleneditor ein", () => {
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
// ohne Note ist die Zeile leer: kein Benotungsdatum -> offen
|
||||
page.expectFreigabeState(student.uid, "offen");
|
||||
|
||||
page.setNotenvorschlag(student.uid, bezeichnung(ctx.gradeNotes[0]));
|
||||
|
||||
page.expectNotenvorschlag(student.uid, bezeichnung(ctx.gradeNotes[0]));
|
||||
// der Vorschlag allein schreibt noch keine LV-Note
|
||||
page.expectFreigabeState(student.uid, "offen");
|
||||
});
|
||||
|
||||
it("übernimmt den Vorschlag als LV-Note und zeigt den Status changed", () => {
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.setNotenvorschlag(student.uid, bezeichnung(ctx.gradeNotes[0]));
|
||||
page.uebernehmen(student.uid);
|
||||
|
||||
page.expectLvNote(student.uid, bezeichnung(ctx.gradeNotes[0]));
|
||||
page.expectFreigabeState(student.uid, "changed");
|
||||
});
|
||||
|
||||
it("blendet den Übernehmen-Button aus, sobald Vorschlag und LV-Note übereinstimmen", () => {
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.setNotenvorschlag(student.uid, bezeichnung(ctx.gradeNotes[0]));
|
||||
page.uebernehmen(student.uid);
|
||||
|
||||
page.getUebernehmenButton(student.uid).should("not.exist");
|
||||
});
|
||||
|
||||
it("zeigt eine bereits freigegebene Note nach dem Laden als ok", () => {
|
||||
const student = ctx.students[1];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: true });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.expectLvNote(student.uid, bezeichnung(ctx.gradeNotes[0]));
|
||||
page.expectFreigabeState(student.uid, "ok");
|
||||
});
|
||||
|
||||
it("zeigt eine erfasste, nicht freigegebene Note nach dem Laden als changed", () => {
|
||||
const student = ctx.students[1];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: false });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
// getStudentenNoten liest ungefiltert - wäre das nicht so, bliebe die Zeile hier leer
|
||||
page.expectLvNote(student.uid, bezeichnung(ctx.gradeNotes[0]));
|
||||
page.expectFreigabeState(student.uid, "changed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
|
||||
import {
|
||||
attemptDate,
|
||||
loadNotenContext,
|
||||
requireDbReset,
|
||||
resetNotenState,
|
||||
seedBaseline,
|
||||
seedPruefung,
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
|
||||
/**
|
||||
* Prüfungen anlegen und bearbeiten, über beide Wege: den Dialog aus der Tabellenzelle und die
|
||||
* Sammelanlage aus der Toolbar. Beide laufen serverseitig durch denselben Kern
|
||||
* (siehe noten.pruefungstermin); geprüft wird, dass die Zelle danach ohne Reload stimmt.
|
||||
*
|
||||
* Die Specs laufen im Antrittsmodus, weil die Spaltennamen dort stabil sind: antritt_1, antritt_2,
|
||||
* ... Im Datumsmodus heisst die Spalte wie das Prüfungsdatum.
|
||||
*/
|
||||
context("Benotungstool UI - Prüfungen", () => {
|
||||
let ctx;
|
||||
let bezeichnung;
|
||||
|
||||
before(() => {
|
||||
requireDbReset();
|
||||
loadNotenContext().then((context) => {
|
||||
ctx = context;
|
||||
bezeichnung = (note) => page.bezeichnungOf(ctx, note);
|
||||
|
||||
expect(ctx.maxAntritte, "braucht Platz für mindestens eine Wiederholung").to.be.greaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(function () {
|
||||
if (ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) {
|
||||
// im Punktemodus ist das Notenfeld gesperrt, die Note kommt aus dem Notenschlüssel
|
||||
cy.log("Skipped: CIS_GESAMTNOTE_PUNKTE ist aktiv.");
|
||||
this.skip();
|
||||
}
|
||||
});
|
||||
|
||||
it("zeigt den von der Freigabe angelegten ersten Antritt", () => {
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: true });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.expectPruefung(student.uid, "antritt_1", { note: ctx.gradeNotes[0], antritt: 1 });
|
||||
page.expectAntrittCount(student.uid, 1);
|
||||
});
|
||||
|
||||
it("legt aus der Zelle eine Wiederholung an und zählt sie als Antritt 2", () => {
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: true });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.addPruefungInCell(student.uid, "antritt_2", {
|
||||
note: bezeichnung(ctx.gradeNotes[1]),
|
||||
datum: page.toDDMMYYYY(attemptDate(ctx, 1)),
|
||||
});
|
||||
|
||||
page.expectPruefung(student.uid, "antritt_2", { note: ctx.gradeNotes[1], antritt: 2 });
|
||||
page.expectAntrittCount(student.uid, 2);
|
||||
|
||||
// die LV-Note folgt dem neuesten Antritt und ist damit wieder unfreigegeben
|
||||
page.expectLvNote(student.uid, bezeichnung(ctx.gradeNotes[1]));
|
||||
page.expectFreigabeState(student.uid, "changed");
|
||||
});
|
||||
|
||||
it("legt einen Antritt für einen Studenten ohne LV-Note an und weist darauf hin", () => {
|
||||
const student = ctx.students[2];
|
||||
|
||||
resetNotenState(ctx);
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.getPruefungAddButton(student.uid, "antritt_1").click();
|
||||
page.getPruefungModal().should("be.visible");
|
||||
cy.get("[data-cy='pruefung-ohne-lvnote']").should("be.visible");
|
||||
cy.get("[data-cy='pruefung-submit']").click();
|
||||
page.getPruefungModal().should("not.be.visible");
|
||||
|
||||
// ohne gewählte Note entsteht "Noch nicht eingetragen" - das verbraucht keinen Antritt
|
||||
page.expectPruefung(student.uid, "antritt_1", { note: ctx.notes.nochNichtEingetragen });
|
||||
page.expectLvNote(student.uid, bezeichnung(ctx.notes.nochNichtEingetragen));
|
||||
page.expectAntrittCount(student.uid, 0);
|
||||
});
|
||||
|
||||
it("korrigiert nur das Datum eines bestehenden Antritts", () => {
|
||||
const student = ctx.students[0];
|
||||
const neuesDatum = attemptDate(ctx, 2);
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: true });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.addPruefungInCell(student.uid, "antritt_2", {
|
||||
note: bezeichnung(ctx.gradeNotes[1]),
|
||||
datum: page.toDDMMYYYY(attemptDate(ctx, 1)),
|
||||
});
|
||||
page.editPruefungInCell(student.uid, "antritt_2", { datum: page.toDDMMYYYY(neuesDatum) });
|
||||
|
||||
page.expectPruefung(student.uid, "antritt_2", { note: ctx.gradeNotes[1], antritt: 2 });
|
||||
page.getCell(student.uid, "antritt_2").should("contain.text", page.toDDMMYYYY(neuesDatum));
|
||||
});
|
||||
|
||||
it("sperrt die Note, sobald ein späterer Antritt existiert", () => {
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: true });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
// entschuldigt zuerst: verbraucht keinen Antritt, daher bleibt die dritte Spalte auch bei
|
||||
// maxAntritte = 2 erreichbar
|
||||
page.addPruefungInCell(student.uid, "antritt_2", {
|
||||
note: bezeichnung(ctx.notes.entschuldigt),
|
||||
datum: page.toDDMMYYYY(attemptDate(ctx, 1)),
|
||||
});
|
||||
page.addPruefungInCell(student.uid, "antritt_3", {
|
||||
note: bezeichnung(ctx.gradeNotes[1]),
|
||||
datum: page.toDDMMYYYY(attemptDate(ctx, 2)),
|
||||
});
|
||||
|
||||
page.openPruefungModalForEdit(student.uid, "antritt_2");
|
||||
|
||||
cy.get("[data-cy='pruefung-note-locked']").should("be.visible");
|
||||
cy.get("[data-cy='pruefung-note']").should("have.class", "p-disabled");
|
||||
});
|
||||
|
||||
it("legt über die Sammelanlage für mehrere Studierende denselben Termin an", () => {
|
||||
const [a, b] = ctx.students;
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, a.uid, { note: ctx.gradeNotes[0], freigegeben: true });
|
||||
seedBaseline(ctx, b.uid, { note: ctx.gradeNotes[0], freigegeben: true });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.addPruefungBulk({
|
||||
uids: [a.uid, b.uid],
|
||||
note: bezeichnung(ctx.gradeNotes[1]),
|
||||
datum: page.toDDMMYYYY(attemptDate(ctx, 1)),
|
||||
});
|
||||
|
||||
[a, b].forEach((student) => {
|
||||
page.expectPruefung(student.uid, "antritt_2", { note: ctx.gradeNotes[1], antritt: 2 });
|
||||
page.expectAntrittCount(student.uid, 2);
|
||||
page.expectLvNote(student.uid, bezeichnung(ctx.gradeNotes[1]));
|
||||
});
|
||||
});
|
||||
|
||||
it("legt den letzten Antritt als kommissionelle Prüfung an", () => {
|
||||
const student = ctx.students[1];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: true });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
// Baseline liefert Antritt 1. Bis zum vorletzten Antritt laufen die normalen Spalten.
|
||||
for (let i = 1; i < ctx.maxAntritte - 1; i += 1) {
|
||||
page.addPruefungInCell(student.uid, `antritt_${i + 1}`, {
|
||||
note: bezeichnung(ctx.gradeNotes[0]),
|
||||
datum: page.toDDMMYYYY(attemptDate(ctx, i)),
|
||||
});
|
||||
}
|
||||
|
||||
// Der letzte Antritt ist laut Prüfungsordnung kommissionell und wird daher in der
|
||||
// kommPruef-Spalte angelegt, nicht in einer weiteren Antrittsspalte.
|
||||
page.getPruefungAddButton(student.uid, "kommPruef").should("exist");
|
||||
page.expectKeineAntrittsspalte(ctx.maxAntritte);
|
||||
|
||||
page.addPruefungInCell(student.uid, "kommPruef", {
|
||||
note: bezeichnung(ctx.gradeNotes[0]),
|
||||
datum: page.toDDMMYYYY(attemptDate(ctx, ctx.maxAntritte)),
|
||||
});
|
||||
|
||||
page.expectAntrittCount(student.uid, ctx.maxAntritte);
|
||||
page.expectPruefung(student.uid, "kommPruef", { note: ctx.gradeNotes[0], antritt: "K" });
|
||||
});
|
||||
|
||||
it("bietet keinen weiteren Antritt an, sobald die Grenze erreicht ist", () => {
|
||||
const student = ctx.students[2];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: true });
|
||||
|
||||
// die kommissionelle Prüfung schliesst die Kette; sie kommt aus dem anderen Tool
|
||||
seedPruefung(ctx, student, {
|
||||
note: ctx.gradeNotes[0],
|
||||
datum: attemptDate(ctx, 1),
|
||||
typ: "kommPruef",
|
||||
});
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.getPruefungAddButton(student.uid, "kommPruef").should("not.exist");
|
||||
page.expectKeineAntrittsspalte(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
|
||||
import { requirePunkteModus } from "../../../../support/helpers/notenConfig";
|
||||
import {
|
||||
attemptDate,
|
||||
loadNotenContext,
|
||||
readLvGesamtnote,
|
||||
requireDbReset,
|
||||
resetNotenState,
|
||||
seedBaseline,
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
import { notenApi } from "../../../../support/api/notenApi";
|
||||
|
||||
/**
|
||||
* Punktemodus (CIS_GESAMTNOTE_PUNKTE) in der Oberfläche.
|
||||
*
|
||||
* Die Punktespalte und die Punktefelder der beiden Dialoge existieren nur mit diesem Flag, die Note
|
||||
* wird dann aus dem Notenschlüssel abgeleitet statt gewählt. Genau diese Felder deckt sonst nichts
|
||||
* ab, deshalb steht der ganze Modus hier in einer eigenen Datei.
|
||||
*
|
||||
* Der Notenschlüssel wird nie hartcodiert: die erwartete Note kommt zur Laufzeit aus
|
||||
* getNoteByPunkte, damit die Specs an jeder Instanz mit eigenem Schlüssel laufen.
|
||||
*/
|
||||
context("Benotungstool UI - Punktemodus", () => {
|
||||
let ctx;
|
||||
// Punktewerte, deren Noten zur Laufzeit ermittelt werden
|
||||
const OBEN = 100;
|
||||
const MITTE = 70;
|
||||
let noteOben;
|
||||
let noteMitte;
|
||||
|
||||
before(() => {
|
||||
requireDbReset();
|
||||
loadNotenContext().then((context) => {
|
||||
ctx = context;
|
||||
if (!ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) return;
|
||||
|
||||
notenApi.getNoteByPunkte(OBEN, ctx.lvId, ctx.semKurzbz).then((r) => {
|
||||
noteOben = r.body.data;
|
||||
expect(noteOben, `${OBEN} Punkte müssen eine Note ergeben`).to.not.be.null;
|
||||
});
|
||||
notenApi.getNoteByPunkte(MITTE, ctx.lvId, ctx.semKurzbz).then((r) => {
|
||||
noteMitte = r.body.data;
|
||||
expect(noteMitte, `${MITTE} Punkte müssen eine Note ergeben`).to.not.be.null;
|
||||
expect(String(noteMitte), "die beiden Punktewerte müssen verschiedene Noten liefern").to.not.eq(
|
||||
String(noteOben),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(function () {
|
||||
requirePunkteModus(this, ctx);
|
||||
});
|
||||
|
||||
describe("Punktespalte in der Tabelle", () => {
|
||||
it("zeigt die Punktespalte und sperrt dafür die Notenauswahl", () => {
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.getPunkteCell(student.uid).should("exist");
|
||||
// die Note kommt aus den Punkten, sie darf nicht direkt gewählt werden
|
||||
page.expectNotenvorschlagGesperrt(student.uid);
|
||||
});
|
||||
|
||||
it("leitet den Notenvorschlag beim Tippen aus den Punkten ab", () => {
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.setPunkteInCell(student.uid, OBEN);
|
||||
|
||||
page.expectNotenvorschlag(student.uid, page.bezeichnungOf(ctx, noteOben));
|
||||
});
|
||||
|
||||
it("schreibt Note und Punkte, wenn der Vorschlag übernommen wird", () => {
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.setPunkteInCell(student.uid, MITTE);
|
||||
page.uebernehmen(student.uid);
|
||||
|
||||
page.expectLvNote(student.uid, page.bezeichnungOf(ctx, noteMitte));
|
||||
page.expectFreigabeState(student.uid, "changed");
|
||||
|
||||
readLvGesamtnote(ctx, student.uid).then((row) => {
|
||||
expect(String(row.note), "abgeleitete Note").to.eq(String(noteMitte));
|
||||
expect(Number(row.punkte), "die Punkte werden mitgeschrieben").to.eq(MITTE);
|
||||
});
|
||||
});
|
||||
|
||||
it("zeigt gespeicherte Punkte nach einem Reload wieder an", () => {
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.setPunkteInCell(student.uid, OBEN);
|
||||
page.uebernehmen(student.uid);
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.expectPunkte(student.uid, OBEN);
|
||||
page.expectLvNote(student.uid, page.bezeichnungOf(ctx, noteOben));
|
||||
});
|
||||
|
||||
it("sperrt die Punktespalte, sobald ein Termin existiert", () => {
|
||||
const student = ctx.students[1];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: true });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.expectPunkteZelleGesperrt(student.uid);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Prüfungsdialog", () => {
|
||||
it("bietet ein Punktefeld statt der Notenauswahl", () => {
|
||||
const student = ctx.students[1];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: true });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
page.getPruefungAddButton(student.uid, "antritt_2").click();
|
||||
page.getPruefungModal().should("be.visible");
|
||||
|
||||
cy.get("[data-cy='pruefung-punkte']").should("be.visible");
|
||||
page.expectNoteFeldGesperrt("pruefung-note");
|
||||
});
|
||||
|
||||
it("legt den Termin mit der aus den Punkten abgeleiteten Note an", () => {
|
||||
const student = ctx.students[1];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: true });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
page.getPruefungAddButton(student.uid, "antritt_2").click();
|
||||
page.getPruefungModal().should("be.visible");
|
||||
|
||||
page.setDatum("pruefung-datum", page.toDDMMYYYY(attemptDate(ctx, 1)));
|
||||
page.setPruefungPunkte(OBEN);
|
||||
|
||||
cy.get("[data-cy='pruefung-submit']").click();
|
||||
cy.wait("@saveStudentPruefung").its("response.statusCode").should("eq", 200);
|
||||
page.getPruefungModal().should("not.be.visible");
|
||||
|
||||
page.expectPruefung(student.uid, "antritt_2", { note: noteOben, antritt: 2 });
|
||||
page.expectLvNote(student.uid, page.bezeichnungOf(ctx, noteOben));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sammelanlage", () => {
|
||||
it("legt für mehrere Studierende Termine mit der abgeleiteten Note an", () => {
|
||||
const [a, b] = ctx.students;
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, a.uid, { note: ctx.gradeNotes[0], freigegeben: true });
|
||||
seedBaseline(ctx, b.uid, { note: ctx.gradeNotes[0], freigegeben: true });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
cy.get("[data-cy='btn-neue-pruefung']").click();
|
||||
page.getNeuePruefungModal().should("be.visible");
|
||||
|
||||
// dort gibt es kein Notenfeld zum Auswählen, nur Punkte
|
||||
cy.get("[data-cy='neue-pruefung-punkte']").should("be.visible");
|
||||
page.expectNoteFeldGesperrt("neue-pruefung-note");
|
||||
|
||||
page.setDatum("neue-pruefung-datum", page.toDDMMYYYY(attemptDate(ctx, 1)));
|
||||
page.setNeuePruefungPunkte(MITTE);
|
||||
|
||||
cy.get("[data-cy='neue-pruefung-studenten']").click();
|
||||
[a, b].forEach((s) =>
|
||||
cy.contains(".p-multiselect-panel .p-multiselect-item", s.uid).click(),
|
||||
);
|
||||
cy.get("[data-cy='neue-pruefung-studenten']").click();
|
||||
cy.get(".p-multiselect-panel").should("not.exist");
|
||||
|
||||
cy.get("[data-cy='neue-pruefung-submit']").click();
|
||||
cy.wait("@createPruefungen").its("response.statusCode").should("eq", 200);
|
||||
page.getNeuePruefungModal().should("not.be.visible");
|
||||
|
||||
[a, b].forEach((s) => {
|
||||
page.expectPruefung(s.uid, "antritt_2", { note: noteMitte, antritt: 2 });
|
||||
page.expectLvNote(s.uid, page.bezeichnungOf(ctx, noteMitte));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Die §1-Regeln auf Clientseite. Pure functions - kein Server, keine Fixture.
|
||||
*
|
||||
* Die Regeln liegen im Server und erreichen den Client als `student.verlauf`; hier wird nur das
|
||||
* Auswerten und der lokale Fallback (Verlauf noch nicht geladen) festgenagelt.
|
||||
*/
|
||||
|
||||
import {
|
||||
antrittCountStudent,
|
||||
brauchtNeueLvNote,
|
||||
canAddPruefung,
|
||||
checkFreigabe,
|
||||
maxAntrittCount,
|
||||
} from "../../../../../public/js/components/Cis/Benotungstool/notenRules.js";
|
||||
|
||||
const ENTSCHULDIGT = 14;
|
||||
const NOCH_NICHT = 9;
|
||||
const NICHT_BEURTEILT = 7;
|
||||
|
||||
/** Mirrors getCisConfig: maxAntritte is derived server-side, the client only reads the number. */
|
||||
const configWith = (maxAntritte) => ({
|
||||
CIS_GESAMTNOTE_MAX_ANTRITTE: maxAntritte,
|
||||
NOTEN_OHNE_ANTRITT: [NOCH_NICHT, ENTSCHULDIGT, NICHT_BEURTEILT],
|
||||
});
|
||||
|
||||
const ours = configWith(2);
|
||||
|
||||
const notenOptions = [
|
||||
{ note: 1, lehre: true },
|
||||
{ note: 5, lehre: true },
|
||||
{ note: ENTSCHULDIGT, lehre: true },
|
||||
{ note: NOCH_NICHT, lehre: true },
|
||||
{ note: NICHT_BEURTEILT, lehre: true }, // "Nicht beurteilt" - a grade on paper only
|
||||
{ note: 20, lehre: false }, // "intern angerechnet" - no participation
|
||||
];
|
||||
|
||||
const student = (props) => ({ pruefungen: [], note: null, hoechsterAntritt: 0, ...props });
|
||||
|
||||
/** A row whose Verlauf the server has already delivered. */
|
||||
const withVerlauf = (verlauf, props = {}) => student({ verlauf: { maxAntritte: 2, ...verlauf }, ...props });
|
||||
|
||||
describe("Benotungstool rules (client)", () => {
|
||||
describe("maxAntrittCount", () => {
|
||||
it("reads the server-derived maximum", () => {
|
||||
expect(maxAntrittCount(configWith(1)), "no retakes").to.eq(1);
|
||||
expect(maxAntrittCount(ours), "our installation").to.eq(2);
|
||||
expect(maxAntrittCount(configWith(3)), "a raised limit").to.eq(3);
|
||||
});
|
||||
|
||||
it("falls back to a single attempt when the config is missing", () => {
|
||||
expect(maxAntrittCount({}), "no key at all").to.eq(1);
|
||||
expect(maxAntrittCount(null), "no config at all").to.eq(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("antrittCountStudent", () => {
|
||||
it("takes the count from the Verlauf when the server delivered one", () => {
|
||||
const s = withVerlauf({ antrittCount: 2 }, { note: 1, pruefungen: [{ note: 5 }] });
|
||||
expect(antrittCountStudent(s, ours, notenOptions), "server wins over the local fallback").to.eq(2);
|
||||
});
|
||||
|
||||
describe("local fallback (Verlauf not loaded yet)", () => {
|
||||
it("does not count entschuldigt, 'noch nicht eingetragen' or 'nicht beurteilt'", () => {
|
||||
// still just the original note: none of these consumed an attempt, which is what lets a
|
||||
// second retake be entered
|
||||
const s = student({
|
||||
note: 1,
|
||||
pruefungen: [{ note: ENTSCHULDIGT }, { note: NOCH_NICHT }, { note: NICHT_BEURTEILT }],
|
||||
});
|
||||
expect(antrittCountStudent(s, ours, notenOptions)).to.eq(1);
|
||||
});
|
||||
|
||||
it("counts a real grade on a pruefung", () => {
|
||||
const s = student({ note: 1, pruefungen: [{ note: ENTSCHULDIGT }, { note: 5 }] });
|
||||
expect(antrittCountStudent(s, ours, notenOptions)).to.eq(1);
|
||||
});
|
||||
|
||||
it("counts the original LV note as the first Antritt", () => {
|
||||
expect(antrittCountStudent(student({ note: 1 }), ours, notenOptions)).to.eq(1);
|
||||
});
|
||||
|
||||
it("does not count a non-lehre note as an Antritt", () => {
|
||||
// "intern angerechnet" means no participation, so no attempt was used
|
||||
expect(antrittCountStudent(student({ note: 20 }), ours, notenOptions)).to.eq(0);
|
||||
});
|
||||
|
||||
it("does not count a 'Nicht beurteilt' note as an Antritt", () => {
|
||||
expect(antrittCountStudent(student({ note: NICHT_BEURTEILT }), ours, notenOptions)).to.eq(0);
|
||||
});
|
||||
|
||||
it("counts nothing for a student without any grade", () => {
|
||||
expect(antrittCountStudent(student({}), ours, notenOptions)).to.eq(0);
|
||||
});
|
||||
|
||||
// notenOptions holds only ACTIVE notes; an unknown note must not take the grid render with it
|
||||
it("survives a note that is no longer an active option", () => {
|
||||
expect(() => antrittCountStudent(student({ note: 999 }), ours, notenOptions)).to.not.throw();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("canAddPruefung", () => {
|
||||
it("follows the Verlauf", () => {
|
||||
expect(canAddPruefung(withVerlauf({ canAdd: true, antrittCount: 1 }), ours)).to.be.true;
|
||||
expect(canAddPruefung(withVerlauf({ canAdd: false, antrittCount: 2 }), ours)).to.be.false;
|
||||
});
|
||||
|
||||
it("blocks an angerechnet row even though attempts are left", () => {
|
||||
// the server sets canAdd:false for an Anrechnung; the client must not second-guess it
|
||||
const s = withVerlauf({ canAdd: false, antrittCount: 0, angerechnet: true });
|
||||
expect(canAddPruefung(s, ours)).to.be.false;
|
||||
});
|
||||
|
||||
it("falls back to the cap and the terminal attempt without a Verlauf", () => {
|
||||
expect(canAddPruefung(student({ note: 1 }), ours), "one attempt of two used").to.be.true;
|
||||
expect(
|
||||
canAddPruefung(student({ note: 1, pruefungen: [{ note: 5 }, { note: 4 }] }), ours),
|
||||
"cap reached",
|
||||
).to.be.false;
|
||||
expect(
|
||||
canAddPruefung(student({ note: 1, kommPruef: { note: 5 } }), ours),
|
||||
"terminal attempt exists",
|
||||
).to.be.false;
|
||||
});
|
||||
|
||||
// Bekannte Lücke, hier festgenagelt: die LV-Note zählt nur solange gar keine Prüfung
|
||||
// existiert. Deshalb schreibt die Freigabe Antritt 1 als echte Zeile.
|
||||
it("stops counting the LV note once a Prüfung exists", () => {
|
||||
const s = student({ note: 1, pruefungen: [{ note: 5 }] });
|
||||
expect(antrittCountStudent(s, ours, notenOptions), "the LV note is no longer added").to.eq(1);
|
||||
expect(canAddPruefung(s, ours), "so another attempt still looks available").to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
describe("brauchtNeueLvNote", () => {
|
||||
it("uses hatLvNote from the Verlauf, not the displayed lv_note", () => {
|
||||
// hatLvNote ist massgeblich, lv_note kann noch leer sein
|
||||
const entered = withVerlauf({ hatLvNote: true }, { lv_note: null });
|
||||
expect(brauchtNeueLvNote(entered), "an existing note must not read as missing").to.be.false;
|
||||
|
||||
const missing = withVerlauf({ hatLvNote: false }, { lv_note: null });
|
||||
expect(brauchtNeueLvNote(missing)).to.be.true;
|
||||
});
|
||||
|
||||
it("falls back to lv_note without a Verlauf", () => {
|
||||
expect(brauchtNeueLvNote(student({ lv_note: 1 }))).to.be.false;
|
||||
expect(brauchtNeueLvNote(student({ lv_note: null }))).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkFreigabe", () => {
|
||||
it("derives the state from the two timestamps", () => {
|
||||
expect(checkFreigabe(null, null), "no grade at all").to.eq("offen");
|
||||
expect(checkFreigabe(null, "2026-01-10 08:00:00"), "never released").to.eq("changed");
|
||||
expect(checkFreigabe("2026-01-10 08:00:00", "2026-01-20 08:00:00"), "re-graded").to.eq("changed");
|
||||
expect(checkFreigabe("2026-01-10 08:00:00", "2026-01-10 08:00:00"), "unchanged").to.eq("ok");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
BENOTUNGSTOOL - TESTSUITE
|
||||
=========================
|
||||
|
||||
WARNUNG: Die Suite löscht und schreibt Noten in der Datenbank. Starte sie nur gegen eine
|
||||
Testinstanz.
|
||||
|
||||
|
||||
1. WAS HIER LIEGT
|
||||
-----------------
|
||||
|
||||
e2e/specs/unit/ Regeln als reine Funktion. Kein Server, keine Datenbank.
|
||||
e2e/specs/api/noten/ Die API. Ein Test ruft einen Endpunkt auf und prüft die Antwort.
|
||||
e2e/specs/ui/ Die Oberfläche im Browser.
|
||||
|
||||
support/api/ Die API-Aufrufe an einer Stelle. Spiegelt public/js/api/factory/noten.js.
|
||||
support/pages/ Die Oberfläche an einer Stelle. Selektoren und Klickwege.
|
||||
support/helpers/ Testdaten, Fehlermeldungen, Fixture-Reset.
|
||||
tasks/ Datenbankverbindung und SSH-Tunnel. Läuft in Node, nicht im Browser.
|
||||
suites/noten.js Verbindet die Suite mit cypress.config.js.
|
||||
tools/dbCheck.js Prüft die Fixture ohne HTTP. Gutes erstes Kommando bei Problemen.
|
||||
|
||||
Die Konfiguration steht in cypress.config.js. Die Zugangsdaten stehen in
|
||||
tests/cypress/.env.
|
||||
|
||||
|
||||
2. VORBEREITUNG
|
||||
---------------
|
||||
|
||||
Einmalig:
|
||||
|
||||
npm install
|
||||
|
||||
Dann die Zugangsdaten anlegen:
|
||||
|
||||
cp tests/cypress/.env.example tests/cypress/.env
|
||||
|
||||
Fülle die Datei aus. Die Kommentare darin erklären jeden Wert. Drei Punkte sind wichtig:
|
||||
|
||||
- .env darf nie in Git landen.
|
||||
- USER_NAME ist der LEKTOR, nicht ein Administrator. Ein Administrator unterrichtet meist nichts,
|
||||
dann findet die Suite keine Lehrveranstaltung.
|
||||
- Von einer Arbeitsstation aus brauchst du den SSH-Tunnel. Die Datenbank nimmt nur den
|
||||
Applikationsserver an. Setze NOTEN_SSH_TUNNEL=true und die drei NOTEN_SSH_*-Werte.
|
||||
|
||||
Beide Seiten müssen auf dieselbe Datenbank zeigen: die Webinstanz (config/system.config.inc.php,
|
||||
DB_NAME) und die Suite (NOTEN_DB_NAME in .env). Sonst seedet die Suite die eine Datenbank und prüft
|
||||
gegen die andere.
|
||||
|
||||
|
||||
3. STARTEN
|
||||
----------
|
||||
|
||||
npm run noten:check Fixture prüfen.
|
||||
npm run cy:noten:api Unit- und API-Tests.
|
||||
npm run cy:noten:ui Oberfläche in Chrome.
|
||||
npm run cy:open Cypress interaktiv.
|
||||
|
||||
Zwei weitere Skripte prüfen den Punktemodus:
|
||||
|
||||
npm run cy:noten:punkte-on erwartet CIS_GESAMTNOTE_PUNKTE = true
|
||||
npm run cy:noten:punkte-off erwartet CIS_GESAMTNOTE_PUNKTE = false
|
||||
|
||||
Passt die Instanz nicht zur Erwartung, bricht der Lauf sofort ab.
|
||||
|
||||
|
||||
4. DAS ERGEBNIS
|
||||
---------------------
|
||||
|
||||
Am Ende steht eine Tabelle mit vier Spalten.
|
||||
|
||||
Passing Der Test lief und war erfolgreich.
|
||||
Failing Der Test lief und fand einen Fehler. Nur das ist ein Problem.
|
||||
Pending Der Test hat sich selbst übersprungen. Das ist kein Fehler.
|
||||
Skipped Ein Test lief nicht, weil vorher etwas abgebrochen ist.
|
||||
|
||||
Pending ist normal. Ein Test überspringt sich, wenn seine Voraussetzung fehlt. Beispiele: der
|
||||
Punktemodus ist aus, die Frist ist nicht aktiv, die Freigabemail ist nicht erlaubt. Den Grund
|
||||
schreibt der Test ins Protokoll.
|
||||
|
||||
Der aktuelle Sollzustand: API und Unit 84 Tests, 69 grün, 15 pending. UI 30 Tests, 18 grün,
|
||||
12 pending. Kein roter Test.
|
||||
|
||||
|
||||
5. WENN ETWAS SCHIEFGEHT
|
||||
------------------------
|
||||
|
||||
401 bei allen Tests
|
||||
Die Webinstanz und .env zeigen auf verschiedene Datenbanken. Siehe Abschnitt 2.
|
||||
|
||||
"Fixture reset unavailable"
|
||||
Die Datenbankverbindung fehlt. Prüfe den SSH-Tunnel. Einen Schlüssel mit einem Namen ausserhalb
|
||||
der OpenSSH-Standardnamen probiert SSH nie automatisch. Trage ihn in NOTEN_SSH_KEY ein.
|
||||
|
||||
Ein Test erwartet eine Fehlermeldung und bekommt eine andere
|
||||
Die Phrase fehlt in der Datenbank. Lass system/phrasesupdate.php auf dem Server laufen.
|
||||
|
||||
Der nächste Lauf startet nicht
|
||||
Ein Cypress-Prozess hängt noch. Beende ihn: Stop-Process -Name Cypress -Force
|
||||
|
||||
Ein Screenshot zeigt eine leere Seite
|
||||
Bei API-Tests ist das normal. Diese Tests öffnen keine Seite.
|
||||
|
||||
|
||||
6. KONVENTIONEN
|
||||
---------------
|
||||
|
||||
- Keine festen Ids im Test. Die Noten kommen aus getNoten, die Regelwerte aus getCisConfig. Eine
|
||||
andere Installation hat andere Zahlen.
|
||||
- Warte nach jeder schreibenden Aktion auf den Request, nicht auf die Oberfläche.
|
||||
- Sprich die Oberfläche über data-cy an, nie über eine CSS-Klasse.
|
||||
- Setze den Zustand vor dem Test über givenBaseline oder resetNotenState. Verlass dich nie auf den
|
||||
Zustand, den ein Test davor hinterlassen hat.
|
||||
@@ -27,13 +27,10 @@ export const notenApi = {
|
||||
getLvForStudiengang: (studiengang_kz, sem_kurzbz) =>
|
||||
apiGet("getLvForStudiengang", { studiengang_kz, sem_kurzbz }),
|
||||
|
||||
/**
|
||||
* data is a POSITIONAL array (Noten.php:431):
|
||||
* [studenten, pruefungen, DOMAIN, grades-by-uid, anwesenheiten]
|
||||
*/
|
||||
/** data ist POSITIONAL: [studenten, pruefungen, DOMAIN, grades-by-uid, anwesenheiten] */
|
||||
getStudentenNoten: (lv_id, sem_kurzbz) => apiGet("getStudentenNoten", { lv_id, sem_kurzbz }),
|
||||
|
||||
/** Reads through the UNFILTERED getter, so it sees notes getStudentenNoten hides. uid -> caller. */
|
||||
/** Single-student read through the unfiltered getter. uid -> caller. */
|
||||
getNotenvorschlagStudent: (lv_id, sem_kurzbz, uid = null) =>
|
||||
apiGet("getNotenvorschlagStudent", { lv_id, sem_kurzbz, uid }),
|
||||
|
||||
@@ -41,11 +38,7 @@ export const notenApi = {
|
||||
saveNotenvorschlag: (lv_id, sem_kurzbz, student_uid, note, punkte = null) =>
|
||||
apiPost("saveNotenvorschlag", { lv_id, sem_kurzbz, student_uid, note, punkte }),
|
||||
|
||||
/**
|
||||
* data -> [savedPruefung, lvgesamtnote, verlauf]
|
||||
* No `typ` on the wire: which attempt a Prüfung becomes is derived server-side from the
|
||||
* existing Prüfungsverlauf (PruefungsverlaufLib).
|
||||
*/
|
||||
/** data -> [savedPruefung, lvgesamtnote, verlauf]. Kein `typ` auf der Leitung. */
|
||||
saveStudentPruefung: ({
|
||||
student_uid, note, punkte = null, datum, lva_id, lehreinheit_id, sem_kurzbz, pruefung_id = null,
|
||||
}) =>
|
||||
@@ -60,8 +53,7 @@ export const notenApi = {
|
||||
getNoteByPunkte: (punkte, lv_id, sem_kurzbz) =>
|
||||
apiPost("getNoteByPunkte", { punkte, lv_id, sem_kurzbz }),
|
||||
|
||||
// Bulk paths answer 200 and put the error string in data[uid] per row - they never fail the
|
||||
// whole request (Noten.php:1600/:1656).
|
||||
// Bulk-Pfade antworten 200 und melden Fehler je Zeile in data[uid]
|
||||
saveNotenvorschlagBulk: (lv_id, sem_kurzbz, noten) =>
|
||||
apiPost("saveNotenvorschlagBulk", { lv_id, sem_kurzbz, noten }),
|
||||
|
||||
@@ -78,10 +70,7 @@ export const notenApi = {
|
||||
export const pruefungenOf = (data, uid) =>
|
||||
(data[1] || []).filter((p) => p.student_uid === uid);
|
||||
|
||||
/**
|
||||
* Attempts in Verlauf order. position/zaehlt/antritt_nr/terminal are derived by the server; the
|
||||
* specs assert on those and never on pruefungstyp_kurzbz, which is only a legacy projection.
|
||||
*/
|
||||
/** Antritte in Verlaufsreihenfolge. Specs prüfen position/zaehlt/antritt_nr/terminal, nie den Typ. */
|
||||
export const attemptsOf = (data, uid) =>
|
||||
[...pruefungenOf(data, uid)].sort((a, b) => Number(a.position) - Number(b.position));
|
||||
|
||||
|
||||
@@ -24,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;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const waitForOk = (alias) =>
|
||||
cy.wait(alias).its("response.statusCode").should("eq", 200);
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Konfigurationsabhängige Specs.
|
||||
*
|
||||
* CIS_GESAMTNOTE_PUNKTE ist ein PHP define() in config/global.config.inc.php und damit eine
|
||||
* Eigenschaft der INSTANZ, nicht des Requests - ein Testlauf kann den Modus nicht umschalten.
|
||||
* Die Suite deckt deshalb beide Modi ab und überspringt jeweils den unpassenden Teil.
|
||||
*
|
||||
* Für die Pipeline heisst das: zwei Jobs gegen je eine Instanz. Damit ein Job nicht grün meldet,
|
||||
* weil in Wahrheit alles übersprungen wurde, gibt NOTEN_PUNKTE_MODUS ('on'|'off') die Erwartung
|
||||
* vor - passt die Instanz nicht dazu, scheitert der Lauf sofort und laut.
|
||||
*/
|
||||
|
||||
/** Ist der Punktemodus auf dieser Instanz aktiv? */
|
||||
export const punkteModus = (ctx) => Boolean(ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE);
|
||||
|
||||
/**
|
||||
* In before() aufrufen: prüft die Instanz gegen NOTEN_PUNKTE_MODUS. Ohne gesetzte Variable
|
||||
* (lokaler Lauf) wird nur geloggt.
|
||||
*/
|
||||
export const assertPunkteModus = (ctx) => {
|
||||
const erwartet = String(Cypress.env("NOTEN_PUNKTE_MODUS") || "").toLowerCase();
|
||||
const ist = punkteModus(ctx);
|
||||
|
||||
if (erwartet !== "on" && erwartet !== "off") {
|
||||
cy.log(`CIS_GESAMTNOTE_PUNKTE = ${ist} (NOTEN_PUNKTE_MODUS nicht gesetzt)`);
|
||||
return;
|
||||
}
|
||||
|
||||
expect(
|
||||
ist,
|
||||
`NOTEN_PUNKTE_MODUS=${erwartet}, die Instanz steht aber auf CIS_GESAMTNOTE_PUNKTE=${ist}. ` +
|
||||
"Der Lauf würde sonst grün melden, obwohl der halbe Umfang übersprungen wurde. " +
|
||||
"Flag sitzt in config/global.config.inc.php - cis.config.inc.php wird zu spät geladen.",
|
||||
).to.eq(erwartet === "on");
|
||||
};
|
||||
|
||||
/** Skip, wenn der Punktemodus aus ist. Als erste Zeile in beforeEach(function(){...}) aufrufen. */
|
||||
export const requirePunkteModus = (testContext, ctx) => {
|
||||
if (punkteModus(ctx)) return;
|
||||
cy.log("Übersprungen: CIS_GESAMTNOTE_PUNKTE ist aus.");
|
||||
testContext.skip();
|
||||
};
|
||||
|
||||
/** Skip, wenn der Punktemodus an ist. */
|
||||
export const requireNotenModus = (testContext, ctx) => {
|
||||
if (!punkteModus(ctx)) return;
|
||||
cy.log("Übersprungen: CIS_GESAMTNOTE_PUNKTE ist aktiv.");
|
||||
testContext.skip();
|
||||
};
|
||||
@@ -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}.',
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
/**
|
||||
* Fixture reset over a direct DB connection - the only mechanism.
|
||||
*
|
||||
* A gated HTTP reset endpoint existed briefly and was removed: it deleted grade data, and no
|
||||
* gating makes that as safe as not having it. Consequence: the runner needs DB reachability, which
|
||||
* from a workstation means the SSH tunnel (cypress/tasks/sshTunnel.js).
|
||||
* Fixture-Reset ausschliesslich über eine direkte DB-Verbindung - ein HTTP-Endpunkt, der
|
||||
* Notendaten löscht, ist durch keine Absicherung so sicher wie sein Nichtvorhandensein.
|
||||
* Von der Workstation aus braucht das den SSH-Tunnel (cypress/tasks/sshTunnel.js).
|
||||
*/
|
||||
|
||||
let available = null;
|
||||
@@ -78,6 +76,17 @@ export const performSeedPruefung = (context, studentUid, payload) =>
|
||||
}),
|
||||
);
|
||||
|
||||
export const performSeedZeugnisnote = (context, studentUid, payload) =>
|
||||
authUid().then((uid) =>
|
||||
cy.task("noten:db:seedZeugnisnote", {
|
||||
lvId: context.lvId,
|
||||
semKurzbz: context.semKurzbz,
|
||||
studentUid,
|
||||
mitarbeiterUid: uid,
|
||||
...payload,
|
||||
}),
|
||||
);
|
||||
|
||||
export const performRead = (context, studentUid) =>
|
||||
cy.task("noten:db:readLvGesamtnote", {
|
||||
lvId: context.lvId,
|
||||
|
||||
@@ -5,13 +5,10 @@ import { expectNotenSuccess } from "./notenErrors";
|
||||
import { resetNotenState, seedBaseline } from "./notenTestData";
|
||||
|
||||
/**
|
||||
* Adds an attempt (no pruefung_id -> server runs validatePruefungAdd).
|
||||
* Neuer Antritt (ohne pruefung_id -> validatePruefungAdd). Kein typ auf der Leitung.
|
||||
*
|
||||
* No typ: the server derives from the Verlauf which attempt this is. One call writes exactly one
|
||||
* Prüfung - the former Termin1 snapshot is gone, the first attempt is created by the Freigabe.
|
||||
*
|
||||
* punkte stays null: with CIS_GESAMTNOTE_PUNKTE on, a punkte >= 0 makes the controller re-derive
|
||||
* the note from the Notenschlüssel and override the grade under test.
|
||||
* punkte bleibt null: mit CIS_GESAMTNOTE_PUNKTE würde ein punkte >= 0 die Note aus dem
|
||||
* Notenschlüssel neu ableiten und die zu testende Note überschreiben.
|
||||
*/
|
||||
export const addPruefung = (context, student, { note, datum }) =>
|
||||
notenApi.saveStudentPruefung({
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
/**
|
||||
* Runtime discovery + fixture helpers.
|
||||
* Runtime discovery + Fixture-Helfer.
|
||||
*
|
||||
* No hardcoded fixture ids: the rule parameters (maxAntritte, the special note PKs) come from
|
||||
* server-side config that is not in the repo, and Noten.php resolves the special notes by
|
||||
* Bezeichnung. Everything is therefore read back from the API at runtime.
|
||||
* Override via NOTEN_SEM / NOTEN_LV_ID.
|
||||
* Keine fixen Ids: maxAntritte und die Sondernoten-PKs stehen in Server-Config, die nicht im Repo
|
||||
* liegt, und werden daher zur Laufzeit über die API gelesen. Override: NOTEN_SEM / NOTEN_LV_ID.
|
||||
*/
|
||||
|
||||
import { notenApi } from "../api/notenApi";
|
||||
@@ -12,8 +10,10 @@ import { expectNotenSuccess } from "./notenErrors";
|
||||
// the client rule; it now only reads back what the server derived, so a mismatch is a config bug
|
||||
import { maxAntrittCount as computeMaxAntritte } from "../../../../public/js/components/Cis/Benotungstool/notenRules.js";
|
||||
import {
|
||||
describeFailure, performRead, performReset, performSeed, performSeedPruefung, resolveResetStrategy,
|
||||
describeFailure, performRead, performReset, performSeed, performSeedPruefung,
|
||||
performSeedZeugnisnote, resolveResetStrategy,
|
||||
} from "./notenReset";
|
||||
import { assertPunkteModus } from "./notenConfig";
|
||||
|
||||
const BEZ_ENTSCHULDIGT = "entschuldigt";
|
||||
const BEZ_NOCH_NICHT = "Noch nicht eingetragen";
|
||||
@@ -123,6 +123,9 @@ export const loadNotenContext = () => {
|
||||
.then((response) => {
|
||||
context.cisConfig = expectNotenSuccess(response, "getCisConfig");
|
||||
context.maxAntritte = computeMaxAntritte(context.cisConfig);
|
||||
// ein Lauf, der den falschen Konfigurationsmodus erwartet, soll hier scheitern und nicht
|
||||
// alles stillschweigend überspringen
|
||||
assertPunkteModus(context);
|
||||
return notenApi.getNoten();
|
||||
})
|
||||
.then((response) => {
|
||||
@@ -154,6 +157,9 @@ export const loadNotenContext = () => {
|
||||
// administrative note the editor list excludes ("intern angerechnet" / "nicht zugelassen")
|
||||
const nichtLehre = noten.find((n) => n.lehre === false);
|
||||
|
||||
// as a ZEUGNISnote this one locks the LV note, in the client and in the server
|
||||
const nichtUeberschreibbar = noten.find((n) => n.lkt_ueberschreibbar === false);
|
||||
|
||||
// Anrechnungen block every Prüfung for the LV - keyed on the ZEUGNISnote
|
||||
const angerechnet = byBezeichnung("angerechnet");
|
||||
const internAngerechnet = byBezeichnung("intern angerechnet");
|
||||
@@ -162,10 +168,14 @@ export const loadNotenContext = () => {
|
||||
entschuldigt: entschuldigt.note,
|
||||
nochNichtEingetragen: nochNicht.note,
|
||||
nichtLehre: nichtLehre ? nichtLehre.note : null,
|
||||
nichtUeberschreibbar: nichtUeberschreibbar ? nichtUeberschreibbar.note : null,
|
||||
angerechnet: angerechnet ? angerechnet.note : null,
|
||||
internAngerechnet: internAngerechnet ? internAngerechnet.note : null,
|
||||
};
|
||||
|
||||
// die GUI-Specs wählen und lesen über die Bezeichnung, nicht über die PK
|
||||
context.notenOptions = noten;
|
||||
|
||||
return notenApi.getStudentenNoten(context.lvId, context.semKurzbz);
|
||||
})
|
||||
.then((response) => {
|
||||
@@ -189,15 +199,11 @@ export const resetNotenState = (context, studentUids) =>
|
||||
performReset(context, studentUids || context.studentUids);
|
||||
|
||||
/**
|
||||
* Seeds the Antritt-1 baseline: a released LV note PLUS the Prüfung row that represents it.
|
||||
* Baseline für Antritt 1: freigegebene LV-Note PLUS die Prüfungszeile dazu - das ist, was die
|
||||
* Freigabe produziert. Ohne die Zeile zählt die LV-Note nur solange gar kein Termin existiert, und
|
||||
* der erste hinzugefügte Antritt würde Antritt 1 ersetzen statt Antritt 2 zu werden.
|
||||
*
|
||||
* The row is what the production flow produces - since the refactor the first Antritt is created
|
||||
* by the password-gated Freigabe (Noten::upsertErstantritt), not as a side effect of adding a
|
||||
* retake. Without it the LV note only counts while no Prüfung exists at all, so the first added
|
||||
* attempt would silently replace Antritt 1 instead of being Antritt 2.
|
||||
*
|
||||
* `erstantritt: false` seeds the legacy shape (LV note only, no row) for tests that need it.
|
||||
* `freigegeben: false` implies it too: no Freigabe, no first Antritt.
|
||||
* `erstantritt: false` (bzw. `freigegeben: false`) seedet die Altdaten-Form ohne diese Zeile.
|
||||
*/
|
||||
export const seedBaseline = (context, studentUid, options = {}) => {
|
||||
const note = options.note !== undefined ? options.note : context.gradeNotes[0];
|
||||
@@ -233,6 +239,10 @@ export const seedPruefung = (context, student, { note, datum, typ }) =>
|
||||
typ,
|
||||
});
|
||||
|
||||
/** Zeugnisnote setzen. Nur die Studierendenverwaltung schreibt sie, kein Endpunkt dieses Tools. */
|
||||
export const seedZeugnisnote = (context, studentUid, note) =>
|
||||
performSeedZeugnisnote(context, studentUid, { note });
|
||||
|
||||
/** Raw row, bypassing the freigabedatum filter of getLvGesamtNoten. */
|
||||
export const readLvGesamtnote = (context, studentUid) => performRead(context, studentUid);
|
||||
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import { waitForOk } from "../helpers/network";
|
||||
|
||||
/**
|
||||
* Page object für das Benotungstool.
|
||||
*
|
||||
* Selektoren sind data-cy-Attribute aus Benotungstool.js. Die Tabelle ist Tabulator: Zeilen tragen
|
||||
* data-cy="student-row-<uid>", Zellen das von Tabulator gesetzte tabulator-field, und der
|
||||
* Freigabestatus steckt als data-state am Zellinhalt statt am Icon.
|
||||
*
|
||||
* Nach jeder schreibenden Aktion wird auf den zugehörigen Request gewartet (waitForOk), nicht auf
|
||||
* eine DOM-Änderung - sonst prüfen die Specs gegen den Stand vor der Antwort.
|
||||
*/
|
||||
|
||||
const TABLE_TIMEOUT = 60_000;
|
||||
const API = "**/api/frontend/v1/Noten";
|
||||
|
||||
/**
|
||||
* Exakter Textvergleich für Optionslisten. Nötig, weil die Notenbezeichnungen einander enthalten:
|
||||
* contains("Gut") trifft zuerst "Sehr Gut", contains("Genügend") zuerst "Nicht Genügend".
|
||||
*/
|
||||
const exactText = (text) =>
|
||||
new RegExp(`^\\s*${String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`);
|
||||
|
||||
class BenotungstoolPage {
|
||||
selectors = {
|
||||
table: "[data-cy='benotungstool-table']",
|
||||
row: "[data-cy^='student-row-']",
|
||||
};
|
||||
|
||||
setupIntercepts = () => {
|
||||
cy.intercept({ method: "GET", url: `${API}/getStudentenNoten*` }).as("getStudentenNoten");
|
||||
cy.intercept({ method: "GET", url: `${API}/getCisConfig*` }).as("getCisConfig");
|
||||
cy.intercept({ method: "POST", url: `${API}/saveNotenvorschlag` }).as("saveNotenvorschlag");
|
||||
cy.intercept({ method: "POST", url: `${API}/saveNotenvorschlagBulk` }).as("saveNotenvorschlagBulk");
|
||||
cy.intercept({ method: "POST", url: `${API}/saveStudentPruefung` }).as("saveStudentPruefung");
|
||||
cy.intercept({ method: "POST", url: `${API}/createPruefungen` }).as("createPruefungen");
|
||||
cy.intercept({ method: "POST", url: `${API}/savePruefungenBulk` }).as("savePruefungenBulk");
|
||||
cy.intercept({ method: "POST", url: `${API}/saveStudentenNoten` }).as("saveStudentenNoten");
|
||||
cy.intercept({ method: "POST", url: `${API}/getNoteByPunkte` }).as("getNoteByPunkte");
|
||||
};
|
||||
|
||||
/**
|
||||
* Deep-Link auf LV + Semester - die vier Dropdowns müssen dafür nicht bedient werden.
|
||||
* Die Spaltenaufteilung wird vor dem Laden gesetzt: die Komponente liest den localStorage beim
|
||||
* Initialisieren, und vor dem ersten visit gehört er noch about:blank.
|
||||
*/
|
||||
visit = (ctx, { spalten = "antritt" } = {}) =>
|
||||
cy.visit(`/cis.php/Cis/Benotungstool/${ctx.lvId}/${ctx.semKurzbz}`, {
|
||||
onBeforeLoad(win) {
|
||||
win.localStorage.setItem("notenToolPruefungsspalten", spalten);
|
||||
win.localStorage.removeItem("notenToolStickyCols");
|
||||
},
|
||||
});
|
||||
|
||||
visitAndWaitForTable = (ctx, options) => {
|
||||
cy.login();
|
||||
this.setupIntercepts();
|
||||
this.visit(ctx, options);
|
||||
|
||||
waitForOk("@getStudentenNoten");
|
||||
this.getTable().should("be.visible");
|
||||
this.getRows().should("have.length.greaterThan", 0);
|
||||
};
|
||||
|
||||
// --- Grundelemente ---------------------------------------------------------------------------
|
||||
|
||||
getTable = () => cy.get(this.selectors.table, { timeout: TABLE_TIMEOUT });
|
||||
getRows = () => cy.get(this.selectors.row, { timeout: TABLE_TIMEOUT });
|
||||
getRow = (uid) => cy.get(`[data-cy='student-row-${uid}']`, { timeout: TABLE_TIMEOUT });
|
||||
getCell = (uid, field) => this.getRow(uid).find(`[tabulator-field='${field}']`);
|
||||
|
||||
getFreigabeState = (uid) => this.getCell(uid, "freigegeben").find("[data-cy='freigabe-state']");
|
||||
getPruefungCell = (uid, spalte) => this.getCell(uid, spalte).find("[data-cy='pruefung-cell']");
|
||||
getUebernehmenButton = (uid) => this.getCell(uid, "übernehmen").find("[data-cy='btn-uebernehmen']");
|
||||
getPruefungAddButton = (uid, spalte) =>
|
||||
this.getCell(uid, spalte).find("[data-cy='btn-pruefung-add']");
|
||||
getPruefungEditButton = (uid, spalte) =>
|
||||
this.getCell(uid, spalte).find("[data-cy='btn-pruefung-edit']");
|
||||
|
||||
getPruefungModal = () => cy.get("[data-cy='modal-pruefung']");
|
||||
getNeuePruefungModal = () => cy.get("[data-cy='modal-neue-pruefung']");
|
||||
getFreigabeModal = () => cy.get("[data-cy='modal-freigabe']");
|
||||
getNotenImportModal = () => cy.get("[data-cy='modal-noten-import']");
|
||||
getPruefungImportModal = () => cy.get("[data-cy='modal-pruefung-import']");
|
||||
getFreigabeSummaryRow = (uid) => cy.get(`[data-cy='freigabe-row-${uid}']`);
|
||||
|
||||
// --- Zustand prüfen --------------------------------------------------------------------------
|
||||
|
||||
/** offen | changed | ok */
|
||||
expectFreigabeState = (uid, state) =>
|
||||
this.getFreigabeState(uid).should("have.attr", "data-state", state);
|
||||
|
||||
expectLvNote = (uid, bezeichnung) => this.getCell(uid, "lv_note").should("contain.text", bezeichnung);
|
||||
|
||||
expectNotenvorschlag = (uid, bezeichnung) =>
|
||||
this.getCell(uid, "note_vorschlag").should("contain.text", bezeichnung);
|
||||
|
||||
/** centeredTextFormatter prüft auf falsy, eine 0 rendert daher als leere Zelle. */
|
||||
expectAntrittCount = (uid, count) =>
|
||||
Number(count) === 0
|
||||
? this.getCell(uid, "hoechsterAntritt").invoke("text").invoke("trim").should("eq", "")
|
||||
: this.getCell(uid, "hoechsterAntritt").should("contain.text", String(count));
|
||||
|
||||
expectPruefung = (uid, spalte, { note, antritt } = {}) => {
|
||||
// je Assertion neu abfragen, damit Cypress ein Re-Render der Zelle erneut versucht
|
||||
if (note !== undefined) {
|
||||
this.getPruefungCell(uid, spalte).should("have.attr", "data-note", String(note));
|
||||
}
|
||||
if (antritt !== undefined) {
|
||||
this.getPruefungCell(uid, spalte).should("have.attr", "data-attempt", String(antritt));
|
||||
}
|
||||
};
|
||||
|
||||
expectKeinePruefung = (uid, spalte) =>
|
||||
this.getCell(uid, spalte).find("[data-cy='pruefung-cell']").should("not.exist");
|
||||
|
||||
expectKeineAntrittsspalte = (nr) => cy.get(`[tabulator-field='antritt_${nr}']`).should("not.exist");
|
||||
|
||||
// --- Notenvorschlag --------------------------------------------------------------------------
|
||||
|
||||
/** Öffnet den Tabulator-Listeneditor der Vorschlagsspalte und wählt die Bezeichnung. */
|
||||
setNotenvorschlag = (uid, bezeichnung) => {
|
||||
this.getCell(uid, "note_vorschlag").click();
|
||||
cy.get(".tabulator-edit-list-item").contains(bezeichnung).click();
|
||||
};
|
||||
|
||||
uebernehmen = (uid) => {
|
||||
this.getUebernehmenButton(uid).click();
|
||||
waitForOk("@saveNotenvorschlag");
|
||||
};
|
||||
|
||||
// --- Prüfungen -------------------------------------------------------------------------------
|
||||
|
||||
/** PrimeVue hängt seine Panels an den body, daher nicht innerhalb des Modals suchen. */
|
||||
selectDropdownOption = (dataCy, label) => {
|
||||
cy.get(`[data-cy='${dataCy}']`).click();
|
||||
cy.contains(".p-dropdown-panel .p-dropdown-item", exactText(label)).click();
|
||||
};
|
||||
|
||||
setDatum = (dataCy, ddmmyyyy) =>
|
||||
cy.get(`[data-cy='${dataCy}'] input`).first().clear().type(`${ddmmyyyy}{enter}`);
|
||||
|
||||
/** Dialog aus der Tabellenzelle: neuer Antritt für EINEN Studenten. */
|
||||
addPruefungInCell = (uid, spalte, { note, datum } = {}) => {
|
||||
this.getPruefungAddButton(uid, spalte).click();
|
||||
this.getPruefungModal().should("be.visible");
|
||||
|
||||
if (datum) this.setDatum("pruefung-datum", datum);
|
||||
if (note) this.selectDropdownOption("pruefung-note", note);
|
||||
|
||||
cy.get("[data-cy='pruefung-submit']").click();
|
||||
waitForOk("@saveStudentPruefung");
|
||||
this.getPruefungModal().should("not.be.visible");
|
||||
};
|
||||
|
||||
/** Bestehenden Antritt bearbeiten. Ohne `note` bleibt sie unverändert (Datumskorrektur). */
|
||||
editPruefungInCell = (uid, spalte, { note, datum } = {}) => {
|
||||
this.getPruefungEditButton(uid, spalte).click();
|
||||
this.getPruefungModal().should("be.visible");
|
||||
|
||||
if (datum) this.setDatum("pruefung-datum", datum);
|
||||
if (note) this.selectDropdownOption("pruefung-note", note);
|
||||
|
||||
cy.get("[data-cy='pruefung-submit']").click();
|
||||
waitForOk("@saveStudentPruefung");
|
||||
this.getPruefungModal().should("not.be.visible");
|
||||
};
|
||||
|
||||
openPruefungModalForEdit = (uid, spalte) => {
|
||||
this.getPruefungEditButton(uid, spalte).click();
|
||||
this.getPruefungModal().should("be.visible");
|
||||
};
|
||||
|
||||
/** Sammelanlage über die Toolbar, für mehrere Studierende auf einmal. */
|
||||
addPruefungBulk = ({ uids, note, datum }) => {
|
||||
cy.get("[data-cy='btn-neue-pruefung']").click();
|
||||
this.getNeuePruefungModal().should("be.visible");
|
||||
|
||||
if (datum) this.setDatum("neue-pruefung-datum", datum);
|
||||
if (note) this.selectDropdownOption("neue-pruefung-note", note);
|
||||
|
||||
cy.get("[data-cy='neue-pruefung-studenten']").click();
|
||||
// das Label ist "uid – Nachname Vorname – Antritte: n", daher hier bewusst per Teilstring
|
||||
uids.forEach((uid) => cy.contains(".p-multiselect-panel .p-multiselect-item", uid).click());
|
||||
|
||||
// Panel über den Trigger schliessen, NICHT mit Escape: das schliesst das Bootstrap-Modal mit
|
||||
cy.get("[data-cy='neue-pruefung-studenten']").click();
|
||||
cy.get(".p-multiselect-panel").should("not.exist");
|
||||
|
||||
cy.get("[data-cy='neue-pruefung-submit']").click();
|
||||
waitForOk("@createPruefungen");
|
||||
this.getNeuePruefungModal().should("not.be.visible");
|
||||
};
|
||||
|
||||
// --- Freigabe --------------------------------------------------------------------------------
|
||||
|
||||
openFreigabeModal = () => {
|
||||
cy.get("[data-cy='btn-freigabe']").click();
|
||||
this.getFreigabeModal().should("be.visible");
|
||||
};
|
||||
|
||||
expectFreigabeSummaryRow = (uid, releasedBezeichnung) =>
|
||||
this.getFreigabeSummaryRow(uid)
|
||||
.find("[data-cy='freigabe-row-released']")
|
||||
.should("contain.text", releasedBezeichnung);
|
||||
|
||||
typeFreigabePasswort = (password) =>
|
||||
cy.get("[data-cy='freigabe-passwort'] input").type(password, { log: false });
|
||||
|
||||
submitFreigabe = () => cy.get("[data-cy='freigabe-submit']").click();
|
||||
|
||||
freigeben = (password) => {
|
||||
this.typeFreigabePasswort(password);
|
||||
this.submitFreigabe();
|
||||
waitForOk("@saveStudentenNoten");
|
||||
this.getFreigabeModal().should("not.be.visible");
|
||||
};
|
||||
|
||||
// --- Import ----------------------------------------------------------------------------------
|
||||
|
||||
/** rows: [[uid, note], ...] -> "uid<TAB>note" je Zeile */
|
||||
importNoten = (rows) => {
|
||||
cy.get("[data-cy='btn-noten-import']").click();
|
||||
this.getNotenImportModal().should("be.visible");
|
||||
|
||||
cy.get("[data-cy='noten-import-text']").type(rows.map((r) => r.join("\t")).join("\n"));
|
||||
cy.get("[data-cy='noten-import-submit']").click();
|
||||
waitForOk("@saveNotenvorschlagBulk");
|
||||
this.getNotenImportModal().should("not.be.visible");
|
||||
};
|
||||
|
||||
/** rows: [[uid, "dd.MM.yyyy", note], ...] */
|
||||
importPruefungen = (rows) => {
|
||||
cy.get("[data-cy='btn-pruefung-import']").click();
|
||||
this.getPruefungImportModal().should("be.visible");
|
||||
|
||||
cy.get("[data-cy='pruefung-import-text']").type(rows.map((r) => r.join("\t")).join("\n"));
|
||||
cy.get("[data-cy='pruefung-import-submit']").click();
|
||||
waitForOk("@savePruefungenBulk");
|
||||
this.getPruefungImportModal().should("not.be.visible");
|
||||
};
|
||||
|
||||
// --- Punktemodus -----------------------------------------------------------------------------
|
||||
// Die Punktespalte und die beiden Punktefelder in den Dialogen existieren nur mit
|
||||
// CIS_GESAMTNOTE_PUNKTE. Die Note wird dann aus dem Notenschlüssel abgeleitet, nicht gewählt.
|
||||
|
||||
getPunkteCell = (uid) => this.getCell(uid, "punkte");
|
||||
|
||||
/**
|
||||
* Punkte in die Zelle tippen; die Note holt der Client debounced über getNoteByPunkte nach.
|
||||
*
|
||||
* Über cy.focused(): der liveNumberEditor fokussiert sein Input in onRendered, und die Zeile
|
||||
* wird beim Klick neu formatiert (cellClick -> undoSelection), sodass ein zweiter Zugriff über
|
||||
* die Zelle ins Leere greifen kann.
|
||||
*/
|
||||
setPunkteInCell = (uid, punkte) => {
|
||||
this.getPunkteCell(uid).click();
|
||||
|
||||
// Über cy.focused(), weil der liveNumberEditor sein Input in onRendered fokussiert und die
|
||||
// Zeile beim Klick neu formatiert wird. Enter committet den Wert (success() im Editor);
|
||||
// die Note holt der Client danach debounced über getNoteByPunkte nach - deren Rendern
|
||||
// schliesst den Editor, ein Blur danach ginge ins Leere.
|
||||
cy.focused().should("have.attr", "type", "number").clear().type(`${punkte}{enter}`);
|
||||
waitForOk("@getNoteByPunkte");
|
||||
};
|
||||
|
||||
expectPunkte = (uid, punkte) =>
|
||||
this.getPunkteCell(uid).should("contain.text", String(punkte));
|
||||
|
||||
/** Sobald ein Termin existiert, ist die Punktespalte gesperrt (editable-Guard der Spalte). */
|
||||
expectPunkteZelleGesperrt = (uid) => {
|
||||
this.getPunkteCell(uid).click();
|
||||
this.getPunkteCell(uid).find("input").should("not.exist");
|
||||
};
|
||||
|
||||
/** Im Punktemodus ist die Vorschlagsspalte nicht editierbar - die Note kommt aus den Punkten. */
|
||||
expectNotenvorschlagGesperrt = (uid) => {
|
||||
this.getCell(uid, "note_vorschlag").click();
|
||||
cy.get(".tabulator-edit-list").should("not.exist");
|
||||
};
|
||||
|
||||
expectNoteFeldGesperrt = (dataCy) => cy.get(`[data-cy='${dataCy}']`).should("have.class", "p-disabled");
|
||||
|
||||
/** Punktefeld im Einzeldialog; löst die Ableitung der Note aus. */
|
||||
setPruefungPunkte = (punkte) => {
|
||||
cy.get("[data-cy='pruefung-punkte'] input").clear().type(String(punkte));
|
||||
waitForOk("@getNoteByPunkte");
|
||||
};
|
||||
|
||||
/** Punktefeld der Sammelanlage; dort leitet erst der Server beim Speichern ab. */
|
||||
setNeuePruefungPunkte = (punkte) =>
|
||||
cy.get("[data-cy='neue-pruefung-punkte'] input").clear().type(String(punkte));
|
||||
|
||||
// --- Hilfen ----------------------------------------------------------------------------------
|
||||
|
||||
/** Die Bezeichnung zu einer Noten-PK, wie sie in Dropdowns und Zellen steht. */
|
||||
bezeichnungOf = (ctx, note) =>
|
||||
(ctx.notenOptions ?? []).find((n) => String(n.note) === String(note))?.bezeichnung;
|
||||
|
||||
toDDMMYYYY = (isoDate) => {
|
||||
const [y, m, d] = isoDate.split("-");
|
||||
return `${d}.${m}.${y}`;
|
||||
};
|
||||
}
|
||||
|
||||
export const benotungstoolPage = new BenotungstoolPage();
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user