mirror of
https://github.com/FH-Complete/FHC-Core.git
synced 2026-10-12 15:04:04 +00:00
WIP refactor
This commit is contained in:
@@ -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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,9 @@ class Grades extends FHCAPI_Controller
|
||||
|
||||
// Load Libraries
|
||||
$this->load->library('VariableLib', ['uid' => getAuthUID()]);
|
||||
$this->load->library('PruefungsverlaufLib', null, 'VerlaufLib');
|
||||
|
||||
$this->load->config('noten');
|
||||
|
||||
// Load Phrases
|
||||
$this->loadPhrases([
|
||||
@@ -355,23 +358,24 @@ class Grades extends FHCAPI_Controller
|
||||
$data['lehrveranstaltung_id'] = $lehrveranstaltung_id;
|
||||
$data['student_uid'] = $student_uid;
|
||||
$data['studiensemester_kurzbz'] = $studiensemester_kurzbz;
|
||||
|
||||
$this->ZeugnisnoteModel->insert($data);
|
||||
|
||||
if (defined('FAS_PRUEFUNG_BEI_NOTENEINGABE_ANLEGEN')
|
||||
&& FAS_PRUEFUNG_BEI_NOTENEINGABE_ANLEGEN) {
|
||||
$result = $this->addTestsForGrade(
|
||||
$studiensemester_kurzbz,
|
||||
$student_uid,
|
||||
$lehrveranstaltung_id,
|
||||
$teacherGrade->note,
|
||||
$teacherGrade->punkte
|
||||
);
|
||||
$this->getDataOrTerminateWithError($result);
|
||||
}
|
||||
$this->ZeugnisnoteModel->insert($data);
|
||||
}
|
||||
|
||||
// Der erste Antritt wird bereits bei der Übernahme als eigene Prüfung angelegt, damit der
|
||||
// Prüfungsverlauf von Anfang an vollständig ist und das Benotungstool ihn nicht erst beim
|
||||
// Anlegen einer Wiederholung nachtragen muss. Läuft bewusst in BEIDEN Zweigen (auch beim
|
||||
// Überschreiben einer bestehenden Zeugnisnote) und ist idempotent.
|
||||
if ($this->config->item('CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME')) {
|
||||
$result = $this->addErstantrittForGrade(
|
||||
$studiensemester_kurzbz,
|
||||
$student_uid,
|
||||
$lehrveranstaltung_id,
|
||||
$teacherGrade
|
||||
);
|
||||
$this->getDataOrTerminateWithError($result);
|
||||
}
|
||||
|
||||
|
||||
$this->terminateWithSuccess(true);
|
||||
}
|
||||
|
||||
@@ -491,20 +495,32 @@ class Grades extends FHCAPI_Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function that adds tests for a student
|
||||
* (Entries in lehre.tbl_pruefung)
|
||||
* Legt bei der Notenübernahme den ersten Prüfungsantritt als eigenen Datensatz an
|
||||
* (Eintrag in lehre.tbl_pruefung).
|
||||
*
|
||||
* @param string $studiensemester_kurzbz
|
||||
* @param string $student_uid
|
||||
* @param integer $lehrveranstaltung_id
|
||||
* @param integer $note
|
||||
* @param numeric $punkte
|
||||
* Idempotent: existiert für den Studenten in dieser LV bereits ein Termin, passiert nichts -
|
||||
* die Übernahme darf beliebig oft laufen, ohne den Verlauf zu verfälschen.
|
||||
*
|
||||
* Datiert auf das Freigabedatum der LV-Note: das ist der Zeitpunkt, zu dem die
|
||||
* Leistungsfeststellung verbindlich wurde. Das Benotungsdatum ändert sich bei jeder Korrektur,
|
||||
* der Übernahmezeitpunkt ist reine Verwaltungsarbeit - beide taugen nicht als Termindatum.
|
||||
*
|
||||
* @param string $studiensemester_kurzbz
|
||||
* @param string $student_uid
|
||||
* @param integer $lehrveranstaltung_id
|
||||
* @param stdClass $teacherGrade die übernommene LV-Gesamtnote
|
||||
*
|
||||
* @return stdClass
|
||||
*/
|
||||
protected function addTestsForGrade($studiensemester_kurzbz, $student_uid, $lehrveranstaltung_id, $note, $punkte)
|
||||
protected function addErstantrittForGrade($studiensemester_kurzbz, $student_uid, $lehrveranstaltung_id, $teacherGrade)
|
||||
{
|
||||
$this->load->model('education/Lehrveranstaltung_model', 'LehrveranstaltungModel');
|
||||
$this->load->model('education/LePruefung_model', 'LePruefungModel');
|
||||
|
||||
// bereits ein Termin vorhanden -> der Verlauf beginnt schon, nichts nachzutragen
|
||||
$vorhanden = $this->VerlaufLib->getPruefungen($student_uid, $lehrveranstaltung_id, $studiensemester_kurzbz);
|
||||
if (count($vorhanden) > 0)
|
||||
return success(null);
|
||||
|
||||
// Get Lehreinheit
|
||||
$result = $this->LehrveranstaltungModel->getLeByStudent($student_uid, $studiensemester_kurzbz, $lehrveranstaltung_id);
|
||||
@@ -515,19 +531,24 @@ class Grades extends FHCAPI_Controller
|
||||
return error($this->p->t("stv", "grades_error_lehreinheit_id"));
|
||||
$le = current(getData($result));
|
||||
|
||||
$datum = $teacherGrade->freigabedatum ?: ($teacherGrade->benotungsdatum ?: date('Y-m-d'));
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
// Prepare
|
||||
$this->load->model('education/LePruefung_model', 'LePruefungModel');
|
||||
$data = [
|
||||
"student_uid" => $student_uid,
|
||||
"lehreinheit_id" => $le->lehreinheit_id,
|
||||
"datum" => date('Y-m-d'),
|
||||
"pruefungstyp_kurzbz" => "Termin1",
|
||||
"note" => $note
|
||||
"mitarbeiter_uid" => $teacherGrade->mitarbeiter_uid,
|
||||
"datum" => $datum,
|
||||
"pruefungstyp_kurzbz" => $this->VerlaufLib->legacyTypFuerAntritt(1),
|
||||
"note" => $teacherGrade->note,
|
||||
"insertamum" => $now,
|
||||
"insertvon" => getAuthUID()
|
||||
];
|
||||
|
||||
if (defined('CIS_GESAMTNOTE_PUNKTE') && CIS_GESAMTNOTE_PUNKTE)
|
||||
$data["punkte"] = $punkte;
|
||||
|
||||
$data["punkte"] = $teacherGrade->punkte;
|
||||
|
||||
// Get Anwesenheit
|
||||
$this->load->model('education/Anwesenheit_model', 'AnwesenheitModel');
|
||||
$result = $this->AnwesenheitModel->loadAnwesenheitStudiensemester($studiensemester_kurzbz, $student_uid, $lehrveranstaltung_id);
|
||||
@@ -535,7 +556,8 @@ class Grades extends FHCAPI_Controller
|
||||
return $result;
|
||||
$anwesenheit = getData($result);
|
||||
|
||||
if ($anwesenheit && (float)current($anwesenheit)->prozent < FAS_ANWESENHEIT_ROT) {
|
||||
if (defined('FAS_PRUEFUNG_BEI_NOTENEINGABE_ANLEGEN') && FAS_PRUEFUNG_BEI_NOTENEINGABE_ANLEGEN
|
||||
&& $anwesenheit && (float)current($anwesenheit)->prozent < FAS_ANWESENHEIT_ROT) {
|
||||
// Get Anwesenheitsbefreiung
|
||||
$this->load->model('person/Benutzerfunktion_model', 'BenutzerfunktionModel');
|
||||
$result = $this->BenutzerfunktionModel->getBenutzerFunktionByUidInStdsem($student_uid, $studiensemester_kurzbz, 'awbefreit');
|
||||
@@ -545,7 +567,10 @@ class Grades extends FHCAPI_Controller
|
||||
|
||||
$anwesenheitsbefreit = hasData($result);
|
||||
|
||||
// Wenn nicht Anwesenheitsbefreit und Anwesenheit unter einem bestimmten Prozentsatz fällt dann wird ein Pruefungsantritt abgezogen
|
||||
// Bei zu geringer Anwesenheit ohne Befreiung wird ein zusätzlicher Termin
|
||||
// 'Nicht beurteilt' vor dem eigentlichen Antritt festgehalten. ACHTUNG: diese Note
|
||||
// zählt nicht mehr als Antritt (NOTEN_OHNE_ANTRITT_BEZEICHNUNGEN) - der frühere
|
||||
// "Antritt abziehen"-Effekt entfällt damit, der Eintrag ist reine Dokumentation.
|
||||
if (!$anwesenheitsbefreit) {
|
||||
$data2 = $data;
|
||||
$data2["note"] = 7;
|
||||
@@ -556,8 +581,6 @@ class Grades extends FHCAPI_Controller
|
||||
|
||||
if (isError($result))
|
||||
return $result;
|
||||
|
||||
$data["pruefungstyp_kurzbz"] = "Termin2";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1824,7 +1824,7 @@ $htmlstring .= "
|
||||
|
||||
</table>
|
||||
";
|
||||
if ($anzahlChanged > 0)
|
||||
if (isset($anzahlChanged) && $anzahlChanged > 0)
|
||||
echo '<div id="notenWarning" class="warning">' . $p->t('benotungstool/geaenderteNotenVorhanden') . '</div>';
|
||||
|
||||
echo $htmlstring;
|
||||
|
||||
@@ -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; }
|
||||
.pruefung-badge.attempt-k::before { background: #D55E00; }
|
||||
/* Termin ohne verbrauchten Antritt (entschuldigt, nicht beurteilt) - traegt keine Nummer */
|
||||
.pruefung-badge.attempt-none::before { background: #767676; }
|
||||
@@ -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) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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) => {
|
||||
|
||||
@@ -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 ------------------------------------------------------------------------------------
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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])));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
Reference in New Issue
Block a user