diff --git a/application/config/noten.php b/application/config/noten.php
index 7bdfce1ad..63ad9d911 100644
--- a/application/config/noten.php
+++ b/application/config/noten.php
@@ -2,22 +2,51 @@
if (!defined('BASEPATH')) exit('No direct script access allowed');
-// 'entschuldigt' & 'noch nicht eingetragen' -> wirken sich nicht auf Antritte aus
+// Noten die keinen Prüfungsantritt verbrauchen. Fallback-PKs; massgeblich sind die per Bezeichnung
+// aufgelösten Noten (NOTEN_OHNE_ANTRITT_BEZEICHNUNGEN), da die PKs je Installation abweichen.
$config['NOTEN_OHNE_ANTRITT'] = [9, 17]; // tbl_note pk
-$config['NOTEN_OCCURANCE_LIMIT_MAP'] = [17 => 1]; // across the 4 fixed antritte only one can be entschuldigt
+// 'Nicht beurteilt' ist eine Altlast: formal eine Note, inhaltlich keine Leistungsfeststellung.
+// Wird weiterhin häufig vergeben und darf daher keinen Antritt kosten.
+$config['NOTEN_OHNE_ANTRITT_BEZEICHNUNGEN'] = ['Noch nicht eingetragen', 'entschuldigt', 'Nicht beurteilt'];
+
+// Anrechnungsnoten: die Leistung wurde vorab anerkannt, der/die Studierende ist zwar Teil der
+// Lehrveranstaltung, tritt aber zu keiner Prüfung an. Solange die ZEUGNISNOTE eine davon ist, sind
+// für diese Lehrveranstaltung keine Prüfungen möglich. Auflösung wie oben über die Bezeichnung,
+// die PKs sind nur Fallback.
+$config['NOTEN_ANRECHNUNG_BEZEICHNUNGEN'] = ['angerechnet', 'intern angerechnet'];
+$config['NOTEN_ANRECHNUNG'] = [6, 16]; // tbl_note pk
+
+$config['NOTEN_OCCURANCE_LIMIT_MAP'] = [17 => 1]; // über alle Antritte hinweg nur ein entschuldigt
// tbl_note pk of the 'entschuldigt' note. An entschuldigt Termin is preserved as its own dated
// 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.
+$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'];
+
+// Spaltenaufteilung der Prüfungen in der Notentabelle:
+// 'antritt' - eine Spalte je Antrittsnummer, Datum steht in der Zelle (robust bei Einzelterminen)
+// 'datum' - eine Spalte je Prüfungsdatum (kompakt, wenn ganze Jahrgänge am selben Tag antreten)
+// Nur die Vorgabe; im Tool umschaltbar und pro Benutzer gespeichert.
+$config['CIS_GESAMTNOTE_PRUEFUNGSSPALTEN'] = 'antritt';
+
+// Bei der Notenübernahme durch die Assistenz den ersten Antritt als eigene Prüfung anlegen.
+$config['CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME'] = true;
+
// availability of the two Benotungstool import flows. When both are true they are shown as
// separate buttons/dialogs.
$config['CIS_GESAMTNOTE_PRUEFUNGSIMPORT'] = true; // dated import that creates a pruefung per row
$config['CIS_GESAMTNOTE_NOTENIMPORT'] = false; // classic note-only import (uid + note, no date)
-$config['SHOW_BENOTUNGSDATUM_ON_NOTENVORSCHLAG_UEBERNAHME'] = true;
-
// Noteneintragungsfrist (Prüfungsordnung §1): grade/pruefung entry is only permitted up to this
// deadline. The month/day below is applied to the studiensemester's year:
// Sommersemester (SSyyyy) -> deadline in the SAME calendar year (default 15th November)
diff --git a/application/controllers/api/frontend/v1/Noten.php b/application/controllers/api/frontend/v1/Noten.php
index d2dc0c8ef..c5a60a58f 100644
--- a/application/controllers/api/frontend/v1/Noten.php
+++ b/application/controllers/api/frontend/v1/Noten.php
@@ -47,6 +47,7 @@ class Noten extends FHCAPI_Controller
$this->load->library('AuthLib', null, 'AuthLib');
$this->load->library('PhrasesLib');
+ $this->load->library('PruefungsverlaufLib', null, 'VerlaufLib');
// Loads LogLib with different debug trace levels to get data of the job that extends this class
// It also specify parameters to set database fields
@@ -90,7 +91,7 @@ class Noten extends FHCAPI_Controller
public function getCisConfig() {
// resolved from tbl_note (Bezeichnung) with config fallback -> single source of truth
- $special = $this->resolveSpecialNotes();
+ $special = $this->VerlaufLib->getSpecialNotes();
$NOTEN_OHNE_ANTRITT = $special['ohneAntritt'];
$NOTEN_OCCURANCE_LIMIT_MAP = $special['limitMap'];
$NOTE_ENTSCHULDIGT = $special['entschuldigt'];
@@ -106,14 +107,13 @@ class Noten extends FHCAPI_Controller
// only relevant in punkte calculation in backend
// 'CIS_GESAMTNOTE_GEWICHTUNG' => CIS_GESAMTNOTE_GEWICHTUNG,
- // enables the first in-tool retake (Termin2).
- // Used in the maxAntritte calculation.
- 'CIS_GESAMTNOTE_PRUEFUNG_TERMIN2' => CIS_GESAMTNOTE_PRUEFUNG_TERMIN2,
+ // Maximale Anzahl zählender Antritte in diesem Tool. Serverseitig abgeleitet (explizite
+ // Konfiguration oder Alt-Flags TERMIN2/TERMIN3); der Client rechnet das nicht mehr nach.
+ 'CIS_GESAMTNOTE_MAX_ANTRITTE' => $this->VerlaufLib->getMaxAntritte(),
+
+ // Vorgabe der Spaltenaufteilung ('antritt' | 'datum'), im Tool umschaltbar
+ 'CIS_GESAMTNOTE_PRUEFUNGSSPALTEN' => $this->config->item('CIS_GESAMTNOTE_PRUEFUNGSSPALTEN'),
- // legacy: enables a SECOND in-tool retake (Termin3) for other installations whose ruleset
- // uses three explicit Nachprüfungs-Termine. Each enabled retake type raises the maxAntritte cap.
- 'CIS_GESAMTNOTE_PRUEFUNG_TERMIN3' => CIS_GESAMTNOTE_PRUEFUNG_TERMIN3,
-
// used to toggle availability of kommPruef type pruefungen
'CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF' => CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF,
@@ -142,9 +142,6 @@ class Noten extends FHCAPI_Controller
// pk of the 'entschuldigt' note; used to preserve excused Termine on new pruefung creation
'NOTE_ENTSCHULDIGT' => $NOTE_ENTSCHULDIGT,
- // show the Benotungsdatum of the first Antritt in the "Ursprüngliche Zeugnisnote" column
- 'SHOW_BENOTUNGSDATUM_ON_NOTENVORSCHLAG_UEBERNAHME' => $this->config->item('SHOW_BENOTUNGSDATUM_ON_NOTENVORSCHLAG_UEBERNAHME'),
-
// Noteneintragungsfrist window (enforced server-side; also surfaced so the UI can hint at it)
'CIS_GESAMTNOTE_NOTENEINTRAGUNGSFRIST' => $this->config->item('CIS_GESAMTNOTE_NOTENEINTRAGUNGSFRIST')
)
@@ -429,7 +426,34 @@ class Noten extends FHCAPI_Controller
$pruefungen = $this->LePruefungModel->getPruefungenByLvStudiensemester($lv_id, $sem_kurzbz);
$pruefungenData = getData($pruefungen);
- $this->terminateWithSuccess(array($studentenData, $pruefungenData, DOMAIN, $grades, $anwresult));
+ // 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.
+ $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
+ $zeugnisnoten = [];
+ foreach($studentenData as $s) $zeugnisnoten[$s->uid] = $s->note;
+
+ $pruefungenAbgeleitet = [];
+ foreach(array_unique(array_merge($student_uids, array_keys($proStudent))) as $uid) {
+ $lvNote = isset($grades[$uid]) ? ($grades[$uid]['note_lv'] ?? null) : null;
+ $verlauf = $this->VerlaufLib->buildVerlauf($proStudent[$uid] ?? [], $lvNote, $zeugnisnoten[$uid] ?? null);
+
+ foreach($verlauf->pruefungen as $p) $pruefungenAbgeleitet[] = $p;
+
+ if(isset($grades[$uid])) {
+ $grades[$uid]['verlauf'] = $this->verlaufSummary(
+ $verlauf, false, $this->getLvGesamtnoteRow($lv_id, $uid, $sem_kurzbz) !== null
+ );
+ }
+ }
+
+ $this->terminateWithSuccess(array($studentenData, $pruefungenAbgeleitet, DOMAIN, $grades, $anwresult));
}
/**
@@ -550,6 +574,14 @@ class Noten extends FHCAPI_Controller
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.
+ $this->upsertErstantritt(
+ $lv_id, $lvgesamtnote->student_uid, $sem_kurzbz,
+ $lvgesamtnote->note, $lvgesamtnote->punkte, $lvgesamtnote->freigabedatum
+ );
}
}
@@ -659,9 +691,9 @@ class Noten extends FHCAPI_Controller
* 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.
- * Can return 1 or 2 Prüfungen, since the original grade before the first prüfung is being saved as "Termin1" when
- * a "Termin2" is being created.
+ * 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.
*/
public function saveStudentPruefung() { // einzelne pruefung speichern
$result = $this->getPostJSON();
@@ -681,7 +713,6 @@ class Noten extends FHCAPI_Controller
$pruefung_id = property_exists($result, 'pruefung_id') ? $result->pruefung_id : null;
$stsem = $result->sem_kurzbz;
- $typ = $result->typ;
$this->assertLvAccess($lva_id, $stsem);
@@ -712,91 +743,71 @@ 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);
+
+ // Validierungs- und Schreibfehler kommen als lokalisierte Meldung zurück
+ 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.
+ *
+ * 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
+ */
+ private function savePruefungFuerStudent($pruefung_id, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum)
+ {
// validate the edit before any write: the date must stay between the neighbouring exams and,
- // once a later/higher pruefung exists, the grade may no longer be changed (only the date).
+ // 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)
$editError = $this->validatePruefungEdit($student_uid, $lva_id, $stsem, $note, $datum, $pruefung_id);
- if($editError !== null) {
- $this->terminateWithError($editError, 'general');
- }
+ 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) {
- $this->terminateWithError($addError, 'general');
- }
+ if($addError !== null) return $addError;
}
-
- $this->load->model('education/Lehrveranstaltung_model', 'LehrveranstaltungModel');
- $this->load->model('organisation/Studiengang_model', 'StudiengangModel');
-
- $res = $this->LehrveranstaltungModel->load($lva_id);
- if(isError($res) || !hasData($res)) {
- $this->terminateWithError($this->p->t('benotungstool', 'noValidLvFoundForId', [$lva_id]));
+ // fehlt die lehreinheit_id (der Client liefert sie nicht immer), serverseitig auflösen -
+ // sonst scheitert das Insert stillschweigend am NOT NULL
+ if(!$lehreinheit_id) {
+ $resLe = $this->LehrveranstaltungModel->getLeByStudent($student_uid, $stsem, $lva_id);
+ if(!isError($resLe) && hasData($resLe)) $lehreinheit_id = current(getData($resLe))->lehreinheit_id;
}
- $studiengang_kz = getData($res)[0]->studiengang_kz;
- $res = $this->StudiengangModel->load($studiengang_kz);
- if(isError($res) || !hasData($res)) {
- $this->terminateWithError($this->p->t('benotungstool', 'noValidStudiengangFoundForId', [$studiengang_kz]));
- }
-
+ $jetzt = date("Y-m-d H:i:s");
- $result = $this->LvgesamtnoteModel->getLvGesamtNoten($lva_id, $student_uid, $stsem);
+ // ungefiltert: eine noch nicht freigegebene LV-Note existiert bereits und muss aktualisiert
+ // statt neu angelegt werden (sonst Verstoss gegen den Primärschlüssel)
+ $bestehendeLvNote = $this->getLvGesamtnoteRow($lva_id, $student_uid, $stsem);
- $origLvNote = null;
- $origLvPunkte = null;
- $origBenotungsdatum = null;
-
-// // TODO: check if savePruefungstermin wont throw an error before updating/inserting lv note
- // todo: also check if typ is wrong but another would be available?
-// if(!$this->ableToSavePruefungstermin($typ))
-
- if(!isError($result) && !hasData($result)) {
-
- $id = $this->LvgesamtnoteModel->insert(
- array(
- 'student_uid' => $student_uid,
- 'lehrveranstaltung_id' => $lva_id,
- 'studiensemester_kurzbz' => $stsem,
- 'note' => $note,
- 'punkte' => $punkte,
- 'mitarbeiter_uid' => getAuthUID(),
- 'benotungsdatum' => $jetzt,
- 'freigabedatum' => null,
- 'freigabevon_uid' => null,
- 'bemerkung' => null,
- 'updateamum' => null,
- 'updatevon' => null,
- 'insertamum' => $jetzt,
- 'insertvon' => getAuthUID()
- )
- );
- if($id) {
- $res = $this->LvgesamtnoteModel->load($id->retval);
- if(hasData($res)) $lvgesamtnote = getData($res)[0];
- }
+ // ursprüngliche Note festhalten, bevor sie überschrieben wird: sie entscheidet, ob die
+ // LV-Note als impliziter erster Antritt zählt
+ $origLvNote = $bestehendeLvNote ? $bestehendeLvNote->note : null;
+ $lvgesamtnote = $bestehendeLvNote;
- $this->logLib->logInfoDB(array('saveStudentPruefung insert lvnote', $res, array(
- 'insertvon' => getAuthUID(),
- 'insertamum' => date('Y-m-d H:i:s')
- ), getAuthUID(), getAuthPersonId(), $student_uid, $lva_id, $stsem, $note,$punkte, $jetzt));
+ if($bestehendeLvNote === null) {
+ $lvgesamtnote = $this->createLvGesamtnote($lva_id, $student_uid, $stsem, $note, $punkte);
- }
- else if(!isError($result) && hasData($result))
- {
- $lvgesamtnote = getData($result)[0];
-
- $orig = getData($result)[0];
- $origLvNote = $lvgesamtnote->note;
- $origLvPunkte = $lvgesamtnote->punkte;
- $origBenotungsdatum = $lvgesamtnote->benotungsdatum;
-
+ $this->logLib->logInfoDB(array('pruefung: lvnote angelegt', $student_uid, $lva_id, $stsem,
+ $note, $punkte, getAuthUID(), getAuthPersonId()));
+ } else {
$id = $this->LvgesamtnoteModel->update(
- [$lvgesamtnote->student_uid, $lvgesamtnote->studiensemester_kurzbz, $lvgesamtnote->lehrveranstaltung_id],
+ [$bestehendeLvNote->student_uid, $bestehendeLvNote->studiensemester_kurzbz, $bestehendeLvNote->lehrveranstaltung_id],
array(
'note' => $note,
'punkte' => $punkte,
@@ -810,42 +821,43 @@ class Noten extends FHCAPI_Controller
$res = $this->LvgesamtnoteModel->load($id->retval);
if(hasData($res)) $lvgesamtnote = getData($res)[0];
}
-
- $this->logLib->logInfoDB(array('saveStudentPruefung update lvnote', $res, array(
- 'updatevon' => getAuthUID(),
- 'updateamum' => date('Y-m-d H:i:s')
- ), getAuthUID(), getAuthPersonId(), $student_uid, $lva_id, $stsem, $note,$punkte, $jetzt));
+
+ $this->logLib->logInfoDB(array('pruefung: lvnote aktualisiert', $student_uid, $lva_id, $stsem,
+ $note, $punkte, getAuthUID(), getAuthPersonId()));
}
-
- // save pruefung after updating lvnote, since pruefungspunkte get loaded by lv punkte
- $pruefungenChanged = $this->savePruefungstermin($typ, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum, $origLvNote, $origLvPunkte, $origBenotungsdatum);
- $savedPruefung = $pruefungenChanged['savedPruefung'] ?? null;
- $extraPruefung = $pruefungenChanged['extraPruefung'] ?? null;
+ // 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);
+ if(is_string($pruefungenChanged)) return $pruefungenChanged;
- $savedPruefungData = count($savedPruefung) > 0 ? $savedPruefung[0] : null;
- $extraPruefungData = count($extraPruefung) > 0 ? $extraPruefung[0] : null;
-
- $this->terminateWithSuccess(array($savedPruefungData, $lvgesamtnote, $extraPruefungData));
+ $pruefungenChanged['lvgesamtnote'] = $lvgesamtnote;
+
+ return $pruefungenChanged;
}
/**
- * private helper method to update/insert pruefungstermine
+ * Legt einen Prüfungstermin an oder aktualisiert einen bestehenden.
+ *
+ * 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
*/
- private function savePruefungstermin($typ, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum, $origLvNote = null, $origLvPunkte = null, $origBenotungsdatum = null)
+ private function savePruefungstermin($pruefung_id, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum, $origLvNote = null)
{
+ // allocating pruefungen before lv note is forbidden (ungefiltert: auch eine noch nicht
+ // freigegebene LV-Note zählt als vorhanden, siehe getLvGesamtnoteRow)
+ if($this->getLvGesamtnoteRow($lva_id, $student_uid, $stsem) === null) {
+ return $this->p->t('benotungstool', 'c4keineLvNoteEingetragen');
+ }
- // extra check if the student has lvnote and a zeugnisnote in the relevant lva
- $resultLV = $this->LvgesamtnoteModel->getLvGesamtNoten($lva_id, $student_uid, $stsem);
- $lvgesamtnoteData = getData($resultLV);
-// $this->addMeta('lvgesamtnoteData', $lvgesamtnoteData);
-
- // allocating pruefungen before lv note is forbidden
- if($lvgesamtnoteData == null) return $this->p->t('benotungstool', 'c4keineLvNoteEingetragen');
+ $pruefungen = $this->VerlaufLib->getPruefungen($student_uid, $lva_id, $stsem);
$status = [];
-
- // TODO: config here?
+
// send $grades reference to moodle addon
Events::trigger(
'getEntschuldigungsStatusForStudentOnDate',
@@ -858,261 +870,151 @@ class Noten extends FHCAPI_Controller
'datum' => $datum
]
);
-
+
if(count($status) > 0 && $status[0] == true) {
- $entschuldigtNote = $this->resolveSpecialNotes()['entschuldigt'];
+ $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
- if($entschuldigtNote !== null) {
- $allRes = $this->LePruefungModel->getPruefungenByUidTypLvStudiensemester($student_uid, null, $lva_id, $stsem);
- $existing = (!isError($allRes) && hasData($allRes)) ? getData($allRes) : [];
- if(!$this->exceedsNoteOccuranceLimit($existing, $entschuldigtNote, null)) {
- $note = $entschuldigtNote;
- }
+ if($entschuldigtNote !== null
+ && !$this->VerlaufLib->ueberschreitetNotenLimit($pruefungen, $entschuldigtNote, $pruefung_id)) {
+ $note = $entschuldigtNote;
}
}
-
+
$jetzt = date("Y-m-d H:i:s");
-
+
$pruefungenChanged = [];
-
- if($typ == "Termin2" && defined('CIS_GESAMTNOTE_PRUEFUNG_TERMIN2') && CIS_GESAMTNOTE_PRUEFUNG_TERMIN2)
- {
-
- // Wenn eine Nachprüfung angelegt wird, wird zuerst eine Pruefung mit 1. Termin angelegt welche für die ursprüngliche Note
- // vor den Prüfungsantritten zählt
-
- $result1 = $this->LePruefungModel->getPruefungenByUidTypLvStudiensemester($student_uid, "Termin1", $lva_id, $stsem);
-
- // if there is a termin 1 entry already do nothing
- if(!isError($result1) && hasData($result1)) {
- } else if(!isError($result1) && !hasData($result1)) {
- // new entry termin1
-
- $resultLV = $this->LvgesamtnoteModel->getLvGesamtNoteVorschlag($lva_id, $student_uid, $stsem);
-
- // update Termin1 note
- if ($origLvNote !== null) {
- $pr_note = $origLvNote;
- $pr_punkte = $origLvPunkte;
- $benotungsdatum = $origBenotungsdatum ?? $jetzt;
- }
- else if (hasData($resultLV))
- {
- $lvgesamtnote = getData($resultLV)[0];
- $pr_note = $lvgesamtnote->note;
- $pr_punkte = $lvgesamtnote->punkte;
- $benotungsdatum = $lvgesamtnote->benotungsdatum;
- }
- else if(!hasData($resultLV))// set Termin1 note to "noch nicht eingetragen"
- {
- $this->load->model('education/Note_model', 'NoteModel');
- $result = $this->NoteModel->getNochNichtEingetragenNote();
- $pr_note = getData($result)[0]->note;
- $pr_punkte = '';
- $benotungsdatum = $jetzt;
- }
-
- $id = $this->LePruefungModel->insert(
- array(
- 'lehreinheit_id' => $lehreinheit_id,
- 'student_uid' => $student_uid,
- 'mitarbeiter_uid' => getAuthUID(),
- 'note' => $pr_note,
- 'punkte' => $pr_punkte,
- 'pruefungstyp_kurzbz' => "Termin1",
- 'datum' => $benotungsdatum,
- 'anmerkung' => "",
- 'insertamum' => $jetzt,
- 'insertvon' => getAuthUID(),
- 'updateamum' => null,
- 'updatevon' => null,
- 'ext_id' => null
- )
- );
- if($id) {
- $res = $this->LePruefungModel->load($id->retval);
- if(hasData($res)) $pruefungenChanged['extraPruefung'] = getData($res);
- }
-
- $this->logLib->logInfoDB(array('termin1 created',$res, getAuthUID(), getAuthPersonId()));
-
+ // Bearbeitung: der adressierte Datensatz wird geändert, seine Position im Verlauf ergibt
+ // sich danach wieder aus dem Datum. Kein Typwechsel, keine Neuanlage.
+ if($pruefung_id !== null && $pruefung_id !== '') {
+ $id = $this->LePruefungModel->update(
+ $pruefung_id,
+ array(
+ 'updateamum' => $jetzt,
+ 'updatevon' => getAuthUID(),
+ 'note' => $note,
+ 'punkte' => $punkte,
+ 'datum' => $datum,
+ 'anmerkung' => ""
+ )
+ );
+ if($id) {
+ $res = $this->LePruefungModel->load($id->retval);
+ if(hasData($res)) $pruefungenChanged['savedPruefung'] = getData($res);
}
-
+ $this->logLib->logInfoDB(array('pruefung updated', $res, getAuthUID(), getAuthPersonId()));
-
- // Die Pruefung wird als Termin2 eingetragen.
- // Ein bestehender "entschuldigt"-Termin2 bleibt erhalten: in diesem Fall wird die neue
- // Pruefung als eigener Datensatz angelegt statt den entschuldigten zu ueberschreiben.
- $result2 = $this->LePruefungModel->getPruefungenByUidTypLvStudiensemester($student_uid, "Termin2", $lva_id, $stsem);
-
- $termin2 = null;
- if(!isError($result2) && hasData($result2)) {
- foreach(getData($result2) as $t2) {
- if(!$this->isEntschuldigtNote($t2->note)) { $termin2 = $t2; break; }
- }
+ if(!isset($pruefungenChanged['savedPruefung'])) {
+ return $this->p->t('benotungstool', 'c4pruefungNichtGespeichert', [$student_uid]);
}
- if($termin2 !== null) {
- // update existing (non-excused) Termin2
- $id = $this->LePruefungModel->update(
- $termin2->pruefung_id,
- array(
- 'updateamum' => $jetzt,
- 'updatevon' => getAuthUID(),
- 'note' => $note,
- 'punkte' => $punkte,
- 'datum' => $datum,
- 'anmerkung' => ""
- )
- );
- if($id) {
- $res = $this->LePruefungModel->load($id->retval);
- if(hasData($res)) $pruefungenChanged['savedPruefung'] = getData($res);
- }
-
- $this->logLib->logInfoDB(array('termin2 updated',$res, getAuthUID(), getAuthPersonId()));
-
-
- } else if(!isError($result2)) {
- // no editable Termin2 (none yet, or only an entschuldigt one) -> insert new, excused stays
-
- $id = $this->LePruefungModel->insert(
- array(
- 'lehreinheit_id' => $lehreinheit_id,
- 'student_uid' => $student_uid,
- 'mitarbeiter_uid' => getAuthUID(),
- 'note' => $note,
- 'punkte' => $punkte,
- 'pruefungstyp_kurzbz' => $typ,
- 'datum' => $datum,
- 'anmerkung' => "",
- 'insertamum' => $jetzt,
- 'insertvon' => getAuthUID(),
- 'updateamum' => null,
- 'updatevon' => null,
- 'ext_id' => null
- )
- );
- if($id) {
- $res = $this->LePruefungModel->load($id->retval);
- if(hasData($res)) $pruefungenChanged['savedPruefung'] = getData($res);
- }
-
- $this->logLib->logInfoDB(array('termin2 inserted',$res, getAuthUID(), getAuthPersonId()));
-
- }
-
- }
- else if($typ == "Termin3" && defined('CIS_GESAMTNOTE_PRUEFUNG_TERMIN3') && CIS_GESAMTNOTE_PRUEFUNG_TERMIN3)
- {
- // same entschuldigt-preservation handling as Termin2
- $result3 = $this->LePruefungModel->getPruefungenByUidTypLvStudiensemester($student_uid, "Termin3", $lva_id, $stsem);
-
- $termin3 = null;
- if(!isError($result3) && hasData($result3)) {
- foreach(getData($result3) as $t3) {
- if(!$this->isEntschuldigtNote($t3->note)) { $termin3 = $t3; break; }
- }
- }
-
- if($termin3 !== null) {
- // update existing (non-excused) Termin3
- $id = $this->LePruefungModel->update(
- $termin3->pruefung_id,
- array(
- 'updateamum' => $jetzt,
- 'updatevon' => getAuthUID(),
- 'note' => $note,
- 'punkte' => $punkte,
- 'datum' => $datum,
- 'anmerkung' => ""
- )
- );
- if($id) {
- $res = $this->LePruefungModel->load($id->retval);
- if(hasData($res)) $pruefungenChanged['savedPruefung'] = getData($res);
- }
-
- $this->logLib->logInfoDB(array('termin3 updated',$res, getAuthUID(), getAuthPersonId()));
-
- } else if(!isError($result3)) {
- // no editable Termin3 (none yet, or only an entschuldigt one) -> insert new, excused stays
-
- $id = $this->LePruefungModel->insert(
- array(
- 'lehreinheit_id' => $lehreinheit_id,
- 'student_uid' => $student_uid,
- 'mitarbeiter_uid' => getAuthUID(),
- 'note' => $note,
- 'punkte' => $punkte,
- 'pruefungstyp_kurzbz' => $typ,
- 'datum' => $datum,
- 'anmerkung' => "",
- 'insertamum' => $jetzt,
- 'insertvon' => getAuthUID(),
- 'updateamum' => null,
- 'updatevon' => null,
- 'ext_id' => null
- )
- );
- if($id) {
- $res = $this->LePruefungModel->load($id->retval);
- if(hasData($res)) $pruefungenChanged['savedPruefung'] = getData($res);
- }
-
- $this->logLib->logInfoDB(array('termin3 inserted',$res, getAuthUID(), getAuthPersonId()));
-
- }
+ $pruefungenChanged['verlauf'] = $this->buildVerlaufSummary($student_uid, $lva_id, $stsem);
+ return $pruefungenChanged;
}
- else {
- $this->terminateWithError($this->p->t('benotungstool', 'wrongPruefungType', [$student_uid, $typ]), 'general');
+
+ $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.
+
+ $typ = ($rolle === PruefungsverlaufLib::ROLLE_ERSTANTRITT)
+ ? $this->VerlaufLib->legacyTypFuerAntritt(1)
+ : $this->VerlaufLib->legacyTypFuerWiederholung($verlauf);
+
+ $id = $this->LePruefungModel->insert(
+ array(
+ 'lehreinheit_id' => $lehreinheit_id,
+ 'student_uid' => $student_uid,
+ 'mitarbeiter_uid' => getAuthUID(),
+ 'note' => $note,
+ 'punkte' => $punkte,
+ 'pruefungstyp_kurzbz' => $typ,
+ 'datum' => $datum,
+ 'anmerkung' => "",
+ 'insertamum' => $jetzt,
+ 'insertvon' => getAuthUID(),
+ 'updateamum' => null,
+ 'updatevon' => null,
+ 'ext_id' => null
+ )
+ );
+ if($id) {
+ $res = $this->LePruefungModel->load($id->retval);
+ if(hasData($res)) $pruefungenChanged['savedPruefung'] = getData($res);
}
-
+
+ $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.
+ if(!isset($pruefungenChanged['savedPruefung'])) {
+ return $this->p->t('benotungstool', 'c4pruefungNichtGespeichert', [$student_uid]);
+ }
+
+ $pruefungenChanged['verlauf'] = $this->buildVerlaufSummary($student_uid, $lva_id, $stsem);
return $pruefungenChanged;
}
/**
- * ranking of the pruefung attempt types, used to detect whether a "höhere"
- * (later attempt) pruefung exists for a student
+ * 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.
*/
- private function pruefungAttemptRank($typ)
+ private function buildVerlaufSummary($student_uid, $lva_id, $stsem)
{
- switch($typ) {
- case 'Termin1': return 1;
- case 'Termin2': return 2;
- case 'Termin3': return 3;
- case 'kommPruef': return 4;
- default: return 0;
- }
+ $lvNote = $this->getLvGesamtnoteRow($lva_id, $student_uid, $stsem);
+ $verlauf = $this->VerlaufLib->getVerlauf(
+ $student_uid, $lva_id, $stsem,
+ $lvNote ? $lvNote->note : null,
+ $this->getZeugnisnote($lva_id, $student_uid, $stsem)
+ );
+
+ return $this->verlaufSummary($verlauf, true, $lvNote !== null);
}
/**
- * whether a note is the configured 'entschuldigt' note. Such Termine are preserved as their
- * own dated entry instead of being overwritten when a new pruefung of the same type is created.
+ * @param bool $withPruefungen Termine mitliefern (Schreibpfade) oder nur die Kennzahlen
+ * @return array
*/
- private function isEntschuldigtNote($note)
+ private function verlaufSummary($verlauf, $withPruefungen = false, $hatLvNote = null)
{
- $entschuldigt = $this->resolveSpecialNotes()['entschuldigt'];
- return $entschuldigt !== null && $note == $entschuldigt;
+ $summary = array(
+ 'antrittCount' => $verlauf->antrittCount,
+ 'maxAntritte' => $verlauf->maxAntritte,
+ 'canAdd' => $verlauf->canAdd,
+ '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
+ '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
+ );
+
+ if($withPruefungen) $summary['pruefungen'] = $verlauf->pruefungen;
+
+ return $summary;
}
/**
- * Validates an edit to an existing Termin2/Termin3 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.
+ * 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.
*
* Rules:
* - The new $datum must stay strictly between the dates of the chronologically adjacent
* pruefungen so the attempt order is preserved.
- * - Once a later-dated or higher-attempt pruefung already exists the grade may no longer be
- * changed (only the datum may be corrected within the bounds above).
+ * - 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) nothing is restricted here (adds are validated client-side).
+ * 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
@@ -1126,43 +1028,44 @@ class Noten extends FHCAPI_Controller
{
if($pruefung_id === null || $pruefung_id === '') return null; // add, not an edit
- $result = $this->LePruefungModel->getPruefungenByUidTypLvStudiensemester($student_uid, null, $lva_id, $stsem);
- if(isError($result) || !hasData($result)) return null;
+ // 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]);
+ }
- $pruefungen = getData($result);
+ $pruefungen = $this->VerlaufLib->getPruefungen($student_uid, $lva_id, $stsem);
+ if(count($pruefungen) === 0) return null;
+
+ $verlauf = $this->VerlaufLib->buildVerlauf($pruefungen, null, $zeugnisNote);
// the record being edited
$current = null;
- foreach($pruefungen as $p) {
+ foreach($verlauf->pruefungen as $p) {
if($p->pruefung_id == $pruefung_id) { $current = $p; break; }
}
if($current === null) return null;
- // only the changeable resit grades are guarded
- if($current->pruefungstyp_kurzbz !== 'Termin2' && $current->pruefungstyp_kurzbz !== 'Termin3') return null;
+ // abschliessende Termine (kommPruef) werden in einem anderen Tool gepflegt
+ if($current->terminal) return null;
- $currentRank = $this->pruefungAttemptRank($current->pruefungstyp_kurzbz);
$currentDate = substr((string)$current->datum, 0, 10);
$new = substr((string)$newDatum, 0, 10);
- // chronological bounds from the immediate date-neighbours + detect later/higher pruefung
- $lower = null; $upper = null; $hasLaterOrHigher = false;
- foreach($pruefungen as $p) {
+ // chronologische Grenzen aus den unmittelbaren Datums-Nachbarn
+ $lower = null; $upper = null;
+ foreach($verlauf->pruefungen as $p) {
if($p->pruefung_id == $current->pruefung_id) continue;
$d = substr((string)$p->datum, 0, 10);
- if($d !== '') {
- if($d < $currentDate) { if($lower === null || $d > $lower) $lower = $d; }
- elseif($d > $currentDate) { if($upper === null || $d < $upper) $upper = $d; }
- }
+ if($d === '') continue;
- if($d > $currentDate || $this->pruefungAttemptRank($p->pruefungstyp_kurzbz) > $currentRank) {
- $hasLaterOrHigher = true;
- }
+ if($d < $currentDate) { if($lower === null || $d > $lower) $lower = $d; }
+ elseif($d > $currentDate) { if($upper === null || $d < $upper) $upper = $d; }
}
- // grade is locked once a later/higher pruefung exists
- if($hasLaterOrHigher && $newNote != $current->note) {
+ // grade is locked once a later attempt exists
+ if($this->VerlaufLib->hatSpaeterenTermin($verlauf, $current->pruefung_id) && $newNote != $current->note) {
return $this->p->t('benotungstool', 'pruefungNoteLocked', [$student_uid]);
}
@@ -1172,7 +1075,7 @@ class Noten extends FHCAPI_Controller
}
// an edit may also change the note (e.g. to 'entschuldigt') -> keep the occurrence limit
- if($this->exceedsNoteOccuranceLimit($pruefungen, $newNote, $current->pruefung_id)) {
+ if($this->VerlaufLib->ueberschreitetNotenLimit($verlauf->pruefungen, $newNote, $current->pruefung_id)) {
return $this->p->t('benotungstool', 'noteOccuranceLimitReached', [$student_uid]);
}
@@ -1181,10 +1084,11 @@ 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..
+ * allowed, or null if it is.
*
* Rules (Prüfungsordnung §1):
- * - Rule A: the number of counting Prüfungsantritte may not exceed the configured maximum
+ * - 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.
*
@@ -1197,17 +1101,24 @@ class Noten extends FHCAPI_Controller
*/
private function validatePruefungAdd($student_uid, $lva_id, $stsem, $note, $datum)
{
- $result = $this->LePruefungModel->getPruefungenByUidTypLvStudiensemester($student_uid, null, $lva_id, $stsem);
- $pruefungen = (!isError($result) && hasData($result)) ? getData($result) : [];
+ // 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]);
+ }
+
+ $pruefungen = $this->VerlaufLib->getPruefungen($student_uid, $lva_id, $stsem);
// current LV note (the implicit first Antritt), read before it gets mutated by the caller
- $lvNote = null;
- $resLv = $this->LvgesamtnoteModel->getLvGesamtNoten($lva_id, $student_uid, $stsem);
- if(!isError($resLv) && hasData($resLv)) $lvNote = getData($resLv)[0]->note;
+ $lvRow = $this->getLvGesamtnoteRow($lva_id, $student_uid, $stsem);
+ $lvNote = $lvRow ? $lvRow->note : null;
- // Rule A: max Antritte
- if($this->computeAntrittCount($pruefungen, $lvNote) >= $this->computeMaxAntritte()) {
- return $this->p->t('benotungstool', 'maxAntritteReached', [$student_uid, $this->computeMaxAntritte()]);
+ $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.
+ 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
@@ -1219,131 +1130,14 @@ class Noten extends FHCAPI_Controller
}
}
- // Rule C: occurrence limit (e.g. only one 'entschuldigt' across the fixed Antritte)
- if($this->exceedsNoteOccuranceLimit($pruefungen, $note, null)) {
+ // Rule C: occurrence limit (e.g. only one 'entschuldigt' across the Antritte)
+ if($this->VerlaufLib->ueberschreitetNotenLimit($pruefungen, $note, null)) {
return $this->p->t('benotungstool', 'noteOccuranceLimitReached', [$student_uid]);
}
return null;
}
- /**
- * Number of counting Prüfungsantritte for a student, mirroring the frontend getAntrittCountStudent:
- * a kommPruef caps the count at 4, notes in NOTEN_OHNE_ANTRITT (entschuldigt / noch nicht
- * eingetragen) do not count, and when no pruefung counts yet the original LV note counts as the
- * first Antritt (unless it is a non-lehre note such as 'angerechnet').
- */
- private function computeAntrittCount($pruefungen, $lvNote)
- {
- $notenOhneAntritt = $this->resolveSpecialNotes()['ohneAntritt'];
-
- foreach($pruefungen as $p) {
- if($p->pruefungstyp_kurzbz == 'kommPruef') return 4;
- }
-
- $count = 0;
- foreach($pruefungen as $p) {
- if(!in_array($p->note, $notenOhneAntritt)) $count++;
- }
-
- if($count === 0 && $lvNote !== null && $this->isLehreNote($lvNote)) return 1;
-
- return $count;
- }
-
- /**
- * Maximum number of counting Prüfungsantritte enterable IN THIS TOOL, mirroring the frontend
- * maxAntrittCount: the original note (always 1) plus each enabled in-tool retake type (Termin2 and
- * the legacy Termin3). kommPruef is the terminal attempt, entered elsewhere, and is intentionally
- * not part of this cap. For our installation (Termin2 on, Termin3 off) this is 2; excused Termine do
- * not count (see computeAntrittCount), which is what lets Termin2 occur twice within the cap.
- */
- private function computeMaxAntritte()
- {
- $max = 1;
- if(defined('CIS_GESAMTNOTE_PRUEFUNG_TERMIN2') && CIS_GESAMTNOTE_PRUEFUNG_TERMIN2) $max++;
- if(defined('CIS_GESAMTNOTE_PRUEFUNG_TERMIN3') && CIS_GESAMTNOTE_PRUEFUNG_TERMIN3) $max++;
- return $max;
- }
-
- /**
- * Whether the given note is flagged as a 'lehre' note in tbl_note.
- */
- private function isLehreNote($note)
- {
- $res = $this->NoteModel->load($note);
- if(isError($res) || !hasData($res)) return false;
- return (bool) getData($res)[0]->lehre;
- }
-
- /**
- * Single source of truth for the "special" note PKs (entschuldigt & noch nicht eingetragen).
- * tbl_note is resolved by Bezeichnung (install-independent) and takes precedence; the hardcoded
- * config PKs (NOTE_ENTSCHULDIGT / NOTEN_OHNE_ANTRITT) are only a fallback. This removes the second
- * source of truth so the entschuldigt-preservation and Antritt-counting logic can't disagree with
- * the Bezeichnung lookups on installs whose PKs differ. Cached per request.
- *
- * @return array{entschuldigt: mixed, ohneAntritt: array, limitMap: array}
- */
- private function resolveSpecialNotes()
- {
- static $cache = null;
- if($cache !== null) return $cache;
-
- $cfgEntschuldigt = $this->config->item('NOTE_ENTSCHULDIGT');
- $cfgOhneAntritt = $this->config->item('NOTEN_OHNE_ANTRITT');
- $cfgLimitMap = $this->config->item('NOTEN_OCCURANCE_LIMIT_MAP');
- if(!is_array($cfgOhneAntritt)) $cfgOhneAntritt = [];
- if(!is_array($cfgLimitMap)) $cfgLimitMap = [];
-
- // resolve by Bezeichnung, fall back to the config PKs
- $resEnt = $this->NoteModel->getEntschuldigtNote();
- $entschuldigt = (!isError($resEnt) && hasData($resEnt)) ? getData($resEnt)[0]->note : $cfgEntschuldigt;
-
- $resNn = $this->NoteModel->getNochNichtEingetragenNote();
- $nochNicht = (!isError($resNn) && hasData($resNn)) ? getData($resNn)[0]->note : ($cfgOhneAntritt[0] ?? null);
-
- $ohneAntritt = array_values(array_filter([$nochNicht, $entschuldigt], function($v){ return $v !== null; }));
-
- // re-key the configured entschuldigt limit onto the resolved PK (keep any other entries as-is)
- $limitMap = [];
- foreach($cfgLimitMap as $k => $v) {
- $limitMap[($k == $cfgEntschuldigt) ? $entschuldigt : $k] = $v;
- }
-
- $cache = [
- 'entschuldigt' => $entschuldigt,
- 'ohneAntritt' => $ohneAntritt,
- 'limitMap' => $limitMap
- ];
- return $cache;
- }
-
- /**
- * Check for configured occurrence limit (NOTEN_OCCURANCE_LIMIT_MAP) among the given pruefungen.
- * $excludePruefungId skips the record currently being edited
- * Noten without a configured limit never exceed.
- */
- private function exceedsNoteOccuranceLimit($pruefungen, $note, $excludePruefungId = null)
- {
- $limitMap = $this->resolveSpecialNotes()['limitMap'];
- if(!is_array($limitMap)) return false;
-
- $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;
- }
-
/**
* 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
@@ -1568,22 +1362,22 @@ class Noten extends FHCAPI_Controller
/**
* POST METHOD
- * expects 'uids', 'datum'
- * Bulk variant of saveStudentPruefung, used when creating a new Prüfung for several students. Always sets note to
- * "noch nicht eingetragen" for the created Prüfung.
+ * 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.
*/
public function createPruefungen() {
- $result = $this->getPostJSON();
+ $payload = $this->getPostJSON();
- if(!property_exists($result, 'uids') || !property_exists($result, 'datum')) {
+ if(!property_exists($payload, 'uids') || !property_exists($payload, 'datum')) {
$this->terminateWithError($this->p->t('global', 'missingParameters'), 'general');
}
- $uids = $result->uids;
- $datum = $result->datum;
- $lva_id = $result->lva_id;
+ $uids = $payload->uids;
+ $datum = $payload->datum;
+ $lva_id = $payload->lva_id;
- $stsem = $result->sem_kurzbz;
+ $stsem = $payload->sem_kurzbz;
$this->assertLvAccess($lva_id, $stsem);
@@ -1592,25 +1386,28 @@ class Noten extends FHCAPI_Controller
$ret = [];
- $this->load->model('education/Note_model', 'NoteModel');
- $result = $this->NoteModel->getNochNichtEingetragenNote();
- $note = getData($result)[0]->note;
+ $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
+ 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
+ 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.
foreach ($uids as $student) {
- $student_uid = $student->uid;
- $typ = $student->typ;
- $punkte = null; // new pruefungen never have punkte,
-
- $lehreinheit_id = $student->lehreinheit_id;
-
- // server-side add guards (max Antritte, chronological order, occurrence limit)
- $addError = $this->validatePruefungAdd($student_uid, $lva_id, $stsem, $note, $datum);
- if($addError !== null) {
- $ret[$student->uid] = $addError; // string result is surfaced per-student in the UI
- continue;
- }
-
- $ret[$student->uid] = $this->savePruefungstermin($typ, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum);
+ $ret[$student->uid] = $this->savePruefungFuerStudent(
+ null, $student->uid, $lva_id, $stsem, $student->lehreinheit_id, $note, $punkte, $datum
+ );
}
$this->logLib->logInfoDB(array('createPruefungen',$ret, getAuthUID(), getAuthPersonId()));
@@ -1651,22 +1448,11 @@ class Noten extends FHCAPI_Controller
// $this->addMeta($pruefung->uid."note", $pruefung->note);
}
- $student_uid = $pruefung->uid;
- $typ = $pruefung->typ;
- $note = $pruefung->note; // TODO: parameterize for import maybe
- $datum = $pruefung->datum;
- $punkte = $pruefung->punkte;
-
- $lehreinheit_id = $pruefung->lehreinheit_id;
-
- // server-side add guards (max Antritte, chronological order, occurrence limit)
- $addError = $this->validatePruefungAdd($student_uid, $lv_id, $sem_kurzbz, $note, $datum);
- if($addError !== null) {
- $ret[$student_uid] = $addError; // string result is surfaced per-student in the UI
- continue;
- }
-
- $ret[$student_uid] = $this->savePruefungstermin($typ, $student_uid, $lv_id, $sem_kurzbz, $lehreinheit_id, $note, $punkte, $datum);
+ // identisch zum Dialog aus der Tabelle, nur je Importzeile
+ $ret[$pruefung->uid] = $this->savePruefungFuerStudent(
+ null, $pruefung->uid, $lv_id, $sem_kurzbz, $pruefung->lehreinheit_id,
+ $pruefung->note, $pruefung->punkte, $pruefung->datum
+ );
}
$this->logLib->logInfoDB(array('savePruefungenBulk',$ret, getAuthUID(), getAuthPersonId()));
@@ -1674,6 +1460,138 @@ class Noten extends FHCAPI_Controller
$this->terminateWithSuccess($ret);
}
+ /**
+ * 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.
+ */
+ /**
+ * Legt den ersten Prüfungstermin an oder aktualisiert ihn (Upsert).
+ *
+ * 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
+ */
+ private function upsertErstantritt($lva_id, $student_uid, $stsem, $note, $punkte, $datum)
+ {
+ if($this->VerlaufLib->istAnrechnungsnote($this->getZeugnisnote($lva_id, $student_uid, $stsem))) return;
+
+ $pruefungen = $this->VerlaufLib->getPruefungen($student_uid, $lva_id, $stsem);
+ if(count($pruefungen) > 1) return;
+
+ $jetzt = date("Y-m-d H:i:s");
+
+ if(count($pruefungen) === 1) {
+ $this->LePruefungModel->update(
+ $pruefungen[0]->pruefung_id,
+ array(
+ 'note' => $note,
+ 'punkte' => $punkte,
+ 'datum' => $datum,
+ 'updateamum' => $jetzt,
+ 'updatevon' => getAuthUID()
+ )
+ );
+
+ $this->logLib->logInfoDB(array('erstantritt aktualisiert (freigabe)', $student_uid, getAuthUID(), getAuthPersonId()));
+ return;
+ }
+
+ // lehreinheit_id serverseitig auflösen statt sie vom Client zu übernehmen
+ $resLe = $this->LehrveranstaltungModel->getLeByStudent($student_uid, $stsem, $lva_id);
+ if(isError($resLe) || !hasData($resLe)) return;
+ $le = current(getData($resLe));
+
+ $this->LePruefungModel->insert(
+ array(
+ 'lehreinheit_id' => $le->lehreinheit_id,
+ 'student_uid' => $student_uid,
+ 'mitarbeiter_uid' => getAuthUID(),
+ 'note' => $note,
+ 'punkte' => $punkte,
+ 'pruefungstyp_kurzbz' => $this->VerlaufLib->legacyTypFuerAntritt(1),
+ 'datum' => $datum,
+ 'anmerkung' => "",
+ 'insertamum' => $jetzt,
+ 'insertvon' => getAuthUID(),
+ 'updateamum' => null,
+ 'updatevon' => null,
+ 'ext_id' => null
+ )
+ );
+
+ $this->logLib->logInfoDB(array('erstantritt angelegt (freigabe)', $student_uid, getAuthUID(), getAuthPersonId()));
+ }
+
+ /**
+ * 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.
+ *
+ * @return mixed|null
+ */
+ private function getZeugnisnote($lva_id, $student_uid, $stsem)
+ {
+ $this->load->model('education/Zeugnisnote_model', 'ZeugnisnoteModel');
+
+ $res = $this->ZeugnisnoteModel->load([
+ 'studiensemester_kurzbz' => $stsem,
+ 'student_uid' => $student_uid,
+ 'lehrveranstaltung_id' => $lva_id
+ ]);
+
+ return (!isError($res) && hasData($res)) ? getData($res)[0]->note : null;
+ }
+
+ 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)
+ {
+ $jetzt = date("Y-m-d H:i:s");
+
+ $id = $this->LvgesamtnoteModel->insert(
+ array(
+ 'student_uid' => $student_uid,
+ 'lehrveranstaltung_id' => $lva_id,
+ 'studiensemester_kurzbz' => $stsem,
+ 'note' => $note,
+ 'punkte' => $punkte,
+ 'mitarbeiter_uid' => getAuthUID(),
+ 'benotungsdatum' => $jetzt,
+ 'freigabedatum' => null,
+ 'freigabevon_uid' => null,
+ 'bemerkung' => null,
+ 'updateamum' => null,
+ 'updatevon' => null,
+ 'insertamum' => $jetzt,
+ 'insertvon' => getAuthUID()
+ )
+ );
+ if(!$id) return null;
+
+ $res = $this->LvgesamtnoteModel->load($id->retval);
+ return hasData($res) ? getData($res)[0] : null;
+ }
+
private function getAnwesenheiten($prestudent_ids, $lv_id, $sem_kurzbz) {
$anwesenheiten = [];
diff --git a/application/controllers/api/frontend/v1/stv/Grades.php b/application/controllers/api/frontend/v1/stv/Grades.php
index 128316d2b..abc417bca 100644
--- a/application/controllers/api/frontend/v1/stv/Grades.php
+++ b/application/controllers/api/frontend/v1/stv/Grades.php
@@ -41,6 +41,9 @@ class Grades extends FHCAPI_Controller
// Load Libraries
$this->load->library('VariableLib', ['uid' => getAuthUID()]);
+ $this->load->library('PruefungsverlaufLib', null, 'VerlaufLib');
+
+ $this->load->config('noten');
// Load Phrases
$this->loadPhrases([
@@ -355,23 +358,24 @@ class Grades extends FHCAPI_Controller
$data['lehrveranstaltung_id'] = $lehrveranstaltung_id;
$data['student_uid'] = $student_uid;
$data['studiensemester_kurzbz'] = $studiensemester_kurzbz;
-
- $this->ZeugnisnoteModel->insert($data);
- if (defined('FAS_PRUEFUNG_BEI_NOTENEINGABE_ANLEGEN')
- && FAS_PRUEFUNG_BEI_NOTENEINGABE_ANLEGEN) {
- $result = $this->addTestsForGrade(
- $studiensemester_kurzbz,
- $student_uid,
- $lehrveranstaltung_id,
- $teacherGrade->note,
- $teacherGrade->punkte
- );
- $this->getDataOrTerminateWithError($result);
- }
+ $this->ZeugnisnoteModel->insert($data);
+ }
+
+ // Der erste Antritt wird bereits bei der Übernahme als eigene Prüfung angelegt, damit der
+ // Prüfungsverlauf von Anfang an vollständig ist und das Benotungstool ihn nicht erst beim
+ // Anlegen einer Wiederholung nachtragen muss. Läuft bewusst in BEIDEN Zweigen (auch beim
+ // Überschreiben einer bestehenden Zeugnisnote) und ist idempotent.
+ if ($this->config->item('CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME')) {
+ $result = $this->addErstantrittForGrade(
+ $studiensemester_kurzbz,
+ $student_uid,
+ $lehrveranstaltung_id,
+ $teacherGrade
+ );
+ $this->getDataOrTerminateWithError($result);
}
-
$this->terminateWithSuccess(true);
}
@@ -491,20 +495,32 @@ class Grades extends FHCAPI_Controller
}
/**
- * Helper function that adds tests for a student
- * (Entries in lehre.tbl_pruefung)
+ * Legt bei der Notenübernahme den ersten Prüfungsantritt als eigenen Datensatz an
+ * (Eintrag in lehre.tbl_pruefung).
*
- * @param string $studiensemester_kurzbz
- * @param string $student_uid
- * @param integer $lehrveranstaltung_id
- * @param integer $note
- * @param numeric $punkte
+ * Idempotent: existiert für den Studenten in dieser LV bereits ein Termin, passiert nichts -
+ * die Übernahme darf beliebig oft laufen, ohne den Verlauf zu verfälschen.
+ *
+ * Datiert auf das Freigabedatum der LV-Note: das ist der Zeitpunkt, zu dem die
+ * Leistungsfeststellung verbindlich wurde. Das Benotungsdatum ändert sich bei jeder Korrektur,
+ * der Übernahmezeitpunkt ist reine Verwaltungsarbeit - beide taugen nicht als Termindatum.
+ *
+ * @param string $studiensemester_kurzbz
+ * @param string $student_uid
+ * @param integer $lehrveranstaltung_id
+ * @param stdClass $teacherGrade die übernommene LV-Gesamtnote
*
* @return stdClass
*/
- protected function addTestsForGrade($studiensemester_kurzbz, $student_uid, $lehrveranstaltung_id, $note, $punkte)
+ protected function addErstantrittForGrade($studiensemester_kurzbz, $student_uid, $lehrveranstaltung_id, $teacherGrade)
{
$this->load->model('education/Lehrveranstaltung_model', 'LehrveranstaltungModel');
+ $this->load->model('education/LePruefung_model', 'LePruefungModel');
+
+ // bereits ein Termin vorhanden -> der Verlauf beginnt schon, nichts nachzutragen
+ $vorhanden = $this->VerlaufLib->getPruefungen($student_uid, $lehrveranstaltung_id, $studiensemester_kurzbz);
+ if (count($vorhanden) > 0)
+ return success(null);
// Get Lehreinheit
$result = $this->LehrveranstaltungModel->getLeByStudent($student_uid, $studiensemester_kurzbz, $lehrveranstaltung_id);
@@ -515,19 +531,24 @@ class Grades extends FHCAPI_Controller
return error($this->p->t("stv", "grades_error_lehreinheit_id"));
$le = current(getData($result));
+ $datum = $teacherGrade->freigabedatum ?: ($teacherGrade->benotungsdatum ?: date('Y-m-d'));
+ $now = date('Y-m-d H:i:s');
+
// Prepare
- $this->load->model('education/LePruefung_model', 'LePruefungModel');
$data = [
"student_uid" => $student_uid,
"lehreinheit_id" => $le->lehreinheit_id,
- "datum" => date('Y-m-d'),
- "pruefungstyp_kurzbz" => "Termin1",
- "note" => $note
+ "mitarbeiter_uid" => $teacherGrade->mitarbeiter_uid,
+ "datum" => $datum,
+ "pruefungstyp_kurzbz" => $this->VerlaufLib->legacyTypFuerAntritt(1),
+ "note" => $teacherGrade->note,
+ "insertamum" => $now,
+ "insertvon" => getAuthUID()
];
if (defined('CIS_GESAMTNOTE_PUNKTE') && CIS_GESAMTNOTE_PUNKTE)
- $data["punkte"] = $punkte;
-
+ $data["punkte"] = $teacherGrade->punkte;
+
// Get Anwesenheit
$this->load->model('education/Anwesenheit_model', 'AnwesenheitModel');
$result = $this->AnwesenheitModel->loadAnwesenheitStudiensemester($studiensemester_kurzbz, $student_uid, $lehrveranstaltung_id);
@@ -535,7 +556,8 @@ class Grades extends FHCAPI_Controller
return $result;
$anwesenheit = getData($result);
- if ($anwesenheit && (float)current($anwesenheit)->prozent < FAS_ANWESENHEIT_ROT) {
+ if (defined('FAS_PRUEFUNG_BEI_NOTENEINGABE_ANLEGEN') && FAS_PRUEFUNG_BEI_NOTENEINGABE_ANLEGEN
+ && $anwesenheit && (float)current($anwesenheit)->prozent < FAS_ANWESENHEIT_ROT) {
// Get Anwesenheitsbefreiung
$this->load->model('person/Benutzerfunktion_model', 'BenutzerfunktionModel');
$result = $this->BenutzerfunktionModel->getBenutzerFunktionByUidInStdsem($student_uid, $studiensemester_kurzbz, 'awbefreit');
@@ -545,7 +567,10 @@ class Grades extends FHCAPI_Controller
$anwesenheitsbefreit = hasData($result);
- // Wenn nicht Anwesenheitsbefreit und Anwesenheit unter einem bestimmten Prozentsatz fällt dann wird ein Pruefungsantritt abgezogen
+ // Bei zu geringer Anwesenheit ohne Befreiung wird ein zusätzlicher Termin
+ // 'Nicht beurteilt' vor dem eigentlichen Antritt festgehalten. ACHTUNG: diese Note
+ // zählt nicht mehr als Antritt (NOTEN_OHNE_ANTRITT_BEZEICHNUNGEN) - der frühere
+ // "Antritt abziehen"-Effekt entfällt damit, der Eintrag ist reine Dokumentation.
if (!$anwesenheitsbefreit) {
$data2 = $data;
$data2["note"] = 7;
@@ -556,8 +581,6 @@ class Grades extends FHCAPI_Controller
if (isError($result))
return $result;
-
- $data["pruefungstyp_kurzbz"] = "Termin2";
}
}
diff --git a/application/models/education/Lvgesamtnote_model.php b/application/models/education/Lvgesamtnote_model.php
index 44645e75b..5afb07e40 100644
--- a/application/models/education/Lvgesamtnote_model.php
+++ b/application/models/education/Lvgesamtnote_model.php
@@ -55,7 +55,7 @@ class Lvgesamtnote_model extends DB_Model
$params = [$student_uid, $studiensemester_kurzbz];
if ($lehrveranstaltung_id) {
- $qry .= "AND campus.tbl_lvgesamtnote.lehrveranstaltung_id = ?";
+ $qry .= " AND campus.tbl_lvgesamtnote.lehrveranstaltung_id = ?";
$params[] = $lehrveranstaltung_id;
}
diff --git a/cis/private/lehre/benotungstool/lvgesamtnoteverwalten.php b/cis/private/lehre/benotungstool/lvgesamtnoteverwalten.php
index 553e3f03c..3b3689144 100644
--- a/cis/private/lehre/benotungstool/lvgesamtnoteverwalten.php
+++ b/cis/private/lehre/benotungstool/lvgesamtnoteverwalten.php
@@ -1824,7 +1824,7 @@ $htmlstring .= "
";
-if ($anzahlChanged > 0)
+if (isset($anzahlChanged) && $anzahlChanged > 0)
echo '
' . $p->t('benotungstool/geaenderteNotenVorhanden') . '
';
echo $htmlstring;
diff --git a/public/css/Cis4/Benotungstool.css b/public/css/Cis4/Benotungstool.css
index 83a026d0e..225cfadf8 100644
--- a/public/css/Cis4/Benotungstool.css
+++ b/public/css/Cis4/Benotungstool.css
@@ -62,9 +62,63 @@
transform: translateY(-50%) rotate(180deg);
}
-.pruefung-badge {
+/*
+ * Prueufungszelle: feste Spuren fuer Note | Datum | Aktion.
+ * Vorher war das eine zentrierte Flex-Zeile, dadurch verschoben laengere Notenbezeichnungen
+ * ("Nicht Genuegend" vs "Genuegend") Datum und Button - die Zellen standen von Zeile zu Zeile
+ * nicht mehr untereinander. Mit festen Spurbreiten fluchten sie immer.
+ */
+.pruefung-cell {
position: relative;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 6.5rem; /* Note | Aktion */
+ align-items: center;
+ column-gap: 0.5rem;
+ height: 100%;
padding-left: 32px; /* badge width (20) + its left offset (6) + gap (6) */
+ padding-right: 6px;
+ text-align: left;
+}
+
+/* Spaltenmodus 'antritt': das Datum steht in der Zelle, nicht im Spaltenkopf */
+.pruefung-cell.mit-datum {
+ grid-template-columns: minmax(0, 1fr) 4.75rem 6.5rem; /* Note | Datum | Aktion */
+}
+
+/*
+ * Zellen ohne Aktion (abschliessender Termin, wird in einem anderen Tool gepflegt): die Spur fuer
+ * den Button entfaellt, der Platz gehoert der Notenbezeichnung.
+ */
+.pruefung-cell.ohne-aktion {
+ grid-template-columns: minmax(0, 1fr); /* Note */
+}
+
+.pruefung-cell.ohne-aktion.mit-datum {
+ grid-template-columns: minmax(0, 1fr) 4.75rem; /* Note | Datum */
+}
+
+.pruefung-cell .pruefung-note {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.pruefung-cell .pruefung-datum {
+ font-size: 0.75rem;
+ white-space: nowrap;
+ text-align: right;
+ opacity: 0.7;
+}
+
+/* immer in der letzten Spur, auch wenn Note und Datum fehlen (leere Zelle mit "Hinzufuegen") */
+.pruefung-cell .pruefung-action {
+ grid-column: -2 / -1;
+ display: flex;
+ justify-content: flex-end;
+}
+
+.pruefung-cell .pruefung-action .btn {
+ white-space: nowrap;
}
.pruefung-badge::before {
@@ -87,4 +141,6 @@
.pruefung-badge.attempt-t1::before { background: #0072B2; }
.pruefung-badge.attempt-t2::before { background: #E69F00; }
.pruefung-badge.attempt-t3::before { background: #CC79A7; }
-.pruefung-badge.attempt-k::before { background: #D55E00; }
\ No newline at end of file
+.pruefung-badge.attempt-k::before { background: #D55E00; }
+/* Termin ohne verbrauchten Antritt (entschuldigt, nicht beurteilt) - traegt keine Nummer */
+.pruefung-badge.attempt-none::before { background: #767676; }
\ No newline at end of file
diff --git a/public/js/api/factory/noten.js b/public/js/api/factory/noten.js
index 964cc5e73..55278964d 100644
--- a/public/js/api/factory/noten.js
+++ b/public/js/api/factory/noten.js
@@ -49,18 +49,21 @@ export default {
params: { lv_id, sem_kurzbz, student_uid, note, punkte }
};
},
- saveStudentPruefung(student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, typ, pruefung_id = null){
+ // 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){
return {
method: 'post',
url: '/api/frontend/v1/Noten/saveStudentPruefung',
- params: { student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, typ, pruefung_id }
+ params: { student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, pruefung_id }
};
},
- createPruefungen(uids, datum, lva_id, sem_kurzbz){
+ // note/punkte optional: ohne Auswahl wird der Termin als "noch nicht eingetragen" angelegt
+ createPruefungen(uids, datum, lva_id, sem_kurzbz, note = null, punkte = null){
return {
method: 'post',
url: '/api/frontend/v1/Noten/createPruefungen',
- params: { uids, datum, lva_id, sem_kurzbz }
+ params: { uids, datum, lva_id, sem_kurzbz, note, punkte }
};
},
saveNotenvorschlagBulk(lv_id, sem_kurzbz, noten) {
diff --git a/public/js/components/Cis/Benotungstool/Benotungstool.js b/public/js/components/Cis/Benotungstool/Benotungstool.js
index 0b727ff88..e4f8a3b7c 100644
--- a/public/js/components/Cis/Benotungstool/Benotungstool.js
+++ b/public/js/components/Cis/Benotungstool/Benotungstool.js
@@ -64,10 +64,15 @@ export const Benotungstool = {
selectedPruefungNote: null,
selectedPruefungDate: new Date(), // v-model for pruefung edit datepicker
selectedPruefungPunkte: null,
- pruefungNoteLocked: false, // grade read-only when a later/higher pruefung exists (date stays editable)
+ pruefungNoteLocked: false, // grade read-only when a later pruefung exists (date stays editable)
pruefungDateMin: null,
pruefungDateMax: null,
distinctPruefungsDates: null,
+ // Spaltenaufteilung der Termine: 'antritt' (je Antrittsnummer) oder 'datum' (je Prüfungsdatum).
+ // null = Vorgabe aus der Konfiguration; die Wahl des Benutzers wird lokal gemerkt.
+ pruefungsspalten: localStorage.getItem('notenToolPruefungsspalten'),
+ // Studenten der aktuellen Anlage, für die noch keine LV-Note existiert -> sie entsteht mit
+ // der Prüfung und wird vorher bestätigt
pruefungStudent: null,
pruefung: null,
password: '',
@@ -453,25 +458,15 @@ export const Benotungstool = {
}
},
validatePruefungBulk(pruefungen) {
- // need to check pruefungen for validity in respect to the students nr of antritte
- // pruefungsdatum will be validated aswell so we dont get a termin 3 chronologically before
- // a termin 2 which is totally possible in the old tool
+ // Reihenfolge und Antrittsgrenze vorab prüfen, damit offensichtlich unmögliche Zeilen gar
+ // nicht erst gesendet werden. Eine fehlende LV-Note ist kein Hindernis: liegt für den
+ // Studenten noch keine Leistungsfeststellung vor, entsteht sie mit der importierten Note.
const validatedPruefungen = []
pruefungen.forEach( p => {
const student = this.studenten.find(s => s.uid === p.uid)
- // if a student doesnt have a lv_note entered definitely dont allow to create pruefung
- if(!student.lv_note) {
- this.$fhcAlert.alertWarning('Student ' + student.uid + ' hat noch keine LV-Note eingetragen! Es wird keine Prüfung angelegt.')
- return
- }
-
- // note: a missing Zeugnisnote is intentionally allowed here so that a dated import can
- // create the first attempt for not-yet-graded students (Termin1 falls back to
- // "noch nicht eingetragen" server-side). Only the LV-note is mandatory.
-
// check if student antrittCount is too high already
- if(student.hoechsterAntritt >= this.maxAntrittCount) {
+ if(!this.canAddPruefung(student)) {
this.$fhcAlert.alertWarning('Student ' + student.uid + ' hat bereits ' + student.hoechsterAntritt + ' Prüfungsantritte abgelegt. Die Zeile wurde übersprungen.')
return
}
@@ -571,9 +566,7 @@ export const Benotungstool = {
}
}
- const typ = this.getPruefungstypForStudentByAntritt(student)
-
- pruefungbulk.push({uid: student.uid, datum: dateStr, note, punkte, typ, lehreinheit_id: student.lehreinheit_id, dateObj})
+ pruefungbulk.push({uid: student.uid, datum: dateStr, note, punkte, lehreinheit_id: student.lehreinheit_id, dateObj})
},
saveNotenBulk(notenbulk) {
this.loading = true
@@ -622,7 +615,7 @@ export const Benotungstool = {
let errorList = ''
Object.keys(res.data ?? {}).forEach(uid => {
const entry = res.data[uid]
- if(!entry?.savedPruefung && !entry?.extraPruefung) {
+ if(!entry?.savedPruefung) {
errorList += entry + '\n'
}
})
@@ -643,179 +636,290 @@ export const Benotungstool = {
handleAddNewPruefungenResponse(res, uids) {
// in case we reload when changing lva_id or stsem to always consider local storage layout
this.colLayoutRestored = false;
-
+
const pruefungen = res.data
uids.forEach(entry => {
const rowResult = pruefungen[entry.uid]
- const saved = rowResult?.savedPruefung?.[0]
- const extra = rowResult?.extraPruefung?.[0]
const student = this.studenten.find(s => s.uid == entry.uid)
if(!student) return
// a per-row backend rejection returns a localized error string (or produced nothing) ->
- // skip it here; callers surface those messages to the user separately
- if(!saved) return
+ // skip it here; callers surface those messages to the user separately.
+ // savedPruefung ist der Erfolgsnachweis, nicht verlauf: letzterer beschreibt nur den
+ // Stand und wäre auch bei einem fehlgeschlagenen Insert vorhanden.
+ if(!rowResult?.savedPruefung || !rowResult?.verlauf) return
- // check for extra pruefung (termin1) to add before
- if(extra) {
- student["Termin1"] = extra
- student.pruefungen.push(extra) // push to pruefungen array for antritt evaluation
- }
-
- this.correctOldTerminTypenForStudent(student, saved)
-
- if(!this.distinctPruefungsDates.includes(saved.datum)) {
- this.insertSortedDate(this.distinctPruefungsDates, saved.datum)
- }
-
- // add pruefung to pruefungen array
- student.pruefungen.push(saved)
-
- // add pruefung to student via its datum as a field
- student[saved.datum] = saved
-
- // usually should be in order naturally, just to be save
- student.pruefungen.sort((p1, p2) => {
- if(p1.datum > p2.datum) {
- return 1
- } else if (p1.datum < p2.datum) {
- return -1
- } else {
- return 0
- }
- })
-
- // recalculate student antritte
- student.hoechsterAntritt = this.getAntrittCountStudent(student)
- this.recalculateSelectable(student)
- this.reformatStudentRow(student)
+ // gleiche Nachbereitung wie im Dialog aus der Tabelle: erst LV-Note, dann Verlauf
+ this.applyLvGesamtnote(student, rowResult.lvgesamtnote)
+ this.applyVerlauf(student, rowResult.verlauf)
})
- // add col to table
- const cols = [...this.notenTableOptions.columns.slice(0, -1)];
- let kommCol = null
- if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) kommCol = this.notenTableOptions.columns[this.notenTableOptions.columns.length - 1];
-
- if(this.distinctPruefungsDates.length) {
- cols.push({
- title: this.$capitalize(this.$p.t('benotungstool/c4originalZnote')),
- field: "Termin1",
- formatter: this.pruefungFormatter,
- titleFormatter: this.pruefungTitleFormatter,
- topCalc: this.terminCalcFunc,
- topCalcFormatter: this.terminCalcFormatter,
- hozAlign:"center",
- widthGrow: 1,
- minWidth: 200,
- width: 250,
- visible: true,
- tooltip: false
- })
- }
-
- this.distinctPruefungsDates.forEach((date)=>{
- const dateparts = date.split('-')
- const titledate = `${dateparts[2]}.${dateparts[1]}.${dateparts[0]}`
-
- cols.push({
- title: titledate,//this.$p.t('benotungstool/pruefungNr', [index+1]),
- field: date,
- formatter: this.pruefungFormatter,
- titleFormatter: this.pruefungTitleFormatter,
- topCalc: this.terminCalcFunc,
- topCalcFormatter: this.terminCalcFormatter,
- hozAlign:"center",
- widthGrow: 1,
- minWidth: 200,
- width: 250,
- visible: true,
- tooltip: false
- })
- })
-
- if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) cols.push(kommCol) // keep kommPruef Col as last
- // redraw table
-
-
- // preemptively do the persistence part of columns already here so we get the merged definition before
- // tabulator internal go to war with vue reactives
- let colsUsed = null
- const saved = this.loadState();
- if (saved && saved.columns) {
- // new columns map for lookup
- const colMap = new Map(cols.map(c => [c.field, c]));
-
- const restoredCols = [];
-
- // add columns in the SAVED order, applying saved width/visibility
- saved.columns.forEach(savedCol => {
- const originalDef = colMap.get(savedCol.field);
- if (originalDef) {
- restoredCols.push({
- ...originalDef, // Keep formatters, calcs, etc.
- width: savedCol.width,
- visible: savedCol.visible
- });
- colMap.delete(savedCol.field); // Remove so we don't double-add
- }
- });
-
- // append any NEW columns aka pruefungs cols
- colMap.forEach((def) => {
- restoredCols.push(def);
- });
-
- colsUsed = restoredCols;
- this.colLayoutRestored = true;
- }
-
this.loading = false
-
- const colsFinal = colsUsed ?? cols
- this.$refs.notenTable.tabulator.setColumns(colsFinal)
- this.$refs.notenTable.tabulator.setData(this.studenten);
- this.$refs.notenTable.tabulator.redraw(true);
+
+ // Scrollstand halten: nach einer Bulk-Anlage soll der Benutzer seine Zeilen weiter sehen
+ this.preserveScroll(() => {
+ this.applyPruefungColumns()
+ const loaded = this.$refs.notenTable.tabulator.setData(this.studenten);
+ this.$refs.notenTable.tabulator.redraw(true);
+ return loaded
+ })
+ },
+ /**
+ * Zeile aus dem vom Server gelieferten Prüfungsverlauf neu aufbauen. Positionen, Antrittszahl
+ * und Grenzen kommen ausschliesslich von dort - der Client führt keine eigene Buchhaltung über
+ * Termine, damit die Regeln nicht in zwei Implementierungen auseinanderlaufen.
+ */
+ /**
+ * LV-Note einer Zeile aus der Serverantwort übernehmen: Note, Punkte und Freigabestatus.
+ * Beide Eingabewege (Dialog aus der Tabelle und Sammelanlage) verwenden dasselbe, sonst
+ * zeigt die Tabelle nach einer Sammelanlage die alte Note.
+ */
+ applyLvGesamtnote(student, lvgesamtnote) {
+ if(!student || !lvgesamtnote) return
+
+ student.lv_note = lvgesamtnote.note
+ student.freigabedatum = this.parseDate(lvgesamtnote.freigabedatum)
+ student.benotungsdatum = this.parseDate(lvgesamtnote.benotungsdatum)
+ student.freigegeben = this.checkFreigabe(student.freigabedatum, student.benotungsdatum, student.uid)
+
+ if(this.config?.CIS_GESAMTNOTE_PUNKTE) student.punkte = lvgesamtnote.punkte
+
+ // teilnoten ist die Quelle für changedNoten (Zähler und Liste der Freigabe)
+ if(this.teilnoten && this.teilnoten[student.uid]) {
+ this.teilnoten[student.uid].note_lv = lvgesamtnote.note
+ this.teilnoten[student.uid].punkte_lv = lvgesamtnote.punkte
+ this.teilnoten[student.uid]['freigabedatum'] = lvgesamtnote.freigabedatum
+ this.teilnoten[student.uid]['benotungsdatum'] = lvgesamtnote.benotungsdatum
+ }
+
+ this.changedNotenCounter++ // computed changedNoten neu auswerten
+ },
+ applyVerlauf(student, verlauf) {
+ if(!verlauf) return
+
+ // die Kennzahlen vollständig übernehmen. Nicht einzeln abschreiben: fehlende Felder
+ // (angerechnet, hatLvNote) sind danach undefined und die Oberfläche urteilt falsch,
+ // bis die Lehrveranstaltung neu geladen wird.
+ student.verlauf = {...verlauf}
+ delete student.verlauf.pruefungen
+
+ student.pruefungen = []
+ delete student['kommPruef']
+
+ ;(verlauf.pruefungen ?? []).forEach(p => {
+ p.dateObj = this.parseISODate(p.datum)
+ // der abschliessende Termin steht in einer eigenen, immer letzten Spalte
+ if(p.terminal) student['kommPruef'] = p
+ else student.pruefungen.push(p)
+ })
+
+ // die flache Liste über alle Studenten mitziehen: sie entscheidet, ob Notenvorschlag und
+ // Punkte noch bearbeitbar sind (siehe getColumnsDefinition), und war bisher bis zum
+ // nächsten Laden veraltet
+ this.pruefungen = (this.pruefungen ?? []).filter(p => p.student_uid !== student.uid)
+ ;(verlauf.pruefungen ?? []).forEach(p => this.pruefungen.push(p))
+
+ this.syncDistinctPruefungsDates()
+ this.indexPruefungen(student)
+
+ student.hoechsterAntritt = this.getAntrittCountStudent(student)
+ this.recalculateSelectable(student)
+ this.reformatStudentRow(student)
+ },
+ /** Datumsspalten aus allen Zeilen neu ableiten (nur im Spaltenmodus 'datum' sichtbar). */
+ syncDistinctPruefungsDates() {
+ const dates = new Set()
+ this.studenten?.forEach(s => (s.pruefungen ?? []).forEach(p => dates.add(p.datum)))
+ this.distinctPruefungsDates = [...dates].sort()
+ },
+ /** Feldname der Spalte, in der ein Termin dargestellt wird. */
+ pruefungField(pruefung, index) {
+ if(pruefung.terminal) return 'kommPruef'
+ return this.pruefungsspaltenModus === 'antritt' ? ('antritt_' + (index + 1)) : pruefung.datum
+ },
+ /**
+ * Termine als Spaltenwerte an der Zeile ablegen. Vorherige Zuordnungen werden zuvor entfernt,
+ * damit ein Moduswechsel oder ein korrigiertes Prüfungsdatum keine verwaiste Spalte hinterlässt.
+ */
+ indexPruefungen(student) {
+ (student._pruefungFields ?? []).forEach(f => { delete student[f] })
+
+ const fields = []
+ student.pruefungen.forEach((p, i) => {
+ const field = this.pruefungField(p, i)
+ student[field] = p
+ fields.push(field)
+ })
+
+ student._pruefungFields = fields
+ },
+ /** einheitliche Definition einer Prüfungsspalte, unabhängig vom Spaltenmodus */
+ pruefungColumnDef(field, title) {
+ // Die Zelle hat feste Spuren (siehe .pruefung-cell): Badge 32 + Note + Aktion 6.5rem,
+ // im Antrittsmodus zusätzlich das Datum 4.75rem. Die Mindestbreite muss die festen
+ // Spuren plus lesbaren Notentext fassen, sonst wird die Note sofort abgeschnitten.
+ const minWidth = this.pruefungsspaltenModus === 'antritt' ? 320 : 250
+
+ return {
+ title,
+ field,
+ formatter: this.pruefungFormatter,
+ titleFormatter: this.pruefungTitleFormatter,
+ sorter: this.pruefungSorter,
+ topCalc: this.terminCalcFunc,
+ topCalcFormatter: this.terminCalcFormatter,
+ hozAlign: "center",
+ widthGrow: 1,
+ minWidth,
+ width: minWidth,
+ visible: true,
+ tooltip: false
+ }
+ },
+ /**
+ * Die Prüfungsspalten im aktiven Modus:
+ * 'antritt' - eine Spalte je Antrittsnummer, das Datum steht in der Zelle. Robust auch dann,
+ * wenn jeder Student sein eigenes Prüfungsdatum hat.
+ * 'datum' - eine Spalte je Prüfungsdatum. Kompakt, wenn ganze Jahrgänge am selben Tag
+ * antreten; der erste Antritt ist hier eine Datumsspalte wie jede andere.
+ */
+ buildPruefungColumns() {
+ if(this.pruefungsspaltenModus === 'antritt') {
+ let count = 0
+ this.studenten?.forEach(s => {
+ // eine Spalte mehr, solange bei dieser Zeile noch ein Antritt angelegt werden darf
+ const needed = (s.pruefungen?.length ?? 0) + (this.canAddPruefung(s) ? 1 : 0)
+ if(needed > count) count = needed
+ })
+
+ return Array.from({length: count}, (_, i) =>
+ this.pruefungColumnDef('antritt_' + (i + 1), this.$capitalize(this.$p.t('benotungstool/c4antrittNr', [i + 1])))
+ )
+ }
+
+ return (this.distinctPruefungsDates ?? []).map(date =>
+ this.pruefungColumnDef(date, this.formatDatumDMY(date))
+ )
+ },
+ /**
+ * Prüfungsspalten neu setzen: Basisspalten, dann die Termine im aktiven Modus, zuletzt der
+ * abschliessende Termin. Einzige Stelle, an der die Spalten gebaut werden.
+ *
+ * setColumns baut die gesamte Tabelle neu auf (und setzt dabei den Scrollstand zurück),
+ * deshalb nur aufrufen, wenn sich die Spalten wirklich geändert haben: beim Bearbeiten einer
+ * Prüfung oder beim Anlegen auf einem bereits vorhandenen Termin bleiben sie gleich.
+ */
+ applyPruefungColumns(force = false) {
+ const table = this.$refs.notenTable?.tabulator
+ if(!table || !this.notenTableOptions) return
+
+ const pruefungCols = this.buildPruefungColumns()
+ const key = pruefungCols.map(c => c.field).join('|')
+
+ if(!force && key === this._pruefungColumnKey) return
+ this._pruefungColumnKey = key
+
+ const cols = [...this.notenTableOptions.columns.slice(0, -1)]
+ const kommCol = this.config?.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF
+ ? this.notenTableOptions.columns[this.notenTableOptions.columns.length - 1]
+ : null
+
+ pruefungCols.forEach(c => cols.push(c))
+ if(kommCol) cols.push(kommCol) // abschliessender Termin bleibt die letzte Spalte
+
+ table.setColumns(this.restoreColumnLayout(cols))
+ },
+ /**
+ * Führt eine Tabellenoperation aus, die neu rendert (setColumns / setData / redraw), und
+ * stellt den Scrollstand danach wieder her - sonst springt die Tabelle nach jedem Speichern
+ * nach ganz oben links und der Benutzer verliert seine Zeile aus dem Blick.
+ *
+ * Gelesen und gesetzt wird direkt am .tabulator-tableholder. Tabulators interne
+ * rowManager.scrollLeft/scrollTop werden nicht in allen Renderpfaden nachgeführt - vor allem
+ * horizontal nicht, daher sprang die Tabelle bisher trotz Wiederherstellung nach links.
+ *
+ * Wiederhergestellt wird zusätzlich in den nächsten beiden Frames: Tabulator rendert Spalten
+ * und Zeilen teilweise erst danach und setzt den Scrollstand dabei erneut zurück. Vue.nextTick
+ * läuft auf der Microtask-Queue und damit zu früh.
+ */
+ preserveScroll(operation) {
+ const holder = this.$refs.notenTable?.tabulator?.element?.querySelector('.tabulator-tableholder')
+
+ const left = holder?.scrollLeft ?? 0
+ const top = holder?.scrollTop ?? 0
+
+ const result = operation()
+
+ // nichts wiederherzustellen -> auch nicht gegen ein legitimes Scrollen nach oben arbeiten
+ if(!holder || (!left && !top)) return result
+
+ const restore = () => {
+ holder.scrollLeft = left
+ holder.scrollTop = top
+ }
+
+ restore()
+ requestAnimationFrame(() => { restore(); requestAnimationFrame(restore) })
+
+ // setData liefert in Tabulator ein Promise -> nach dem Laden noch einmal
+ if(result && typeof result.then === 'function') result.then(() => requestAnimationFrame(restore))
+
+ return result
+ },
+ /**
+ * Gespeicherte Breite/Sichtbarkeit/Reihenfolge auf frisch gebaute Spalten anwenden, bevor
+ * Tabulator sie übernimmt - sonst geraten die internen Definitionen mit den Vue-Reactives
+ * aneinander.
+ */
+ restoreColumnLayout(cols) {
+ const saved = this.loadState()
+ if(!saved?.columns) return cols
+
+ const colMap = new Map(cols.map(c => [c.field, c]))
+ const restored = []
+
+ // Spalten in der GESPEICHERTEN Reihenfolge, mit gespeicherter Breite/Sichtbarkeit
+ saved.columns.forEach(savedCol => {
+ const originalDef = colMap.get(savedCol.field)
+ if(originalDef) {
+ restored.push({...originalDef, width: savedCol.width, visible: savedCol.visible})
+ colMap.delete(savedCol.field)
+ }
+ })
+
+ colMap.forEach(def => restored.push(def)) // neue Spalten anhängen
+
+ this.colLayoutRestored = true
+ return restored
+ },
+ /** Spaltenmodus umschalten; die Wahl bleibt lokal gespeichert. */
+ setPruefungsspalten(modus) {
+ if(modus !== 'antritt' && modus !== 'datum') return
+
+ this.pruefungsspalten = modus
+ localStorage.setItem('notenToolPruefungsspalten', modus)
+
+ this.studenten?.forEach(s => this.indexPruefungen(s))
+
+ // vertikale Position halten - die Spalten wechseln komplett, die Zeile bleibt dieselbe
+ this.preserveScroll(() => {
+ this.applyPruefungColumns(true)
+ const loaded = this.$refs.notenTable?.tabulator?.setData(this.studenten)
+ this.$refs.notenTable?.tabulator?.redraw(true)
+ return loaded
+ })
},
reformatStudentRow(student) {
const table = this.$refs.notenTable.tabulator
if(!table) return
-
+
const row = table.rowManager.getRowFromDataObject(student)
+ if(!row) return // Zeile noch nicht gerendert (zB direkt nach einem Datenwechsel)
const rowComponent = row.getComponent()
rowComponent.reformat()
},
- correctOldTerminTypenForStudent(student, saved) {
- // check if student has a preceding pruefung from same type and remove it since in this case
- // the new pruefung will have overwritten the old one.
- // EXCEPTION: an "entschuldigt" Termin must remain as its own dated entry (it was not an
- // attempt, so the new pruefung is an additional dated record, not an overwrite of it).
- const oldP = student.pruefungen.find(p =>
- p.pruefungstyp_kurzbz == saved.pruefungstyp_kurzbz
- && p.pruefung_id != saved.pruefung_id
- && !(this.config?.NOTE_ENTSCHULDIGT != null && p.note == this.config.NOTE_ENTSCHULDIGT)
- )
- if(oldP) {
- delete student[oldP.datum] // delete the variable col value
-
- const pIndex = student.pruefungen.indexOf(oldP)
- if (pIndex > -1) {
- student.pruefungen.splice(pIndex, 1)
- }
- // student.pruefungen.splice(student.pruefungen.indexOf(oldP), 1) // delete from pruefungen array
-
- // check if on that date any other student has a pruefung, if not delete it from distinctPruefungsDates
- const other = this.studenten.find(s => s[oldP.datum] !== undefined)
- if(!other) {
- const dateIndex = this.distinctPruefungsDates.indexOf(oldP.datum);
- if (dateIndex > -1) {
- this.distinctPruefungsDates.splice(dateIndex, 1);
- }
- // this.distinctPruefungsDates.splice(this.distinctPruefungsDates.findIndex(oldP.datum), 1)
- }
- }
- },
importPruefungen() {
// Prüfungsimport: every row must carry a date: "UID/Matrikelnr Datum Note".
// Each row creates a dated exam attempt.
@@ -902,13 +1006,8 @@ export const Benotungstool = {
},
selectableCheck(row, e) {
- const data = row.getData();
-
- if(data['kommPruef']) return false
- else if(data.hoechsterAntritt >= this.maxAntrittCount) return false // 2 or 3 pruefungen counted
-
- return true; // student can be selected to add pruefung
-
+ // auswählbar ist, wer überhaupt noch einen Antritt bekommen darf
+ return this.canAddPruefung(row.getData());
},
getColumnsDefinition() {
const columns = []
@@ -967,22 +1066,26 @@ export const Benotungstool = {
handleClick: this.selectAllHandler
},
width: 50,
+ minWidth: 50,
cssClass: this.stickyClass('selectCol'),
field: 'selectCol',
title: ''
})
- columns.push({title: 'UID', field: 'uid', tooltip: false, topCalc: this.sumCalcFunc, formatter: centeredTextFormatter, cssClass: this.stickyClass('uid')})
- columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4mail'))), field: 'email', formatter: this.mailFormatter, tooltip: false, visible: false, variableHeight: true})
- columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4antrittCountv2'))), field: 'hoechsterAntritt', formatter: centeredTextFormatter, tooltip: false})
- columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4vorname'))), field: 'vorname', formatter: centeredTextFormatter, headerFilter: true, tooltip: false, cssClass: this.stickyClass('vorname')})
- columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4nachname'))), field: 'nachname', formatter: centeredTextFormatter, headerFilter: true, cssClass: this.stickyClass('nachname')})
- columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4anwesenheitsquote'))), field: 'anwquote', formatter: this.percentFormatter})
- columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4mobility'))), field: 'mobility_zusatz', formatter: centeredTextFormatter, headerFilter: true, visible: false})
+ // Mindestbreiten: Spalten mit Kopffilter oder Editor brauchen mehr Platz als ihr Titel,
+ // sonst starten sie so schmal, dass Filterfeld und Werte abgeschnitten sind.
+ columns.push({title: 'UID', field: 'uid', tooltip: false, topCalc: this.sumCalcFunc, formatter: centeredTextFormatter, minWidth: 110, cssClass: this.stickyClass('uid')})
+ columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4mail'))), field: 'email', formatter: this.mailFormatter, tooltip: false, visible: false, minWidth: 170, variableHeight: true})
+ columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4antrittCountv2'))), field: 'hoechsterAntritt', formatter: centeredTextFormatter, tooltip: false, minWidth: 100})
+ columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4vorname'))), field: 'vorname', formatter: centeredTextFormatter, headerFilter: true, tooltip: false, minWidth: 140, cssClass: this.stickyClass('vorname')})
+ columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4nachname'))), field: 'nachname', formatter: centeredTextFormatter, headerFilter: true, minWidth: 140, cssClass: this.stickyClass('nachname')})
+ columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4anwesenheitsquote'))), field: 'anwquote', formatter: this.percentFormatter, minWidth: 120})
+ columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4mobility'))), field: 'mobility_zusatz', formatter: centeredTextFormatter, headerFilter: true, visible: false, minWidth: 140})
if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_MOODLE_LE_NOTE) {
- columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4teilnoten'))), field: 'teilnote', formatter: this.teilnotenFormatter, variableHeight: true})
+ columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4teilnoten'))), field: 'teilnote', formatter: this.teilnotenFormatter, minWidth: 160, variableHeight: true})
}
if(this.config?.CIS_GESAMTNOTE_PUNKTE) {
- columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4punkte'))), field: 'punkte',
+ columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4punkte'))), field: 'punkte',
+ minWidth: 110,
editor: this.liveNumberEditor,
editable: (cell) => {
const rowData = cell.getRow().getData();
@@ -994,6 +1097,7 @@ export const Benotungstool = {
})
}
columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4notenvorschlag'))), field: 'note_vorschlag',
+ minWidth: 160,
editor: 'list',
editorParams: (cell) => {
// write original cell value into row to it can be retrieved if edit is cancelled without selection
@@ -1032,10 +1136,11 @@ export const Benotungstool = {
return '' + val + '
'
}
})
- columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4notenvorschlagUebernehmen'))), field: 'übernehmen', width: 150, hozAlign: 'center', formatter: this.arrowFormatter,
- cellClick: this.saveNote,
+ columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4notenvorschlagUebernehmen'))), field: 'übernehmen', width: 150, minWidth: 100, hozAlign: 'center', formatter: this.arrowFormatter,
+ cellClick: this.saveNote,
variableHeight: true})
columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4lvnote'))), field: 'lv_note',
+ minWidth: 160,
formatter: this.notenFormatter,
headerFilter: 'list',
headerFilterParams: () => {
@@ -1043,9 +1148,10 @@ export const Benotungstool = {
},
headerFilterFunc: this.notenFilterFunc
})
- columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4freigabe'))), field: 'freigegeben', formatter: this.freigabeFormatter, variableHeight: true})
+ columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4freigabe'))), field: 'freigegeben', formatter: this.freigabeFormatter, minWidth: 130, variableHeight: true})
columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4zeugnisnote'))),
field: 'note',
+ minWidth: 160,
formatter: this.notenFormatter,
topCalc: this.negativeNotenCalc,
topCalcFormatter: this.negativeNotenCalcFormatter,
@@ -1061,7 +1167,7 @@ export const Benotungstool = {
sorter: this.pruefungSorter,
topCalc: this.terminCalcFunc,
topCalcFormatter: this.terminCalcFormatter,
- hozAlign:"center", minWidth: 150, visible: false,
+ hozAlign:"center", minWidth: 200, visible: false,
tooltip: false
})
@@ -1110,8 +1216,7 @@ export const Benotungstool = {
const data = row.getData()
- const notSelectable = data.pruefungen?.find(p => p.pruefungstyp_kurzbz == 'kommPruef') || data.hoechsterAntritt >= this.maxAntrittCount
- if(notSelectable) {
+ if(!this.canAddPruefung(data)) {
const el = row.getElement()
el.children[0]?.children[0]?.remove()
@@ -1282,182 +1387,127 @@ export const Benotungstool = {
return ''+val+'
'
},
- pruefungAttemptRank(typ) {
- // ranking of the attempt types so we can compare "höhere" pruefungen
- switch(typ) {
- case 'Termin1': return 1
- case 'Termin2': return 2
- case 'Termin3': return 3
- case 'kommPruef': return 4
- default: return 0
- }
+ /** Ob für diese Zeile überhaupt noch ein Antritt angelegt werden darf (Serverstand). */
+ canAddPruefung(student) {
+ return NotenRules.canAddPruefung(student, this.config)
},
- hasLaterOrHigherPruefung(student, pruefung) {
- // a Termin2/3 grade may no longer be changed once the student already has a
- // later-dated exam OR an exam of a higher attempt (e.g. a Termin3/kommPruef
- // exists while looking at the Termin2). Mirrors the add-button's youngerPruefung guard.
+ hasLaterPruefung(student, pruefung) {
+ // Die Note eines Termins ist gesperrt, sobald im Verlauf ein späterer Termin steht (das
+ // Datum darf weiterhin korrigiert werden). Die Position kommt vom Server, es wird also
+ // nicht mehr über den Termintyp verglichen.
if(!pruefung) return false
- const currentRank = this.pruefungAttemptRank(pruefung.pruefungstyp_kurzbz)
- const currentDate = (pruefung.datum ?? '').slice(0, 10)
- return (student.pruefungen ?? []).some(p => {
- if(p === pruefung) return false
- if(p.pruefung_id != null && pruefung.pruefung_id != null && p.pruefung_id === pruefung.pruefung_id) return false
- const laterDate = (p.datum ?? '').slice(0, 10) > currentDate
- const higherRank = this.pruefungAttemptRank(p.pruefungstyp_kurzbz) > currentRank
- return laterDate || higherRank
- })
+ const alle = [...(student.pruefungen ?? [])]
+ if(student.kommPruef) alle.push(student.kommPruef)
+
+ return alle.some(p => p.pruefung_id != pruefung.pruefung_id && p.position > pruefung.position)
},
+ /**
+ * Zelle einer Prüfungsspalte. Termintyp-agnostisch: welcher Antritt in der Zelle steht, ergibt
+ * sich aus der Spaltenzuordnung (siehe indexPruefungen); Antrittsnummer und ob der Termin
+ * zählt, kommen aus dem Verlauf vom Server.
+ */
pruefungFormatter(cell) {
const data = cell.getData()
+ // Angerechnet: die Zeile bleibt sichtbar (die Person gehört zur Lehrveranstaltung), es gibt
+ // aber keine Prüfungen dazu - weder anlegen noch ändern.
+ if(data.verlauf?.angerechnet) return ''
+
+ // Eine bereits im Zeugnis stehende, nicht überschreibbare Note sperrt die Terminspalten.
+ // Ohne Zeugnisnote bleiben sie offen: dort wird der erste Antritt überhaupt erst erfasst.
const noteDef = data.note ? this.notenOptions.find(n => n.note == data.note) : null
- if(!data.note || !noteDef?.lkt_ueberschreibbar) {
- return ''
- }
-
- const colDef = cell.getColumn().getDefinition()
-
- // column is just a date, student can have any of his antritte on this date, so we need to get
- // student.pruefungen and look for a pruefung with this cols title as date
+ if(noteDef && !noteDef.lkt_ueberschreibbar) return ''
const field = cell.getColumn().getField()
- let studentPruefung = null
- if(field === 'kommPruef') { studentPruefung = data['kommPruef'] }
- else if(field === "Termin1") { studentPruefung = data['Termin1'] }
- else { studentPruefung = data.pruefungen.find(p => p.datum === field) }
+ const studentPruefung = data[field]
+
+ // im Antrittsmodus trägt die Spalte kein Datum -> es steht in der Zelle
+ const antrittModus = this.pruefungsspaltenModus === 'antritt'
+
+ // feste Aufteilung Note | Datum | Aktion (siehe .pruefung-cell), damit Datum und Button
+ // über alle Zeilen hinweg fluchten, egal wie lang die Notenbezeichnung ist
+ const rowDiv = document.createElement('div')
+ rowDiv.className = antrittModus ? 'pruefung-cell mit-datum' : 'pruefung-cell'
+
+ const addSlot = (className, content) => {
+ const div = document.createElement('div')
+ div.className = className
+
+ if(typeof content === 'string') div.textContent = content
+ else if(content instanceof HTMLElement) div.appendChild(content)
+
+ rowDiv.appendChild(div)
+ return div
+ }
+
+ const addButton = (label, title, onClick) => {
+ const button = document.createElement('button')
+ button.className = 'btn btn-outline-secondary'
+ button.textContent = label
+ if(title) button.title = title
+ button.addEventListener('click', onClick)
+
+ addSlot('pruefung-action', button)
+ }
- // is this column/cell allowed to have an add pruefung action
- const canAdd = field !== 'kommPruef' && field !== "Termin1" && data.hoechsterAntritt < this.maxAntrittCount
-
- // TODO: check for some time limit maybe? old pruefungen can be changed/created
-
- // Create root row div
- const rowDiv = document.createElement('div');
- rowDiv.className = 'row flex-nowrap';
- rowDiv.style.display = 'flex';
- rowDiv.style.justifyContent = 'center';
- rowDiv.style.alignItems = 'center';
- rowDiv.style.height = '100%';
-
- let attemptLabel = ''
- let attemptClass = ''
if(studentPruefung) {
- let color = ''
-
-
- switch(studentPruefung.pruefungstyp_kurzbz) {
- case 'Termin1':
- color = 'green'
- break
- case 'Termin2':
- color = 'yellow'
- break
- case 'Termin3':
- color = 'orange'
- break
- case 'kommPruef':
- color = 'red'
- break
- }
+ // Antrittsnummer als Badge; nicht zählende Termine (entschuldigt, nicht beurteilt)
+ // tragen keine Nummer und werden neutral dargestellt
+ const attemptLabel = studentPruefung.terminal
+ ? 'K'
+ : (studentPruefung.antritt_nr ? String(studentPruefung.antritt_nr) : '–')
+ const attemptClass = studentPruefung.terminal
+ ? 'attempt-k'
+ : (studentPruefung.zaehlt ? ('attempt-t' + Math.min(studentPruefung.antritt_nr, 3)) : 'attempt-none')
- // rowDiv.style.borderLeft = `4px solid ${color}`;
- rowDiv.style.marginLeft = "6px"; // small indent so text doesn't overlap
- rowDiv.style.boxSizing = "border-box";
+ rowDiv.classList.add('pruefung-badge', attemptClass)
+ rowDiv.setAttribute('data-attempt', attemptLabel)
- switch(studentPruefung.pruefungstyp_kurzbz) {
- case 'Termin1': attemptLabel = '1'; attemptClass = 'attempt-t1'; break
- case 'Termin2': attemptLabel = '2'; attemptClass = 'attempt-t2'; break
- case 'Termin3': attemptLabel = '3'; attemptClass = 'attempt-t3'; break
- case 'kommPruef': attemptLabel = 'K'; attemptClass = 'attempt-k'; break
- }
+ // der abschliessende Termin wird in einem anderen Tool gepflegt -> keine Aktion, und
+ // der dafür reservierte Platz gehört der Notenbezeichnung
+ if(studentPruefung.terminal) rowDiv.classList.add('ohne-aktion')
+
+ const noteDefEntry = this.notenOptions.find(n => n.note == studentPruefung.note)
+ const bezeichnung = noteDefEntry?.bezeichnung || ''
+ addSlot('pruefung-note', bezeichnung).title = bezeichnung
+
+ if(antrittModus) addSlot('pruefung-datum', this.formatDatumDMY(studentPruefung.datum))
+
+ if(studentPruefung.terminal) return rowDiv
+
+ // sobald ein späterer Termin existiert ist die NOTE gesperrt, das Datum darf aber noch
+ // korrigiert werden -> Button bleibt aktiv, die Note wird im Modal gesperrt
+ const noteLocked = this.hasLaterPruefung(data, studentPruefung)
+
+ addButton(
+ this.$capitalize(this.$p.t('benotungstool/changePruefungButtonText')),
+ noteLocked ? this.$capitalize(this.$p.t('benotungstool/pruefungNoteLockedHint')) : null,
+ () => this.openPruefungModal(data, studentPruefung, field)
+ )
+
+ return rowDiv
}
-
- function createCol(content, classParam) {
- const colDiv = document.createElement('div');
- colDiv.className = classParam ?? 'col-4';
- colDiv.style.justifyContent = 'center';
- colDiv.style.alignItems = 'center';
- colDiv.style.alignContent = 'center';
- colDiv.style.height = '100%';
- if (typeof content === 'string') {
- colDiv.textContent = content;
- } else if (content instanceof HTMLElement) {
- colDiv.appendChild(content);
- }
+ // leere Zelle: Anlegen nur, wenn überhaupt noch ein Antritt möglich ist und die Spalte
+ // chronologisch nach allen bereits eingetragenen Terminen liegt
+ if(field === 'kommPruef' || !this.canAddPruefung(data)) return ''
- return colDiv;
- }
-
- if(data[field]) {
-
- const noteDefEntry = data.note ? this.notenOptions.find(n => n.note == data[field].note) : null
-
- if(attemptClass) rowDiv.classList.add('pruefung-badge', attemptClass);
- if(attemptLabel) rowDiv.setAttribute('data-attempt', attemptLabel);
-
- // Second column (note_bezeichnung)
- rowDiv.appendChild(createCol(noteDefEntry.bezeichnung || '', 'col-auto d-flex justify-content-center align-items-center'));
-
- // no actions on kommPruef allowed
- // no actions on termin1 aka pruefung 0 aka ursprüngliche note erlaubt
- if(field === 'kommPruef' || field === "Termin1") {
- // Prüfungsordnung §2: for the original Zeugnisnote (Termin1 = first Antritt) optionally
- // show its Benotungsdatum inline next to the grade
- if(field === "Termin1"
- && this.config?.SHOW_BENOTUNGSDATUM_ON_NOTENVORSCHLAG_UEBERNAHME
- && studentPruefung?.datum) {
- const dateDiv = createCol(this.formatDatumDMY(studentPruefung.datum), 'col-auto d-flex justify-content-center align-items-center')
- dateDiv.classList.add('text-muted')
- dateDiv.style.fontSize = '0.75rem'
- dateDiv.style.whiteSpace = 'nowrap'
- dateDiv.style.marginLeft = '6px'
- rowDiv.appendChild(dateDiv)
- }
- return rowDiv
- }
-
- if(data[field]?.pruefungstyp_kurzbz !== 'Termin1') {
- // once a later-dated or higher-attempt pruefung exists (e.g. a Termin3/kommPruef has
- // already been entered) the GRADE is locked, but the exam DATE may still be corrected
- // (within the neighbouring exam dates) -> keep the button enabled, lock the note in the modal
- const noteLocked = this.hasLaterOrHigherPruefung(data, data[field])
-
- // Third column (button)
- const button = document.createElement('button');
- button.className = 'btn btn-outline-secondary';
- button.textContent = this.$capitalize(this.$p.t('benotungstool/changePruefungButtonText'));
- if(noteLocked) {
- button.title = this.$capitalize(this.$p.t('benotungstool/pruefungNoteLockedHint'))
- }
- button.addEventListener('click', () => {
- this.openPruefungModal(data, data[field], field);
- });
-
- rowDiv.appendChild(createCol(button, 'col-4 d-flex justify-content-center align-items-center'));
- }
-
- return rowDiv;
-
- } else if (canAdd) { // return new btn action
- // dont render the add button in cells where a younger pruefung exists for the students
- const youngerPruefung = data.pruefungen.find(p => p.datum > field)
- if(youngerPruefung) return rowDiv
-
- const button = document.createElement('button');
- button.className = 'btn btn-outline-secondary';
- button.textContent = this.$capitalize(this.$p.t('benotungstool/addPruefungButtonText'));
- button.addEventListener('click', () => {
- this.openPruefungModal(data, null, field)
- });
-
- rowDiv.appendChild(createCol(button), 'col-4 d-flex justify-content-center align-content-center');
-
- return rowDiv;
+ if(antrittModus) {
+ // nur die unmittelbar nächste freie Antrittsspalte darf einen Neuanlage-Button zeigen
+ if(field !== ('antritt_' + ((data.pruefungen?.length ?? 0) + 1))) return ''
} else {
- return ''
+ // keine Neuanlage vor einem bereits eingetragenen Termin
+ if((data.pruefungen ?? []).some(p => p.datum >= field)) return ''
}
+
+ addButton(
+ this.$capitalize(this.$p.t('benotungstool/addPruefungButtonText')),
+ null,
+ () => this.openPruefungModal(data, null, field)
+ )
+
+ return rowDiv
},
parseISODate(iso) {
const parts = (iso ?? '').slice(0, 10).split('-')
@@ -1495,15 +1545,13 @@ export const Benotungstool = {
openPruefungModal(student, pruefung = null, field) {
this.pruefungStudent = student
this.pruefung = pruefung
- const dateStr = this.pruefung?.datum ?? field
- const pruefungDateParts = dateStr.split('-')
+ // Im Antrittsmodus trägt die Spalte kein Datum -> beim Anlegen mit heute vorbelegen.
+ const dateStr = this.pruefung?.datum ?? (/^\d{4}-\d{2}-\d{2}$/.test(field) ? field : null)
+ this.selectedPruefungDate = dateStr ? this.parseISODate(dateStr) : new Date()
- const newDate = new Date(Number(pruefungDateParts[0]), Number(pruefungDateParts[1]) - 1, Number(pruefungDateParts[2]))
- this.selectedPruefungDate = newDate
-
- // grade is locked once a later/higher pruefung exists; only the date may be corrected
- this.pruefungNoteLocked = !!(pruefung && this.hasLaterOrHigherPruefung(student, pruefung))
+ // grade is locked once a later pruefung exists; only the date may be corrected
+ this.pruefungNoteLocked = !!(pruefung && this.hasLaterPruefung(student, pruefung))
// constrain the date to stay between the neighbouring exam dates
const bounds = this.getPruefungDateBounds(student, pruefung, field)
@@ -1565,17 +1613,6 @@ export const Benotungstool = {
buildMailToLink(student){
return 'mailto:' + student.uid +'@'+ this.domain
},
- insertSortedDate(arr, dateStr) {
- // Binary search to find insertion index
- let left = 0, right = arr.length;
- while (left < right) {
- const mid = (left + right) >> 1;
- if (arr[mid] < dateStr) left = mid + 1;
- else right = mid;
- }
- arr.splice(left, 0, dateStr); // insert at index
- return arr;
- },
tableResolve(resolve) {
this.tableBuiltResolve = resolve
},
@@ -1585,7 +1622,7 @@ export const Benotungstool = {
async setupData(data){
// in case we reload when changing lva_id or stsem to always consider local storage layout
this.colLayoutRestored = false;
-
+
this.studenten = data[0] ?? []
this.studenten.forEach(s => {
s.pruefungen = []
@@ -1594,58 +1631,36 @@ export const Benotungstool = {
})
this.pruefungen = data[1] ?? []
this.domain = data[2]
-
- // contains notenvorschläge from moodle, lv_note
+
+ // contains notenvorschläge from moodle, lv_note und den Prüfungsverlauf je Student
this.teilnoten = data[3] ?? []
-
- // let pruefungenRegularColCount = 0;
+
this.distinctPruefungsDates = []
- const cols = [...this.notenTableOptions.columns.slice(0, -1)];
- let kommCol = null
- if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) kommCol = this.notenTableOptions.columns[this.notenTableOptions.columns.length - 1];
-
+
this.pruefungen?.forEach(p => {
- const dateParts = p.datum.split('-')
- p.dateObj = new Date(dateParts[0], +(dateParts[1]) - 1, dateParts[2])
-
const student = this.studenten.find(s => s.uid === p.student_uid)
if(!student) return
-
- if(p.pruefungstyp_kurzbz !== 'kommPruef'
- && p.pruefungstyp_kurzbz !== 'Termin1'
- && !this.distinctPruefungsDates.includes(p.datum)) {
- this.distinctPruefungsDates.push(p.datum)
- }
-
- // seperate kommPruefungen from previous pruefungen counts since the column count variability always ends with this
- if(p.pruefungstyp_kurzbz == 'kommPruef') {
+
+ p.dateObj = this.parseISODate(p.datum)
+
+ // der abschliessende Termin steht in einer eigenen, immer letzten Spalte
+ if(p.terminal) {
student['kommPruef'] = p
} else {
student.pruefungen.push(p)
+ if(!this.distinctPruefungsDates.includes(p.datum)) this.distinctPruefungsDates.push(p.datum)
}
-
})
+ this.distinctPruefungsDates.sort()
+
this.studenten?.forEach(s => {
- // sort students regular pruefungen by datum
- s.pruefungen.sort((p1, p2) => {
- if(p1.datum > p2.datum) {
- return 1
- } else if (p1.datum < p2.datum) {
- return -1
- } else {
- return 0
- }
- })
- // set the sorted pruefungen to their respective column fields
- s.pruefungen.forEach((p, i) => {
- // Termin1 aka originale Zeugnisnote should note create variable columns
- if(p.pruefungstyp_kurzbz == "Termin1") {
- s["Termin1"] = p
- } else {
- s[p.datum] = p
- }
- })
+ // der Server liefert die Termine bereits in Verlaufsreihenfolge; hier nur absichern
+ s.pruefungen.sort((p1, p2) => (p1.position ?? 0) - (p2.position ?? 0))
+
+ // Antrittszahl, Grenzen und die nächste mögliche Rolle kommen vom Server
+ s.verlauf = this.teilnoten[s.uid]?.verlauf ?? null
+ this.indexPruefungen(s)
s.hoechsterAntritt = this.getAntrittCountStudent(s)
// multiselect label, uid first: "uid – Nachname Vorname – Antritte: n"
@@ -1656,9 +1671,9 @@ export const Benotungstool = {
s.freigabedatum = this.parseDate(this.teilnoten[s.uid]['freigabedatum'])
s.benotungsdatum = this.parseDate(this.teilnoten[s.uid]['benotungsdatum'])
s.freigegeben = this.checkFreigabe(s.freigabedatum, s.benotungsdatum, s.uid);
-
+
s.punkte = this.teilnoten[s.uid].punkte_lv
-
+
const grades = this.teilnoten[s.uid].grades
s.teilnote = ''
s.mobility_zusatz = this.teilnoten[s.uid].mobility_zusatz
@@ -1668,111 +1683,13 @@ export const Benotungstool = {
if(notenOption.positiv) s.teilnote += (''+g.text +''+ '
')
else s.teilnote += (''+g.text +''+ '
')
})
-
- const vueThis = this
- Object.defineProperty(s, 'selectable', {
- get() {
- const kP = s.pruefungen?.find(p => p.pruefungstyp_kurzbz == 'kommPruef')
- const maxAntrittReached = s.hoechsterAntritt >= vueThis.maxAntrittCount
- return !(kP || maxAntrittReached)
-
- },
- set() {
- // empty setter so tabulator doesnt scream
- },
- enumerable: true,
- configurable: true
- })
-
- })
-
- // if we have any pruefungen at all someone must have original note/termin1 pruefung
- if(this.distinctPruefungsDates.length) {
- cols.push({
- title: this.$capitalize(this.$p.t('benotungstool/c4originalZnote')),
- field: "Termin1",
- formatter: this.pruefungFormatter,
- titleFormatter: this.pruefungTitleFormatter,
- sorter: this.pruefungSorter,
- topCalc: this.terminCalcFunc,
- topCalcFormatter: this.terminCalcFormatter,
- hozAlign:"center",
- widthGrow: 1,
- minWidth: 200,
- width: 250,
- visible: true,
- tooltip: false
- })
- }
- this.distinctPruefungsDates.sort((d1, d2) => {
- if(d1 > d2) {
- return 1
- } else if (d1 < d2) {
- return -1
- } else {
- return 0
- }
- })
- this.distinctPruefungsDates.forEach((date)=>{
- const dateparts = date.split('-')
- const titledate = `${dateparts[2]}.${dateparts[1]}.${dateparts[0]}`
-
- cols.push({
- title: titledate,
- field: date,
- formatter: this.pruefungFormatter,
- titleFormatter: this.pruefungTitleFormatter,
- sorter: this.pruefungSorter,
- topCalc: this.terminCalcFunc,
- topCalcFormatter: this.terminCalcFormatter,
- hozAlign:"center",
- widthGrow: 1,
- minWidth: 200,
- width: 250,
- visible: true,
- tooltip: false
- })
+ this.recalculateSelectable(s)
})
- if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) cols.push(kommCol) // keep kommPruef Col as last
+ // frisch geladene LV -> Spalten in jedem Fall neu aufbauen, Scrollstand bewusst zurücksetzen
+ this.applyPruefungColumns(true)
-
- // preemptively do the persistence part of columns already here so we get the merged definition before
- // tabulator internal go to war with vue reactives
- let colsUsed = null
- const saved = this.loadState();
- if (saved && saved.columns) {
- // new columns map for lookup
- const colMap = new Map(cols.map(c => [c.field, c]));
-
- const restoredCols = [];
-
- // add columns in the SAVED order, applying saved width/visibility
- saved.columns.forEach(savedCol => {
- const originalDef = colMap.get(savedCol.field);
- if (originalDef) {
- restoredCols.push({
- ...originalDef, // Keep formatters, calcs, etc.
- width: savedCol.width,
- visible: savedCol.visible
- });
- colMap.delete(savedCol.field); // Remove so we don't double-add
- }
- });
-
- // append any NEW columns aka pruefungs cols
- colMap.forEach((def) => {
- restoredCols.push(def);
- });
-
- colsUsed = restoredCols;
- this.colLayoutRestored = true;
- }
-
- const colsFinal = colsUsed ?? cols
-
- this.$refs.notenTable.tabulator.setColumns(colsFinal)
this.$refs.notenTable.tabulator.setData(this.studenten);
this.$refs.notenTable.tabulator.redraw(true);
@@ -2002,9 +1919,6 @@ export const Benotungstool = {
getOptionLabelLe(option) {
return option.infoString
},
- getPruefungstypForStudentByAntritt(student) {
- return NotenRules.pruefungstypForAntritt(student, this.config)
- },
savePruefungEingabe() {
// keep the date within the neighbouring exam dates (a typed value can bypass the picker bounds)
if((this.pruefungDateMin && this.selectedPruefungDate < this.pruefungDateMin) ||
@@ -2018,11 +1932,7 @@ export const Benotungstool = {
const day = String(this.selectedPruefungDate.getDate()).padStart(2, '0');
const dateStr = `${year}-${month}-${day}`;
- // first pruefung is always "Termin2" since normal note counts as Termin1
- // const pOffset = this.pruefung === null && this.pruefungStudent.pruefungen.length === 0 ? 2 : 1
-
- const typ = this.pruefung ? this.pruefung.pruefungstyp_kurzbz : this.getPruefungstypForStudentByAntritt(this.pruefungStudent)
- // when the grade is locked (later/higher pruefung exists) keep the existing note untouched
+ // when the grade is locked (later pruefung exists) keep the existing note untouched
const note = this.pruefungNoteLocked && this.pruefung
? this.pruefung.note
: (this.selectedPruefungNote?.note ?? 9) // noch nicht eingetragen
@@ -2039,7 +1949,6 @@ export const Benotungstool = {
this.lv_id,
this.pruefungStudent.lehreinheit_id,
this.sem_kurzbz,
- typ,
this.pruefung?.pruefung_id ?? null // null = adding a new pruefung, otherwise edit of this record
)).then(res => {
if(res.meta.status === 'success') { //'Prüfung für Student ' + this.pruefungStudent.uid + ' bearbeitet oder angelegt'
@@ -2050,182 +1959,32 @@ export const Benotungstool = {
true
)
const s = this.studenten.find(s => s.uid === res.data[1]?.student_uid)
-
- s.freigabedatum = this.parseDate(res.data[1]?.['freigabedatum'])
- s.benotungsdatum = this.parseDate(res.data[1]?.['benotungsdatum'])
-
- s.freigegeben = this.checkFreigabe(s.freigabedatum, s.benotungsdatum, s.uid);
-
- s.lv_note = res.data[1]?.note
- const oldScrollLeft = this.$refs.notenTable.tabulator.rowManager.scrollLeft
- const oldScrollTop = this.$refs.notenTable.tabulator.rowManager.scrollTop
-
- // add new pruefung to row
- if(!this.pruefung) {
- this.handleAddNewTermin(res.data, s)
- } else { // update existing
- const oldIndex = s.pruefungen.findIndex(p => p.pruefung_id == this.pruefung.pruefung_id)
- if(oldIndex !== -1) {
- s.pruefungen.splice(oldIndex, 1, res.data[0])
- s[res.data[0].datum] = res.data[0]
- }
- // antritte might have changed due to different benotung
- s.hoechsterAntritt = this.getAntrittCountStudent(s)
- this.recalculateSelectable(s)
- this.reformatStudentRow(s)
- }
-
- this.$refs.notenTable.tabulator.redraw(true)
- Vue.nextTick(()=> {
- const table = this.$refs.notenTable.tabulator.element.querySelector('.tabulator-tableholder')
- if(table) {
- table.scrollLeft = oldScrollLeft;
- table.scrollTop = oldScrollTop;
- }
+ this.applyLvGesamtnote(s, res.data[1])
+
+ // Zeile komplett aus dem Serververlauf neu aufbauen: das deckt Anlegen und
+ // Bearbeiten gleichermassen ab. Scrollstand halten, damit die bearbeitete Zeile
+ // im Blick bleibt.
+ this.colLayoutRestored = false
+ this.preserveScroll(() => {
+ this.applyVerlauf(s, res.data[2])
+ this.applyPruefungColumns()
+ this.$refs.notenTable.tabulator.redraw(true)
})
-
}
}).finally(()=> {
this.pruefungStudent = null
this.pruefung = null
this.loading = false
})
-
+
this.$refs.modalContainerPruefung.hide()
},
- handleAddNewTermin(data, student){
- this.colLayoutRestored = false; // always keep persistence options in mind when modifying table cols dynamically
-
- const savedPruefung = data[0]
- const extra = data[2]
-
- // check for extra pruefung (termin1) to add before
- if(extra) {
- student["Termin1"] = extra
- student.pruefungen.push(extra) // push to pruefungen array for antritt evaluation
- }
-
- this.correctOldTerminTypenForStudent(student, savedPruefung)
-
- if(!this.distinctPruefungsDates.includes(savedPruefung.datum)) {
- this.insertSortedDate(this.distinctPruefungsDates, savedPruefung.datum)
- }
-
- // add pruefung to pruefungen array
- student.pruefungen.push(savedPruefung)
-
- // add pruefung to student via its datum as a field
- student[savedPruefung.datum] = savedPruefung
-
- // usually should be in order naturally, just to be save
- student.pruefungen.sort((p1, p2) => {
- if(p1.datum > p2.datum) {
- return 1
- } else if (p1.datum < p2.datum) {
- return -1
- } else {
- return 0
- }
- })
-
- // recalculate student antritte
-
- student.hoechsterAntritt = this.getAntrittCountStudent(student)
- this.recalculateSelectable(student)
- this.reformatStudentRow(student)
-
- // add col to table
- const cols = [...this.notenTableOptions.columns.slice(0, -1)];
- let kommCol = null
- if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) kommCol = this.notenTableOptions.columns[this.notenTableOptions.columns.length - 1];
-
- if(this.distinctPruefungsDates.length) {
- cols.push({
- title: this.$capitalize(this.$p.t('benotungstool/c4originalZnote')),
- field: "Termin1",
- formatter: this.pruefungFormatter,
- titleFormatter: this.pruefungTitleFormatter,
- sorter: this.pruefungSorter,
- topCalc: this.terminCalcFunc,
- topCalcFormatter: this.terminCalcFormatter,
- hozAlign:"center",
- widthGrow: 1,
- minWidth: 200,
- width: 250,
- visible: true,
- tooltip: false
- })
- }
-
- this.distinctPruefungsDates.forEach((date)=>{
- const dateparts = date.split('-')
- const titledate = `${dateparts[2]}.${dateparts[1]}.${dateparts[0]}`
-
- cols.push({
- title: titledate,//this.$p.t('benotungstool/pruefungNr', [index+1]),
- field: date,
- formatter: this.pruefungFormatter,
- titleFormatter: this.pruefungTitleFormatter,
- sorter: this.pruefungSorter,
- topCalc: this.terminCalcFunc,
- topCalcFormatter: this.terminCalcFormatter,
- hozAlign:"center",
- widthGrow: 1,
- minWidth: 200,
- width: 250,
- visible: true,
- tooltip: false
- })
- })
-
- if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) cols.push(kommCol) // keep kommPruef Col as last
-
-
- // preemptively do the persistence part of columns already here so we get the merged definition before
- // tabulator internal go to war with vue reactives
- let colsUsed = null
- const saved = this.loadState();
- if (saved && saved.columns) {
- // new columns map for lookup
- const colMap = new Map(cols.map(c => [c.field, c]));
-
- const restoredCols = [];
-
- // add columns in the SAVED order, applying saved width/visibility
- saved.columns.forEach(savedCol => {
- const originalDef = colMap.get(savedCol.field);
- if (originalDef) {
- restoredCols.push({
- ...originalDef, // Keep formatters, calcs, etc.
- width: savedCol.width,
- visible: savedCol.visible
- });
- colMap.delete(savedCol.field); // Remove so we don't double-add
- }
- });
-
- // append any NEW columns aka pruefungs cols
- colMap.forEach((def) => {
- restoredCols.push(def);
- });
-
- colsUsed = restoredCols;
- this.colLayoutRestored = true;
- }
-
-
- const colsFinal = colsUsed ?? cols
- this.$refs.notenTable.tabulator.setColumns(colsFinal)
- // this.$refs.notenTable.tabulator.redraw(true)
- // redraw table outside this function
- },
recalculateSelectable(student) {
const vueThis = this
Object.defineProperty(student, 'selectable', {
get() {
- const kP = student.pruefungen?.find(p => p.pruefungstyp_kurzbz == 'kommPruef')
- return !(kP || student.hoechsterAntritt >= vueThis.maxAntrittCount)
+ return vueThis.canAddPruefung(student)
},
set() {
// empty setter so tabulator doesnt scream
@@ -2268,6 +2027,10 @@ export const Benotungstool = {
this.$refs.modalContainerNotenSpeichern.show()
},
openNewPruefungsdatumModal() {
+ // gleiche Eingaben wie im Dialog aus der Tabelle -> beide starten leer, ohne Note
+ this.selectedPruefungNote = null
+ this.selectedPruefungPunkte = null
+ this.selectedPruefungDate = new Date()
this.$refs.modalContainerNeuesPruefungsdatum.show()
},
openPruefungImportModal() {
@@ -2280,34 +2043,18 @@ export const Benotungstool = {
return option.bezeichnung
},
addPruefung(){
-
- this.$refs.modalContainerNeuesPruefungsdatum.hide()
-
- // filter students that already have a pruefung on datum
-
const year = this.selectedPruefungDate.getFullYear();
const month = String(this.selectedPruefungDate.getMonth() + 1).padStart(2, '0'); // Months are 0-based
const day = String(this.selectedPruefungDate.getDate()).padStart(2, '0');
const dateStrDb = `${year}-${month}-${day}`;
const dateStrFront = `${day}.${month}.${year}`;
-
- const uids = []
- // const uids = this.selectedUids.map(student => {
- this.selectedUids.forEach(student => {
-
- // if a student doesnt have a lv_note entered definitely dont allow to create pruefung
- if(!student.lv_note) {
- this.$fhcAlert.alertWarning('Student ' + student.uid + ' hat noch keine LV-Note eingetragen! Es wird keine Prüfung angelegt.')
- return
- }
- if(!student.note) {
- this.$fhcAlert.alertWarning('Student ' + student.uid + ' hat noch keine Zeugnis Note eingetragen! Es wird keine Prüfung angelegt.')
- return
- }
+ const uids = []
+
+ this.selectedUids.forEach(student => {
// check if student antrittCount is too high already
- if(student.hoechsterAntritt >= this.maxAntrittCount) {
+ if(!this.canAddPruefung(student)) {
this.$fhcAlert.alertWarning('Student ' + student.uid + ' hat bereits ' + student.hoechsterAntritt + ' Prüfungsantritte abgelegt. Es wird keine Prüfung angelegt.')
return
}
@@ -2323,46 +2070,53 @@ export const Benotungstool = {
uids.push({
uid: student.uid,
- lehreinheit_id: student.lehreinheit_id,
- typ: this.getPruefungstypForStudentByAntritt(student)//student.hoechsterAntritt
+ lehreinheit_id: student.lehreinheit_id
})
})
-
+
+ this.$refs.modalContainerNeuesPruefungsdatum.hide()
+
+ if(!uids.length) return
+
+ // eine fehlende LV-Note blockiert nicht: sie entsteht serverseitig mit der Note dieser
+ // Prüfung. Der Hinweis darauf steht im Dialog, siehe pruefungOhneLvNote.
this.loading = true;
this.$api.call(ApiNoten.createPruefungen(
uids,
- dateStrDb,
+ dateStrDb,
this.lv_id,
this.sem_kurzbz,
+ this.selectedPruefungNote?.note ?? null,
+ this.selectedPruefungPunkte ?? null
)).then(res => {
if(res.meta.status === "success") {
-
- // iterate over response data
+
+ // iterate over response data
// -> alert successful pruefungen
// -> alert denied pruefungen + reason
-
+
let uidListSuccess = ''
let uidListError = ''
const successData = []
Object.keys(res.data).forEach(student_uid => {
const student = res.data[student_uid]
// actual pruefung has been allocated
- if(student.savedPruefung || student.extraPruefung) {
+ if(student.savedPruefung) {
uidListSuccess += ' ' + student_uid
-
+
// keep res.data format intact for handleResponse method
successData[student_uid] = student
} else { // there should be an error message why no pruefungen where allocated for this person, many reasons possible
uidListError += student_uid + ' - ' + student +'\n'// student variable is the error message here
}
})
-
+
if(uidListError != '') {
this.$fhcAlert.alertError(
this.$capitalize(this.$p.t('benotungstool/c4pruefungAnlageError', [dateStrFront])) + ': ' + uidListError + ' '
)
}
-
+
if(uidListSuccess != '') {
this.$fhcAlert.alertDefault(
'success',
@@ -2373,7 +2127,7 @@ export const Benotungstool = {
this.handleAddNewPruefungenResponse({data: successData}, uids)
}
-
+
}
}).finally(()=> this.loading = false)
},
@@ -2443,7 +2197,7 @@ export const Benotungstool = {
const limit = limitMap[note]
if (limit == null) return
- // all fixed antritte: Termin1/2/3 sit in pruefungen, kommPruef is separate
+ // alle Termine des Verlaufs; der abschliessende steht separat an der Zeile
const allPruefungen = [...this.pruefungStudent.pruefungen]
if (this.pruefungStudent.kommPruef) allPruefungen.push(this.pruefungStudent.kommPruef)
@@ -2477,8 +2231,18 @@ export const Benotungstool = {
+ this.$p.t('global/gesamt')
+ ': ' + (this.studenten?.length || 0) + '';
},
- maxAntrittCount() {
- return NotenRules.maxAntrittCount(this.config)
+ // ausgewählte Studenten, für die noch keine LV-Note existiert: sie entsteht mit dieser
+ // Prüfung. Nur ein Hinweis im Dialog - blockiert nichts und braucht keine Bestätigung.
+ pruefungOhneLvNote() {
+ return (this.selectedUids ?? []).filter(s => NotenRules.brauchtNeueLvNote(s))
+ },
+ // derselbe Hinweis im Dialog aus der Tabelle: beide Dialoge zeigen dasselbe an
+ pruefungStudentOhneLvNote() {
+ return !this.pruefung && !!this.pruefungStudent && NotenRules.brauchtNeueLvNote(this.pruefungStudent)
+ },
+ // aktive Spaltenaufteilung: Wahl des Benutzers, sonst die Vorgabe aus der Konfiguration
+ pruefungsspaltenModus() {
+ return this.pruefungsspalten ?? this.config?.CIS_GESAMTNOTE_PRUEFUNGSSPALTEN ?? 'antritt'
},
getFreigabeCounter() {
return this.studenten ? this.studenten.reduce((acc, cur) => {
@@ -2656,7 +2420,29 @@ export const Benotungstool = {
-
+
+
+
{{$capitalize($p.t('benotungstool/c4punkte'))}}:
+
+
+
+
+
+
+
+
{{$capitalize($p.t('lehre/note'))}}:
+
+
+
+
+
+
{{$capitalize($p.t('benotungstool/prueflingSelectionv2'))}}:
@@ -2670,6 +2456,19 @@ export const Benotungstool = {
class="w-100" />
+
+
+
+
+
{{ $p.t('benotungstool/c4lvNoteWirdAngelegtHinweis') }}
+
+
+ , {{ s.vorname }} {{ s.nachname }} ({{ s.uid }})
+
+
+
+
+
@@ -2769,6 +2568,14 @@ export const Benotungstool = {
+
+
+
+
+ {{ $p.t('benotungstool/c4lvNoteWirdAngelegtHinweis') }}
+
+
+
@@ -2883,6 +2690,21 @@ export const Benotungstool = {
class="ml-2"
style="min-width: 12rem" />
+
+
+
+
+
diff --git a/public/js/components/Cis/Benotungstool/notenRules.js b/public/js/components/Cis/Benotungstool/notenRules.js
index b59dd4375..1360f9692 100644
--- a/public/js/components/Cis/Benotungstool/notenRules.js
+++ b/public/js/components/Cis/Benotungstool/notenRules.js
@@ -1,46 +1,51 @@
/**
- * Prüfungsordnung §1 rules as the client applies them. Extracted from the Vue component so they are
- * testable, and so the server-side mirrors (Noten.php: computeAntrittCount / computeMaxAntritte /
- * getPruefungstypForStudentByAntritt) have something to be compared against.
+ * Prüfungsordnung §1 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.
+ *
+ * Der lokale Fallback greift nur, solange der Verlauf für eine Zeile noch nicht geladen ist.
*/
-/** Enabled in-tool retake types, in attempt order. Termin1 is the original note, kommPruef terminal. */
-export const retakeTypes = (config) => {
- const types = [];
- if (config?.CIS_GESAMTNOTE_PRUEFUNG_TERMIN2) types.push("Termin2");
- if (config?.CIS_GESAMTNOTE_PRUEFUNG_TERMIN3) types.push("Termin3");
- return types;
-};
-
-/** Original note (always 1) plus each enabled retake. kommPruef is entered elsewhere and not capped here. */
-export const maxAntrittCount = (config) => 1 + retakeTypes(config).length;
+/** Maximale Anzahl zählender Antritte in diesem Tool (serverseitig abgeleitet). */
+export const maxAntrittCount = (config) => config?.CIS_GESAMTNOTE_MAX_ANTRITTE ?? 1;
/**
- * Counting Prüfungsantritte. Counted by NOTE, not by typ: notes in NOTEN_OHNE_ANTRITT (entschuldigt,
- * noch nicht eingetragen) never count, which is what lets Termin2 be entered twice. A kommPruef is
- * terminal and returns a sentinel >= any maxAntrittCount. With no counting pruefung the original LV
- * note is the first Antritt, unless it is a non-lehre note ("angerechnet") meaning no participation.
+ * 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.
*/
export const antrittCountStudent = (student, config, notenOptions) => {
- if (student["kommPruef"]) return 4;
+ if (student.verlauf) return student.verlauf.antrittCount;
- const ohneAntritt = config.NOTEN_OHNE_ANTRITT;
+ const ohneAntritt = config?.NOTEN_OHNE_ANTRITT ?? [];
let count = 0;
- for (const p of student.pruefungen) {
+ for (const p of student.pruefungen ?? []) {
if (!ohneAntritt.find((pk) => pk == p.note)) count++;
}
- if (count === 0 && student.note) {
- const noteOption = notenOptions.find((n) => n.note == student.note);
- return noteOption.lehre ? 1 : 0;
+ if (count === 0 && student.note && !ohneAntritt.find((pk) => pk == student.note)) {
+ const noteOption = notenOptions?.find((n) => n.note == student.note);
+ return noteOption?.lehre ? 1 : 0;
}
return count;
};
-/** Next retake type to offer. Attempt 1 is the original note, so retake n sits at index n-1. */
-export const pruefungstypForAntritt = (student, config) =>
- retakeTypes(config)[Math.max(0, student.hoechsterAntritt - 1)] ?? "";
+/** Ob für den Studenten überhaupt noch ein Antritt angelegt werden darf. */
+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.
+ */
+export const brauchtNeueLvNote = (student) => student.verlauf ? !student.verlauf.hatLvNote : !student.lv_note;
/** Grade state from the two timestamps. */
export const checkFreigabe = (freigabedatum, benotungsdatum) => {
diff --git a/system/phrasesupdate.php b/system/phrasesupdate.php
index a4186ba1d..41d955d0e 100644
--- a/system/phrasesupdate.php
+++ b/system/phrasesupdate.php
@@ -62167,6 +62167,166 @@ I have been informed that I am under no obligation to consent to the transmissio
)
)
),
+ array(
+ 'app' => 'core',
+ 'category' => 'benotungstool',
+ 'phrase' => 'c4antrittNr',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => '{0}. Antritt',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Attempt {0}',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'benotungstool',
+ 'phrase' => 'c4spaltenAntritt',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Antritte',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Attempts',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'benotungstool',
+ 'phrase' => 'c4spaltenAntrittHint',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Eine Spalte je Antritt, das Prüfungsdatum steht in der Zelle.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'One column per attempt, the exam date is shown in the cell.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'benotungstool',
+ 'phrase' => 'c4spaltenDatum',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Datum',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Date',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'benotungstool',
+ 'phrase' => 'c4spaltenDatumHint',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Eine Spalte je Prüfungsdatum.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'One column per exam date.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'benotungstool',
+ 'phrase' => 'c4lvNoteWirdAngelegtHinweis',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Es ist noch keine LV-Note eingetragen. Sie wird mit der Note dieser Prüfung angelegt und kann anschließend wie gewohnt bearbeitet und freigegeben werden.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'There is no course grade yet. It will be created from the grade of this exam and can then be edited and approved as usual.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'benotungstool',
+ 'phrase' => 'c4angerechnetKeinePruefung',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Für {0} ist die Lehrveranstaltung angerechnet. Dafür können keine Prüfungen eingetragen werden.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'The course is credited for {0}. No exams can be recorded for it.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'benotungstool',
+ 'phrase' => 'c4pruefungNichtGespeichert',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Die Prüfung für {0} konnte nicht gespeichert werden.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'The exam for {0} could not be saved.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
// CIS4 GESAMTNOTENEINGABE ENDE ------------------------------------------------------------------------------------
);
diff --git a/tests/cypress/e2e/specs/api/noten/noten.bulk.cy.js b/tests/cypress/e2e/specs/api/noten/noten.bulk.cy.js
index b51317022..3d2285e07 100644
--- a/tests/cypress/e2e/specs/api/noten/noten.bulk.cy.js
+++ b/tests/cypress/e2e/specs/api/noten/noten.bulk.cy.js
@@ -22,8 +22,7 @@ import {
} from "../../../../support/helpers/notenTestData";
import {
addPruefung,
- attemptsOfTyp,
- enabledRetakeTypes,
+ attemptsOfStudent,
readState,
} from "../../../../support/helpers/notenScenario";
@@ -41,30 +40,28 @@ describe("Noten API - bulk paths", () => {
it("rejects only the row that breaks a §1 rule and accepts the rest", () => {
const atCap = ctx.students[0];
const fresh = ctx.students[1];
- const retakes = enabledRetakeTypes(ctx);
resetNotenState(ctx);
seedBaseline(ctx, atCap.uid, { freigegeben: true });
seedBaseline(ctx, fresh.uid, { freigegeben: true });
- // drive the first student up to the cap
- retakes.forEach((typ, i) => {
+ // drive the first student up to the cap - the baseline already provides Antritt 1
+ for (let i = 0; i < ctx.maxAntritte - 1; i += 1) {
addPruefung(ctx, atCap, {
note: ctx.gradeNotes[0],
datum: attemptDate(ctx, i + 1),
- typ,
}).then((response) => {
- expectNotenSuccess(response, `bring ${atCap.uid} to the cap via ${typ}`);
+ expectNotenSuccess(response, `bring ${atCap.uid} to the cap (attempt ${i + 2})`);
});
- });
+ }
- const datum = attemptDate(ctx, retakes.length + 2);
+ const datum = attemptDate(ctx, ctx.maxAntritte + 1);
notenApi
.createPruefungen(
[
- { uid: atCap.uid, typ: retakes[retakes.length - 1], lehreinheit_id: atCap.lehreinheit_id },
- { uid: fresh.uid, typ: "Termin2", lehreinheit_id: fresh.lehreinheit_id },
+ { uid: atCap.uid, lehreinheit_id: atCap.lehreinheit_id },
+ { uid: fresh.uid, lehreinheit_id: fresh.lehreinheit_id },
],
datum,
ctx.lvId,
@@ -79,7 +76,7 @@ describe("Noten API - bulk paths", () => {
});
readState(ctx).then((data) => {
- const created = attemptsOfTyp(data, fresh.uid, "Termin2");
+ const created = attemptsOfStudent(data, fresh.uid);
expect(
created.map((p) => String(p.datum).slice(0, 10)),
"the accepted row was really created",
@@ -97,14 +94,13 @@ describe("Noten API - bulk paths", () => {
addPruefung(ctx, student, {
note: ctx.notes.nochNichtEingetragen,
datum: attemptDate(ctx, 3),
- typ: "Termin2",
}).then((response) => {
- expectNotenSuccess(response, "seed an open Termin2");
+ expectNotenSuccess(response, "seed an open attempt");
});
notenApi
.createPruefungen(
- [{ uid: student.uid, typ: "Termin2", lehreinheit_id: student.lehreinheit_id }],
+ [{ uid: student.uid, lehreinheit_id: student.lehreinheit_id }],
attemptDate(ctx, 2), // earlier than the existing attempt
ctx.lvId,
ctx.semKurzbz,
@@ -135,27 +131,21 @@ describe("Noten API - bulk paths", () => {
it("applies the §1 rules per row", () => {
const atCap = ctx.students[0];
const fresh = ctx.students[1];
- const retakes = enabledRetakeTypes(ctx);
resetNotenState(ctx);
seedBaseline(ctx, atCap.uid, { freigegeben: true });
seedBaseline(ctx, fresh.uid, { freigegeben: true });
- retakes.forEach((typ, i) => {
- addPruefung(ctx, atCap, {
- note: ctx.gradeNotes[0],
- datum: attemptDate(ctx, i + 1),
- typ,
- });
- });
+ for (let i = 0; i < ctx.maxAntritte - 1; i += 1) {
+ addPruefung(ctx, atCap, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, i + 1) });
+ }
- const datum = attemptDate(ctx, retakes.length + 2);
+ const datum = attemptDate(ctx, ctx.maxAntritte + 1);
notenApi
.savePruefungenBulk(ctx.lvId, ctx.semKurzbz, [
{
uid: atCap.uid,
- typ: retakes[retakes.length - 1],
note: ctx.gradeNotes[0],
punkte: null,
datum,
@@ -163,7 +153,6 @@ describe("Noten API - bulk paths", () => {
},
{
uid: fresh.uid,
- typ: "Termin2",
note: ctx.gradeNotes[0],
punkte: null,
datum,
diff --git a/tests/cypress/e2e/specs/api/noten/noten.frist.cy.js b/tests/cypress/e2e/specs/api/noten/noten.frist.cy.js
index 701ee13ca..0af22643f 100644
--- a/tests/cypress/e2e/specs/api/noten/noten.frist.cy.js
+++ b/tests/cypress/e2e/specs/api/noten/noten.frist.cy.js
@@ -59,8 +59,7 @@ describe("Noten API - Noteneintragungsfrist (Prüfungsordnung §1)", () => {
lva_id: ctx.lvId,
lehreinheit_id: ctx.students[0].lehreinheit_id,
sem_kurzbz: sem,
- typ: "Termin2",
- pruefung_id: null,
+ pruefung_id: null,
})
.then((response) => {
expectNotenError(response, "noteneintragungsfristVorbei");
@@ -109,7 +108,6 @@ describe("Noten API - Noteneintragungsfrist (Prüfungsordnung §1)", () => {
addPruefung(ctx, student, {
note: ctx.gradeNotes[0],
datum: attemptDate(ctx, 1),
- typ: "Termin2",
}).then((response) => {
expectNotenSuccess(response, `grade entry within the deadline for ${ctx.semKurzbz}`);
});
diff --git a/tests/cypress/e2e/specs/api/noten/noten.pruefungsantritte.cy.js b/tests/cypress/e2e/specs/api/noten/noten.pruefungsantritte.cy.js
index 6df0e371c..4a20046f0 100644
--- a/tests/cypress/e2e/specs/api/noten/noten.pruefungsantritte.cy.js
+++ b/tests/cypress/e2e/specs/api/noten/noten.pruefungsantritte.cy.js
@@ -1,12 +1,12 @@
/**
- * Prüfungsordnung §1 - Prüfungsantritte (P0, cases 1-7).
+ * 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.
*
- * Bookkeeping: the first Termin2 add also auto-creates a Termin1 row with the original LV note, so
- * one call produces two attempts. A later Termin2 add updates the existing non-excused Termin2
- * instead of inserting. "entschuldigt" and "Noch nicht eingetragen" never count towards the cap.
+ * 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.
*/
import { notenApi } from "../../../../support/api/notenApi";
@@ -21,11 +21,12 @@ import {
} from "../../../../support/helpers/notenTestData";
import {
addPruefung,
- attemptsOfTyp,
+ attemptsOfStudent,
+ countingAttemptsOfStudent,
editPruefung,
- enabledRetakeTypes,
givenBaseline,
readState,
+ verlaufOfStudent,
} from "../../../../support/helpers/notenScenario";
describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
@@ -38,21 +39,33 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
cy.log(
`LV ${ctx.lvId} / ${ctx.semKurzbz} | maxAntritte=${ctx.maxAntritte} | ` +
- `retakes=${enabledRetakeTypes(ctx).join(",") || "none"} | ` +
`entschuldigt=${ctx.notes.entschuldigt}`,
);
expect(
- enabledRetakeTypes(ctx).length,
- "needs TERMIN2 or TERMIN3 enabled, else saveStudentPruefung only answers wrongPruefungType",
- ).to.be.greaterThan(0);
+ ctx.maxAntritte,
+ "needs room for at least one retake beyond the first Antritt",
+ ).to.be.greaterThan(1);
});
});
// one student per test, so a leaked row cannot reach the next scenario
const studentFor = (index) => ctx.students[index % ctx.students.length];
- // Every rule below is keyed on these PKs. resolveSpecialNotes() resolves them from tbl_note by
+ /** Adds counting attempts until the cap is reached. Baseline already provides Antritt 1. */
+ const fillToCap = (student, firstIndex = 1) => {
+ for (let i = 0; i < ctx.maxAntritte - 1; i += 1) {
+ addPruefung(ctx, student, {
+ note: ctx.gradeNotes[0],
+ datum: attemptDate(ctx, firstIndex + i),
+ }).then((response) => {
+ expectNotenSuccess(response, `attempt ${i + 2} of ${ctx.maxAntritte}`);
+ });
+ }
+ 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.
it("resolves the special notes from tbl_note, not from config", () => {
@@ -71,41 +84,36 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
});
});
+ it("exposes the attempt cap instead of the retake type toggles", () => {
+ notenApi.getCisConfig().then((response) => {
+ const config = expectNotenSuccess(response, "getCisConfig");
+
+ expect(config.CIS_GESAMTNOTE_MAX_ANTRITTE, "the cap is derived server-side").to.eq(ctx.maxAntritte);
+ expect(config, "the client no longer needs the type toggles").to.not.have.property(
+ "CIS_GESAMTNOTE_PRUEFUNG_TERMIN2",
+ );
+ });
+ });
+
describe("Rule A - maximum number of Prüfungsantritte", () => {
it("rejects an attempt once the configured maximum is reached", () => {
const student = studentFor(0);
- const retakes = enabledRetakeTypes(ctx);
givenBaseline(ctx, student);
- // Antritt 1 is the seeded LV note; each retake type adds one row -> count reaches maxAntritte
- retakes.forEach((typ, i) => {
- addPruefung(ctx, student, {
- note: ctx.gradeNotes[0],
- datum: attemptDate(ctx, i + 1),
- typ,
- }).then((response) => {
- expectNotenSuccess(response, `add ${typ} #${i + 1} (still below the cap)`);
- });
- });
+ const nextDate = fillToCap(student);
- // one more attempt of the last enabled type -> the cap must now bite
- const lastTyp = retakes[retakes.length - 1];
- addPruefung(ctx, student, {
- note: ctx.gradeNotes[0],
- datum: attemptDate(ctx, retakes.length + 1),
- typ: lastTyp,
- }).then((response) => {
+ addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: nextDate }).then((response) => {
expectNotenError(response, "maxAntritteReached");
});
// and nothing was written for the rejected attempt
readState(ctx).then((data) => {
- const attempts = attemptsOfTyp(data, student.uid, lastTyp);
- expect(
- attempts.map((p) => String(p.datum).slice(0, 10)),
- `no ${lastTyp} may carry the rejected date`,
- ).to.not.include(attemptDate(ctx, retakes.length + 1));
+ const dates = attemptsOfStudent(data, student.uid).map((p) => String(p.datum).slice(0, 10));
+ expect(dates, "no row may carry the rejected date").to.not.include(nextDate);
+
+ expect(countingAttemptsOfStudent(data, student.uid), "exactly maxAntritte counting rows")
+ .to.have.length(ctx.maxAntritte);
});
});
});
@@ -115,12 +123,8 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
// cannot fire first and mask Rule B.
const givenOpenAttemptOn = (student, datum) => {
givenBaseline(ctx, student);
- addPruefung(ctx, student, {
- note: ctx.notes.nochNichtEingetragen,
- datum,
- typ: "Termin2",
- }).then((response) => {
- expectNotenSuccess(response, "seed an open (uncounted) Termin2");
+ addPruefung(ctx, student, { note: ctx.notes.nochNichtEingetragen, datum }).then((response) => {
+ expectNotenSuccess(response, "seed an open (uncounted) attempt");
});
};
@@ -130,28 +134,21 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
givenOpenAttemptOn(student, existing);
- addPruefung(ctx, student, {
- note: ctx.gradeNotes[0],
- datum: existing,
- typ: "Termin2",
- }).then((response) => {
+ addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: existing }).then((response) => {
expectNotenError(response, "pruefungDatumBeforeExisting");
});
});
it("rejects a new attempt dated before an existing one", () => {
const student = studentFor(1);
- const existing = attemptDate(ctx, 2);
- givenOpenAttemptOn(student, existing);
+ givenOpenAttemptOn(student, attemptDate(ctx, 2));
- addPruefung(ctx, student, {
- note: ctx.gradeNotes[0],
- datum: attemptDate(ctx, 1),
- typ: "Termin2",
- }).then((response) => {
- expectNotenError(response, "pruefungDatumBeforeExisting");
- });
+ addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 1) }).then(
+ (response) => {
+ expectNotenError(response, "pruefungDatumBeforeExisting");
+ },
+ );
});
it("accepts a new attempt dated after every existing one", () => {
@@ -159,13 +156,11 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
givenOpenAttemptOn(student, attemptDate(ctx, 2));
- addPruefung(ctx, student, {
- note: ctx.gradeNotes[0],
- datum: attemptDate(ctx, 3),
- typ: "Termin2",
- }).then((response) => {
- expectNotenSuccess(response, "attempt strictly after the existing one");
- });
+ addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 3) }).then(
+ (response) => {
+ expectNotenSuccess(response, "attempt strictly after the existing one");
+ },
+ );
});
});
@@ -175,27 +170,19 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
givenBaseline(ctx, student);
- addPruefung(ctx, student, {
- note: ctx.notes.entschuldigt,
- datum: attemptDate(ctx, 1),
- typ: "Termin2",
- }).then((response) => {
- expectNotenSuccess(response, "first entschuldigt attempt");
- });
+ addPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: attemptDate(ctx, 1) }).then(
+ (response) => expectNotenSuccess(response, "first entschuldigt attempt"),
+ );
- addPruefung(ctx, student, {
- note: ctx.notes.entschuldigt,
- datum: attemptDate(ctx, 2),
- typ: "Termin2",
- }).then((response) => {
- expectNotenError(response, "noteOccuranceLimitReached");
- });
+ addPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: attemptDate(ctx, 2) }).then(
+ (response) => expectNotenError(response, "noteOccuranceLimitReached"),
+ );
readState(ctx).then((data) => {
- const excused = attemptsOfTyp(data, student.uid, "Termin2").filter(
+ const excused = attemptsOfStudent(data, student.uid).filter(
(p) => String(p.note) === String(ctx.notes.entschuldigt),
);
- expect(excused, "exactly one excused Termin2 may exist").to.have.length(1);
+ expect(excused, "exactly one excused attempt may exist").to.have.length(1);
});
});
});
@@ -206,24 +193,24 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
givenBaseline(ctx, student);
- addPruefung(ctx, student, {
- note: ctx.notes.entschuldigt,
- datum: attemptDate(ctx, 1),
- typ: "Termin2",
- }).then((response) => {
- expectNotenSuccess(response, "excused attempt");
- });
+ addPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: attemptDate(ctx, 1) }).then(
+ (response) => expectNotenSuccess(response, "excused attempt"),
+ );
// excused must not count, so this real grade is still within the cap
- addPruefung(ctx, student, {
- note: ctx.gradeNotes[0],
- datum: attemptDate(ctx, 2),
- typ: "Termin2",
- }).then((response) => {
- expectNotenSuccess(
- response,
- "a real grade after an excused attempt (excused must not count towards the cap)",
- );
+ addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 2) }).then(
+ (response) =>
+ expectNotenSuccess(
+ response,
+ "a real grade after an excused attempt (excused must not count towards the cap)",
+ ),
+ );
+
+ readState(ctx).then((data) => {
+ expect(
+ verlaufOfStudent(data, student.uid).antrittCount,
+ "baseline + the real grade, the excused one not counted",
+ ).to.eq(2);
});
});
@@ -232,31 +219,23 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
givenBaseline(ctx, student);
- addPruefung(ctx, student, {
- note: ctx.notes.entschuldigt,
- datum: attemptDate(ctx, 1),
- typ: "Termin2",
- });
- addPruefung(ctx, student, {
- note: ctx.gradeNotes[0],
- datum: attemptDate(ctx, 2),
- typ: "Termin2",
- });
+ addPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: attemptDate(ctx, 1) });
+ addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 2) });
readState(ctx).then((data) => {
- const termin2 = attemptsOfTyp(data, student.uid, "Termin2");
+ const attempts = attemptsOfStudent(data, student.uid);
- expect(termin2, "the excused row and the new row must coexist").to.have.length(2);
+ expect(attempts, "baseline, the excused row and the new row must coexist").to.have.length(3);
- expect(String(termin2[0].note), "older row keeps the excused grade").to.eq(
+ expect(String(attempts[1].note), "second row keeps the excused grade").to.eq(
String(ctx.notes.entschuldigt),
);
- expect(String(termin2[0].datum).slice(0, 10)).to.eq(attemptDate(ctx, 1));
+ expect(String(attempts[1].datum).slice(0, 10)).to.eq(attemptDate(ctx, 1));
- expect(String(termin2[1].note), "newer row carries the real grade").to.eq(
+ expect(String(attempts[2].note), "third row carries the real grade").to.eq(
String(ctx.gradeNotes[0]),
);
- expect(String(termin2[1].datum).slice(0, 10)).to.eq(attemptDate(ctx, 2));
+ expect(String(attempts[2].datum).slice(0, 10)).to.eq(attemptDate(ctx, 2));
});
});
});
@@ -275,12 +254,14 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
});
// dated after the kommPruef, so only Rule A can fire
- addPruefung(ctx, student, {
- note: ctx.gradeNotes[0],
- datum: attemptDate(ctx, 2),
- typ: enabledRetakeTypes(ctx)[0],
- }).then((response) => {
- expectNotenError(response, "maxAntritteReached");
+ addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 2) }).then(
+ (response) => expectNotenError(response, "maxAntritteReached"),
+ );
+
+ readState(ctx).then((data) => {
+ const verlauf = verlaufOfStudent(data, student.uid);
+ expect(verlauf.terminal, "the Verlauf is marked terminal").to.be.true;
+ expect(verlauf.canAdd, "and closed for further attempts").to.be.false;
});
});
});
@@ -288,64 +269,68 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
describe("'Noch nicht eingetragen' does not consume an attempt", () => {
it("leaves the full cap available after an uncounted attempt", () => {
const student = studentFor(1);
- const retakes = enabledRetakeTypes(ctx);
givenBaseline(ctx, student);
- // occupies a date without counting; the first retake add also snapshots Termin1
+ // occupies a date without counting
addPruefung(ctx, student, {
note: ctx.notes.nochNichtEingetragen,
datum: attemptDate(ctx, 1),
- typ: retakes[0],
- }).then((response) => {
- expectNotenSuccess(response, "uncounted attempt");
- });
+ }).then((response) => expectNotenSuccess(response, "uncounted attempt"));
- retakes.forEach((typ, i) => {
- addPruefung(ctx, student, {
- note: ctx.gradeNotes[0],
- datum: attemptDate(ctx, i + 2),
- typ,
- }).then((response) => {
- expectNotenSuccess(response, `${typ} after an uncounted attempt`);
- });
- });
+ // the whole remaining cap is still available afterwards
+ const nextDate = fillToCap(student, 2);
- // only now is the cap reached - the uncounted attempt consumed nothing
- addPruefung(ctx, student, {
- note: ctx.gradeNotes[0],
- datum: attemptDate(ctx, retakes.length + 2),
- typ: retakes[retakes.length - 1],
- }).then((response) => {
+ addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: nextDate }).then((response) => {
expectNotenError(response, "maxAntritteReached");
});
});
});
+ describe("Anrechnung - no Prüfungen at all", () => {
+ it("refuses an attempt while the Zeugnisnote is angerechnet", () => {
+ const angerechnet = ctx.notes.angerechnet ?? ctx.notes.internAngerechnet;
+ if (!angerechnet) {
+ cy.log("no Anrechnungsnote in tbl_note - skipped");
+ 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
+ readState(ctx).then((data) => {
+ const target = (data[0] || []).find((s) => String(s.note) === String(angerechnet));
+ if (!target) {
+ cy.log(`no student with Zeugnisnote ${angerechnet} in LV ${ctx.lvId} - skipped`);
+ return;
+ }
+
+ const verlauf = verlaufOfStudent(data, target.uid);
+ expect(verlauf.angerechnet, "the Verlauf marks the Anrechnung").to.be.true;
+ expect(verlauf.canAdd, "and blocks further attempts").to.be.false;
+
+ addPruefung(ctx, target, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 1) }).then(
+ (response) => expectNotenError(response, "c4angerechnetKeinePruefung"),
+ );
+ });
+ });
+ });
+
describe("edit guards", () => {
/**
- * Builds: Termin1 (baseline date) < excused Termin2 (attempt 1) < real Termin2 (attempt 2)
+ * 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.
*/
const givenThreeAttempts = (student) => {
givenBaseline(ctx, student);
- addPruefung(ctx, student, {
- note: ctx.notes.entschuldigt,
- datum: attemptDate(ctx, 1),
- typ: "Termin2",
- });
- addPruefung(ctx, student, {
- note: ctx.gradeNotes[0],
- datum: attemptDate(ctx, 2),
- typ: "Termin2",
- });
+ addPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: attemptDate(ctx, 1) });
+ addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 2) });
return readState(ctx).then((data) => {
- const excused = attemptsOfTyp(data, student.uid, "Termin2").find(
+ const excused = attemptsOfStudent(data, student.uid).find(
(p) => String(p.note) === String(ctx.notes.entschuldigt),
);
- expect(excused, "excused Termin2 to edit").to.exist;
+ expect(excused, "excused attempt to edit").to.exist;
return excused;
});
};
@@ -358,10 +343,7 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
pruefungId: excused.pruefung_id,
note: ctx.gradeNotes[1], // different grade -> locked
datum: attemptDate(ctx, 1),
- typ: "Termin2",
- }).then((response) => {
- expectNotenError(response, "pruefungNoteLocked");
- });
+ }).then((response) => expectNotenError(response, "pruefungNoteLocked"));
});
});
@@ -374,42 +356,44 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
pruefungId: excused.pruefung_id,
note: excused.note,
datum: shiftDate(attemptDate(ctx, 1), 3),
- typ: "Termin2",
- }).then((response) => {
- // NOTE: savePruefungstermin() does not receive pruefung_id - it re-derives the target
- // as "the first non-excused Termin2" (Noten.php:943-968). The validation under test
- // is what this asserts; the resulting row placement is covered separately.
- expectNotenSuccess(response, "date-only correction inside the neighbour bounds");
- });
+ })
+ .then((response) => expectNotenSuccess(response, "date-only correction inside the bounds"))
+ .then(() => readState(ctx))
+ .then((data) => {
+ const moved = attemptsOfStudent(data, student.uid).find(
+ (p) => p.pruefung_id === excused.pruefung_id,
+ );
+ expect(moved, "the addressed row still exists").to.exist;
+ expect(String(moved.datum).slice(0, 10), "and it is the one that moved").to.eq(
+ shiftDate(attemptDate(ctx, 1), 3),
+ );
+ });
});
});
- // The add-time limit is covered by Rule C; this is the edit-time re-check
- // (validatePruefungEdit:1167). Its converse - re-saving the excused row itself, which the
- // exclusion must let through - is the date-only correction above.
+ // 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.
it("rejects an edit that would exceed the entschuldigt limit", () => {
const student = studentFor(0);
- // the real Termin2 is the latest attempt, so no note lock masks the limit
+ // the real attempt is the latest one, so no note lock masks the limit
givenThreeAttempts(student)
.then(() => readState(ctx))
.then((data) =>
- attemptsOfTyp(data, student.uid, "Termin2").find(
- (p) => String(p.note) !== String(ctx.notes.entschuldigt),
+ attemptsOfStudent(data, student.uid).find(
+ (p) => String(p.datum).slice(0, 10) === attemptDate(ctx, 2),
),
)
.then((real) => {
- expect(real, "the non-excused Termin2").to.exist;
+ expect(real, "the latest attempt").to.exist;
return editPruefung(ctx, student, {
pruefungId: real.pruefung_id,
note: ctx.notes.entschuldigt,
datum: attemptDate(ctx, 2),
- typ: "Termin2",
});
})
- .then((response) => {
- expectNotenError(response, "noteOccuranceLimitReached");
- });
+ .then((response) => expectNotenError(response, "noteOccuranceLimitReached"));
});
it("rejects a date on or before the previous attempt", () => {
@@ -419,11 +403,8 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
editPruefung(ctx, student, {
pruefungId: excused.pruefung_id,
note: excused.note,
- datum: baselineDate(ctx), // == the Termin1 date -> not strictly after
- typ: "Termin2",
- }).then((response) => {
- expectNotenError(response, "pruefungDatumOutOfRange");
- });
+ datum: baselineDate(ctx), // == the Antritt-1 date -> not strictly after
+ }).then((response) => expectNotenError(response, "pruefungDatumOutOfRange"));
});
});
@@ -434,11 +415,8 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
editPruefung(ctx, student, {
pruefungId: excused.pruefung_id,
note: excused.note,
- datum: attemptDate(ctx, 2), // == the later Termin2 -> not strictly before
- typ: "Termin2",
- }).then((response) => {
- expectNotenError(response, "pruefungDatumOutOfRange");
- });
+ datum: attemptDate(ctx, 2), // == the later attempt -> not strictly before
+ }).then((response) => expectNotenError(response, "pruefungDatumOutOfRange"));
});
});
});
diff --git a/tests/cypress/e2e/specs/api/noten/noten.pruefungstermin.cy.js b/tests/cypress/e2e/specs/api/noten/noten.pruefungstermin.cy.js
index f6aa2d554..ff3d07f2d 100644
--- a/tests/cypress/e2e/specs/api/noten/noten.pruefungstermin.cy.js
+++ b/tests/cypress/e2e/specs/api/noten/noten.pruefungstermin.cy.js
@@ -1,16 +1,15 @@
/**
- * savePruefungstermin - the write path shared by saveStudentPruefung, createPruefungen and
- * savePruefungenBulk. The existing specs cover the validators; this one covers the writer.
+ * savePruefungFuerStudent / savePruefungstermin - the write path shared by saveStudentPruefung,
+ * createPruefungen and savePruefungenBulk. The other specs cover the validators; this one covers
+ * the writer.
+ *
+ * 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.
*/
-import {
- expectBulkRowError,
- expectNotenError,
- expectNotenSuccess,
-} from "../../../../support/helpers/notenErrors";
+import { expectNotenSuccess } from "../../../../support/helpers/notenErrors";
import {
attemptDate,
- baselineDate,
loadNotenContext,
readLvGesamtnote,
requireDbReset,
@@ -20,75 +19,118 @@ import {
} from "../../../../support/helpers/notenTestData";
import {
addPruefung,
- attemptsOfTyp,
+ attemptsOfStudent,
editPruefung,
givenBaseline,
readState,
+ verlaufOfStudent,
} from "../../../../support/helpers/notenScenario";
-import { notenApi, pruefungenOfTyp } from "../../../../support/api/notenApi";
+import { notenApi, pruefungenOf } from "../../../../support/api/notenApi";
const dayOf = (value) => String(value).slice(0, 10);
-describe("Noten API - savePruefungstermin (write path)", () => {
+describe("Noten API - Prüfungstermin (write path)", () => {
let ctx;
before(() => {
requireDbReset();
loadNotenContext().then((context) => {
ctx = context;
- expect(
- context.cisConfig.CIS_GESAMTNOTE_PRUEFUNG_TERMIN2,
- "needs TERMIN2 enabled - every test here writes a Termin2",
- ).to.be.ok;
+ expect(context.maxAntritte, "needs room for at least one retake").to.be.greaterThan(1);
});
});
const studentFor = (index) => ctx.students[index % ctx.students.length];
- it("auto-creates Termin1 carrying the original LV note and its benotungsdatum", () => {
+ it("writes exactly one Prüfung per add - no snapshot alongside it", () => {
const student = studentFor(0);
givenBaseline(ctx, student);
- addPruefung(ctx, student, { note: ctx.gradeNotes[1], datum: attemptDate(ctx, 1) }).then((response) => {
- const [saved, , extra] = expectNotenSuccess(response, "first Termin2");
+ addPruefung(ctx, student, { note: ctx.gradeNotes[1], datum: attemptDate(ctx, 1) })
+ .then((response) => {
+ const [saved, lvgesamtnote, verlauf] = expectNotenSuccess(response, "add an attempt");
- expect(extra, "Termin1 snapshot in extraPruefung").to.exist;
- expect(extra.pruefungstyp_kurzbz).to.eq("Termin1");
- expect(String(extra.note), "snapshot keeps the pre-retake LV note").to.eq(String(ctx.gradeNotes[0]));
- expect(dayOf(extra.datum), "snapshot keeps the original benotungsdatum").to.eq(baselineDate(ctx));
-
- expect(saved.pruefungstyp_kurzbz).to.eq("Termin2");
- expect(String(saved.note)).to.eq(String(ctx.gradeNotes[1]));
- });
+ expect(saved, "savedPruefung").to.exist;
+ expect(String(saved.note)).to.eq(String(ctx.gradeNotes[1]));
+ expect(lvgesamtnote, "lvgesamtnote is returned so the client can refresh the row").to.exist;
+ expect(verlauf, "verlauf is returned").to.exist;
+ expect(verlauf.pruefungen, "verlauf carries the attempts").to.be.an("array");
+ })
+ .then(() => readState(ctx))
+ .then((data) => {
+ const attempts = attemptsOfStudent(data, student.uid);
+ expect(attempts, "baseline Antritt 1 plus exactly one added row").to.have.length(2);
+ });
});
- it("returns extraPruefung null once Termin1 exists", () => {
- // On PHP 8 the null lands in count() (Noten.php:822) and turns this into a 500.
+ it("derives position and Antrittsnummer server-side", () => {
const student = studentFor(1);
givenBaseline(ctx, student);
addPruefung(ctx, student, { note: ctx.gradeNotes[1], datum: attemptDate(ctx, 1) })
- .then((response) => expectNotenSuccess(response, "first Termin2")[0])
- .then((created) =>
- editPruefung(ctx, student, {
- pruefungId: created.pruefung_id,
- note: ctx.gradeNotes[0],
- datum: attemptDate(ctx, 1),
- }),
- )
- .then((response) => {
- const [saved, lvgesamtnote, extra] = expectNotenSuccess(response, "edit of the Termin2");
+ .then(() => readState(ctx))
+ .then((data) => {
+ const attempts = attemptsOfStudent(data, student.uid);
- expect(saved, "savedPruefung").to.exist;
- expect(extra, "no second Termin1 is created").to.be.null;
- expect(lvgesamtnote, "lvgesamtnote").to.exist;
+ expect(attempts.map((p) => p.position), "positions are 1..n in date order").to.deep.eq([1, 2]);
+ expect(attempts.map((p) => p.antritt_nr), "both count as attempts").to.deep.eq([1, 2]);
+ attempts.forEach((p) => expect(p.zaehlt, `zaehlt of position ${p.position}`).to.be.true);
+
+ const verlauf = verlaufOfStudent(data, student.uid);
+ expect(verlauf.antrittCount, "both attempts counted").to.eq(2);
+ expect(verlauf.maxAntritte, "the cap comes from the server").to.eq(ctx.maxAntritte);
+ expect(verlauf.canAdd, `canAdd with 2 of ${ctx.maxAntritte} used`).to.eq(2 < ctx.maxAntritte);
+ });
+ });
+
+ it("does not count an excused attempt, and keeps it as its own row", () => {
+ const student = studentFor(2);
+
+ givenBaseline(ctx, student);
+
+ addPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: attemptDate(ctx, 1) })
+ .then((response) => expectNotenSuccess(response, "excused attempt"))
+ .then(() => readState(ctx))
+ .then((data) => {
+ const attempts = attemptsOfStudent(data, student.uid);
+
+ expect(attempts, "the excused row is kept, not merged").to.have.length(2);
+
+ const excused = attempts[1];
+ expect(excused.zaehlt, "excused consumes no attempt").to.be.false;
+ expect(excused.antritt_nr, "and carries no Antrittsnummer").to.be.null;
+
+ expect(verlaufOfStudent(data, student.uid).antrittCount, "still one attempt used").to.eq(1);
+ });
+ });
+
+ 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.
+ const student = studentFor(0);
+
+ givenBaseline(ctx, student);
+
+ let firstId;
+ addPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: attemptDate(ctx, 1) })
+ .then((response) => {
+ firstId = expectNotenSuccess(response, "first add")[0].pruefung_id;
+ return addPruefung(ctx, student, { note: ctx.gradeNotes[1], datum: attemptDate(ctx, 2) });
+ })
+ .then((response) => expectNotenSuccess(response, "second add"))
+ .then(() => readState(ctx))
+ .then((data) => {
+ const attempts = attemptsOfStudent(data, student.uid);
+
+ expect(attempts, "baseline + two added rows").to.have.length(3);
+ expect(attempts.map((p) => p.pruefung_id), "the first add still exists").to.include(firstId);
});
});
it("edits the pruefung identified by pruefung_id", () => {
- // An excused and a real Termin2 side by side - no endpoint builds this, hence the direct seed
+ // An excused and a real attempt side by side - no endpoint builds this, hence the direct seed
const student = studentFor(2);
const excusedDate = attemptDate(ctx, 1);
const gradedDate = attemptDate(ctx, 2);
@@ -99,7 +141,6 @@ describe("Noten API - savePruefungstermin (write path)", () => {
let excusedId;
seedPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: excusedDate, typ: "Termin2" })
.then((seeded) => {
- console.log('ctx', ctx)
excusedId = seeded.pruefungId;
return seedPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: gradedDate, typ: "Termin2" });
})
@@ -111,133 +152,111 @@ describe("Noten API - savePruefungstermin (write path)", () => {
datum: movedTo,
}),
)
- .then((response) => expectNotenSuccess(response, "moving the excused Termin2"))
+ .then((response) => expectNotenSuccess(response, "moving the excused attempt"))
.then(() => readState(ctx))
.then((data) => {
- const rows = pruefungenOfTyp(data, student.uid, "Termin2");
+ const rows = pruefungenOf(data, student.uid);
const excused = rows.find((p) => p.pruefung_id === excusedId);
- const graded = rows.find((p) => p.pruefung_id !== excusedId);
+ const graded = rows.find((p) => dayOf(p.datum) === gradedDate);
// the untargeted row first: silently rewriting a real grade is the worse outcome
- expect(graded, "the other Termin2 still exists").to.exist;
- expect(String(graded.note), "the untargeted Termin2 keeps its grade").to.eq(
+ expect(graded, "the other attempt still exists").to.exist;
+ expect(String(graded.note), "the untargeted attempt keeps its grade").to.eq(
String(ctx.gradeNotes[0]),
);
- expect(dayOf(graded.datum), "the untargeted Termin2 keeps its date").to.eq(gradedDate);
- expect(excused, "the excused Termin2 still exists").to.exist;
+ expect(excused, "the excused attempt still exists").to.exist;
expect(dayOf(excused.datum), "the row named by pruefung_id moved").to.eq(movedTo);
});
});
it("adds an attempt while the LV note is still offen", () => {
- // freigegeben:false -> getLvGesamtNoten hides the row, so the writer takes the INSERT branch
- // against an existing primary key.
+ // 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.
const student = studentFor(3);
givenBaseline(ctx, student, { freigegeben: false });
addPruefung(ctx, student, { note: ctx.gradeNotes[1], datum: attemptDate(ctx, 1) }).then((response) => {
- expectNotenSuccess(response, "Termin2 on an un-freigegebene LV note");
+ expectNotenSuccess(response, "attempt on an un-freigegebene LV note");
});
});
- it("updates the existing Termin2 instead of appending a second one", () => {
- // The cap has room (Termin3 is on here), so the second add is allowed and must land on the
- // same row - the update branch is otherwise only reached through an edit.
- const student = studentFor(0);
-
- givenBaseline(ctx, student);
-
- let firstId;
- addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 1) })
- .then((response) => {
- firstId = expectNotenSuccess(response, "first Termin2")[0].pruefung_id;
- return addPruefung(ctx, student, { note: ctx.gradeNotes[1], datum: attemptDate(ctx, 2) });
- })
- .then((response) => expectNotenSuccess(response, "second Termin2"))
- .then(() => readState(ctx))
- .then((data) => {
- const rows = attemptsOfTyp(data, student.uid, "Termin2");
-
- expect(rows, "the second add must not append a row").to.have.length(1);
- expect(rows[0].pruefung_id, "same row").to.eq(firstId);
- expect(String(rows[0].note)).to.eq(String(ctx.gradeNotes[1]));
- expect(dayOf(rows[0].datum)).to.eq(attemptDate(ctx, 2));
- });
- });
-
it("stores an empty note as 'Noch nicht eingetragen'", () => {
const student = studentFor(1);
givenBaseline(ctx, student);
addPruefung(ctx, student, { note: "", datum: attemptDate(ctx, 1) }).then((response) => {
- const [saved] = expectNotenSuccess(response, "Termin2 without a grade");
+ const [saved] = expectNotenSuccess(response, "attempt without a grade");
expect(String(saved.note), "empty note is normalised").to.eq(
String(ctx.notes.nochNichtEingetragen),
);
});
});
- describe("invalid pruefungstyp", () => {
- // The only server-side typ check: which typ an attempt becomes is otherwise decided purely by
- // the frontend (getPruefungstypForStudentByAntritt).
- it("refuses a typ that is not an enabled retake", () => {
- const student = studentFor(2);
+ it("writes the LV note from the attempt's grade", () => {
+ const student = studentFor(3);
- givenBaseline(ctx, student);
+ givenBaseline(ctx, student);
- // Termin1 is the snapshot, kommPruef is entered elsewhere, the third is nonsense
- ["Termin1", "kommPruef", "NichtsDergleichen"].forEach((typ) => {
- addPruefung(ctx, student, {
- note: ctx.gradeNotes[1],
- datum: attemptDate(ctx, 1),
- typ,
- }).then((response) => {
- expectNotenError(response, "wrongPruefungType");
- });
+ addPruefung(ctx, student, { note: ctx.gradeNotes[1], datum: attemptDate(ctx, 1) })
+ .then((response) => expectNotenSuccess(response, "attempt with a grade"))
+ .then(() => readLvGesamtnote(ctx, student.uid))
+ .then((row) => {
+ expect(String(row.note), "the LV note follows the newest attempt").to.eq(
+ String(ctx.gradeNotes[1]),
+ );
});
- });
-
-
- it("leaves the LV note untouched when the typ is rejected", () => {
- const student = studentFor(3);
-
- givenBaseline(ctx, student);
-
- addPruefung(ctx, student, {
- note: ctx.gradeNotes[1],
- datum: attemptDate(ctx, 1),
- typ: "NichtsDergleichen",
- })
- .then((response) => expectNotenError(response, "wrongPruefungType"))
- .then(() => readLvGesamtnote(ctx, student.uid))
- .then((row) => {
- expect(String(row.note), "a rejected request must not re-grade the student").to.eq(
- String(ctx.gradeNotes[0]),
- );
- });
- });
});
- it("refuses a Prüfung for a student who has no LV note yet", () => {
- // Only reachable through the bulk path: saveStudentPruefung inserts the missing LV note itself,
- // so its own guard (Noten.php:839) can never fire.
+ 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.
const student = studentFor(0);
resetNotenState(ctx);
notenApi
.createPruefungen(
- [{ uid: student.uid, typ: "Termin2", lehreinheit_id: student.lehreinheit_id }],
+ [{ uid: student.uid, lehreinheit_id: student.lehreinheit_id }],
attemptDate(ctx, 1),
ctx.lvId,
ctx.semKurzbz,
)
.then((response) => {
const data = expectNotenSuccess(response, "createPruefungen without an LV note");
- expectBulkRowError(data, student.uid, "c4keineLvNoteEingetragen");
+ expect(data[student.uid], `row result for ${student.uid}`).to.be.an("object");
+ expect(data[student.uid].savedPruefung, "the Prüfung was written").to.exist;
+ })
+ .then(() => readLvGesamtnote(ctx, student.uid))
+ .then((row) => {
+ expect(row, "the LV note was created alongside").to.not.be.null;
+ expect(String(row.note), "carries the Prüfung's note").to.eq(
+ String(ctx.notes.nochNichtEingetragen),
+ );
});
});
+
+ it("applies a chosen note to both the Prüfung and the LV note in the bulk path", () => {
+ const student = studentFor(1);
+
+ resetNotenState(ctx);
+
+ notenApi
+ .createPruefungen(
+ [{ uid: student.uid, lehreinheit_id: student.lehreinheit_id }],
+ attemptDate(ctx, 1),
+ ctx.lvId,
+ ctx.semKurzbz,
+ ctx.gradeNotes[1],
+ )
+ .then((response) => {
+ const data = expectNotenSuccess(response, "createPruefungen with a note");
+ expect(String(data[student.uid].savedPruefung[0].note)).to.eq(String(ctx.gradeNotes[1]));
+ })
+ .then(() => readLvGesamtnote(ctx, student.uid))
+ .then((row) => expect(String(row.note)).to.eq(String(ctx.gradeNotes[1])));
+ });
});
diff --git a/tests/cypress/support/api/notenApi.js b/tests/cypress/support/api/notenApi.js
index 2f247cc3e..9dc9993ef 100644
--- a/tests/cypress/support/api/notenApi.js
+++ b/tests/cypress/support/api/notenApi.js
@@ -41,12 +41,16 @@ 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, extraPruefung(auto-created Termin1)] */
+ /**
+ * 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).
+ */
saveStudentPruefung: ({
- student_uid, note, punkte = null, datum, lva_id, lehreinheit_id, sem_kurzbz, typ, pruefung_id = null,
+ student_uid, note, punkte = null, datum, lva_id, lehreinheit_id, sem_kurzbz, pruefung_id = null,
}) =>
apiPost("saveStudentPruefung", {
- student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, typ, pruefung_id,
+ student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, pruefung_id,
}),
/** LDAP-password gated. data -> [{uid, freigabedatum, benotungsdatum}] */
@@ -64,8 +68,9 @@ export const notenApi = {
savePruefungenBulk: (lv_id, sem_kurzbz, pruefungen) =>
apiPost("savePruefungenBulk", { lv_id, sem_kurzbz, pruefungen }),
- createPruefungen: (uids, datum, lva_id, sem_kurzbz) =>
- apiPost("createPruefungen", { uids, datum, lva_id, sem_kurzbz }),
+ // note/punkte are optional; without them the Prüfung is created as "Noch nicht eingetragen"
+ createPruefungen: (uids, datum, lva_id, sem_kurzbz, note = null, punkte = null) =>
+ apiPost("createPruefungen", { uids, datum, lva_id, sem_kurzbz, note, punkte }),
};
// --- selectors over the getStudentenNoten payload ---
@@ -73,7 +78,21 @@ 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.
+ */
+export const attemptsOf = (data, uid) =>
+ [...pruefungenOf(data, uid)].sort((a, b) => Number(a.position) - Number(b.position));
+
+/** Only the attempts that consume one - excused / "noch nicht eingetragen" / nicht beurteilt do not. */
+export const countingAttemptsOf = (data, uid) => attemptsOf(data, uid).filter((p) => p.zaehlt);
+
+/** Legacy projection written for old reports. Asserted in exactly one spec, never used as a rule. */
export const pruefungenOfTyp = (data, uid, typ) =>
pruefungenOf(data, uid).filter((p) => p.pruefungstyp_kurzbz === typ);
export const gradesOf = (data, uid) => (data[3] || {})[uid];
+
+/** Server-derived rule state per student: antrittCount, maxAntritte, canAdd, terminal, angerechnet. */
+export const verlaufOf = (data, uid) => (gradesOf(data, uid) || {}).verlauf;
diff --git a/tests/cypress/support/helpers/notenScenario.js b/tests/cypress/support/helpers/notenScenario.js
index eb2d93dea..63df308e0 100644
--- a/tests/cypress/support/helpers/notenScenario.js
+++ b/tests/cypress/support/helpers/notenScenario.js
@@ -1,19 +1,19 @@
/** Building attempt histories and reading the resulting state. */
-import { notenApi, pruefungenOfTyp, gradesOf } from "../api/notenApi";
+import { notenApi, attemptsOf, countingAttemptsOf, gradesOf, verlaufOf } from "../api/notenApi";
import { expectNotenSuccess } from "./notenErrors";
import { resetNotenState, seedBaseline } from "./notenTestData";
-import { retakeTypes } from "../../../../public/js/components/Cis/Benotungstool/notenRules.js";
-
-/** The real client rule, so a divergence from the server shows up as a failing API spec. */
-export const enabledRetakeTypes = (context) => retakeTypes(context.cisConfig);
/**
* Adds an attempt (no pruefung_id -> server runs validatePruefungAdd).
+ *
+ * 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 (Noten.php:692).
+ * the note from the Notenschlüssel and override the grade under test.
*/
-export const addPruefung = (context, student, { note, datum, typ = "Termin2" }) =>
+export const addPruefung = (context, student, { note, datum }) =>
notenApi.saveStudentPruefung({
student_uid: student.uid,
note,
@@ -22,12 +22,11 @@ export const addPruefung = (context, student, { note, datum, typ = "Termin2" })
lva_id: context.lvId,
lehreinheit_id: student.lehreinheit_id,
sem_kurzbz: context.semKurzbz,
- typ,
pruefung_id: null,
});
/** Edits an attempt (pruefung_id set -> server runs validatePruefungEdit). */
-export const editPruefung = (context, student, { pruefungId, note, datum, typ = "Termin2" }) =>
+export const editPruefung = (context, student, { pruefungId, note, datum }) =>
notenApi.saveStudentPruefung({
student_uid: student.uid,
note,
@@ -36,7 +35,6 @@ export const editPruefung = (context, student, { pruefungId, note, datum, typ =
lva_id: context.lvId,
lehreinheit_id: student.lehreinheit_id,
sem_kurzbz: context.semKurzbz,
- typ,
pruefung_id: pruefungId,
});
@@ -45,11 +43,14 @@ export const readState = (context) =>
.getStudentenNoten(context.lvId, context.semKurzbz)
.then((response) => expectNotenSuccess(response, "getStudentenNoten"));
-/** Attempts of one student and type, oldest first (the API returns datum DESC). */
-export const attemptsOfTyp = (data, uid, typ) =>
- [...pruefungenOfTyp(data, uid, typ)].sort((a, b) =>
- String(a.datum).slice(0, 10).localeCompare(String(b.datum).slice(0, 10)),
- );
+/** All attempts of one student, in Verlauf order (position ascending). */
+export const attemptsOfStudent = (data, uid) => attemptsOf(data, uid);
+
+/** Attempts that consume an Antritt, in Verlauf order. */
+export const countingAttemptsOfStudent = (data, uid) => countingAttemptsOf(data, uid);
+
+/** The server's rule state for one student. */
+export const verlaufOfStudent = (data, uid) => verlaufOf(data, uid);
export const lvNoteOf = (data, uid) => gradesOf(data, uid);
diff --git a/tests/cypress/support/helpers/notenTestData.js b/tests/cypress/support/helpers/notenTestData.js
index a296bef3c..57cbf9318 100644
--- a/tests/cypress/support/helpers/notenTestData.js
+++ b/tests/cypress/support/helpers/notenTestData.js
@@ -9,7 +9,7 @@
import { notenApi } from "../api/notenApi";
import { expectNotenSuccess } from "./notenErrors";
-// the client rule; the server's computeMaxAntritte must agree with it
+// 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,
@@ -154,10 +154,16 @@ export const loadNotenContext = () => {
// administrative note the editor list excludes ("intern angerechnet" / "nicht zugelassen")
const nichtLehre = noten.find((n) => n.lehre === false);
+ // Anrechnungen block every Prüfung for the LV - keyed on the ZEUGNISnote
+ const angerechnet = byBezeichnung("angerechnet");
+ const internAngerechnet = byBezeichnung("intern angerechnet");
+
context.notes = {
entschuldigt: entschuldigt.note,
nochNichtEingetragen: nochNicht.note,
nichtLehre: nichtLehre ? nichtLehre.note : null,
+ angerechnet: angerechnet ? angerechnet.note : null,
+ internAngerechnet: internAngerechnet ? internAngerechnet.note : null,
};
return notenApi.getStudentenNoten(context.lvId, context.semKurzbz);
@@ -183,18 +189,40 @@ export const resetNotenState = (context, studentUids) =>
performReset(context, studentUids || context.studentUids);
/**
- * Seeds the Antritt-1 baseline. Defaults to freigegeben:true because validatePruefungAdd reads the
- * implicit first Antritt through getLvGesamtNoten(), which filters `freigabedatum < NOW()` - an
- * offene note is invisible there and would not count as Antritt 1.
+ * Seeds the Antritt-1 baseline: a released LV note PLUS the Prüfung row that represents it.
+ *
+ * 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.
*/
-export const seedBaseline = (context, studentUid, options = {}) =>
- performSeed(context, studentUid, {
- note: options.note !== undefined ? options.note : context.gradeNotes[0],
+export const seedBaseline = (context, studentUid, options = {}) => {
+ const note = options.note !== undefined ? options.note : context.gradeNotes[0];
+ const freigegeben = options.freigegeben !== undefined ? options.freigegeben : true;
+
+ return performSeed(context, studentUid, {
+ note,
punkte: options.punkte !== undefined ? options.punkte : null,
benotungsdatum: options.benotungsdatum || baselineBenotungsdatum(context),
- freigegeben: options.freigegeben !== undefined ? options.freigegeben : true,
+ freigegeben,
freigabedatum: options.freigabedatum || null,
+ }).then((seeded) => {
+ if (options.erstantritt === false || !freigegeben) return cy.wrap(seeded, { log: false });
+
+ const student = context.students.find((s) => s.uid === studentUid);
+ expect(student, `student ${studentUid} in the loaded LV`).to.exist;
+
+ return performSeedPruefung(context, studentUid, {
+ lehreinheitId: student.lehreinheit_id,
+ note,
+ datum: baselineDate(context),
+ typ: "Termin1", // legacy projection of Antritt 1; the rules never read it back
+ }).then(() => seeded);
});
+};
/** -> { pruefungId, note, datum, typ }. For attempt states the API cannot build. */
export const seedPruefung = (context, student, { note, datum, typ }) =>