adapt import to also parse note.anmerkung (entschuldigt - en, nicht beurteilt - nb, etc) if configured; new config if the benotungstool is allowed to create kommissionelle prüfungen or if that is a job for assistenz in stv; coded a kommPruef as the last pruefungsattempt and not a special case so it follows the same ordering rules in the table; changed headers from "Antritte" to "Prüfungstermine" to keep the wording aligned with the domain objects definitions; tooltip explaining the current antritt while hovering over the badge (1,2,3-K) (maybe make the badges contents configurable?); changed the notenOption sort to resemble stv: order 1-5 by value and afterwards alphabetically; actually removed redundant configs for defining which noten count for antritte -> only go by bezeichnung and fetch the matching primary keys from each db as they can have mixed pk's; vorschlag übernehmen now upserts the first prüfungsattempt, aswell as the notenfreigabe(if no other pruefungen exist) which should fix the possibility of creating a pruefungseries that starts with termin2, vorschlag also carries a datepicker writing the date of that first prüfung but not the benotungsdatum -> that needs to stay the entry date since the freigabemechanismus hinges on it; added a flag handling the password inputs browser prompt to not appear on other inputs; WIP coming up with even more configurability in order to cover uncertain workflow this tool is supposed to represent;

This commit is contained in:
Johann Hoffmann
2026-09-10 13:04:11 +02:00
parent 97be497fda
commit 6d2cbf4896
9 changed files with 748 additions and 281 deletions
+19 -16
View File
@@ -2,26 +2,18 @@
if (!defined('BASEPATH')) exit('No direct script access allowed');
// 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
// Noten, die keinen Prüfungsantritt verbrauchen.
$config['NOTEN_OHNE_ANTRITT_BEZEICHNUNGEN'] = ['Noch nicht eingetragen', '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.
// Anrechnungsnoten: die Leistung wurde vorab anerkannt.
$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
// Wie oft eine Note über alle Antritte hinweg vorkommen darf. Schlüssel ist die Bezeichnung.
$config['NOTEN_OCCURANCE_LIMIT_MAP'] = ['entschuldigt' => 1];
// 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;
// Die Note 'entschuldigt'. Ein entschuldigter Termin bleibt als eigene datierte Zeile erhalten,
// wenn eine neue Prüfung desselben Typs entsteht.
$config['NOTE_ENTSCHULDIGT_BEZEICHNUNG'] = 'entschuldigt';
// The maximum number of attempts that count, the first attempt and the kommissionelle attempt
// included. null derives the number from the old flags:
@@ -36,6 +28,12 @@ $config['PRUEFUNG_KOMMISSIONELL_TYPEN'] = ['kommPruef', 'zusKommPruef'];
// The type that the tool writes for the last attempt. The last attempt is always kommissionell.
$config['PRUEFUNG_TYP_KOMMISSIONELL'] = 'kommPruef';
// The Benotungstool may create the kommissionelle Prüfung itself. Some installations enter it in
// another tool. The tool SHOWS an existing kommissionelle Prüfung either way; false blocks the
// creation only: the cell offers no button, and the dialog, the bulk entry and the import refuse
// the row. The chain then stops one attempt earlier for this tool.
$config['CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF'] = true;
// Exam types that never use an attempt. A zusKommPruef repeats a kommissionelle Prüfung that had
// a procedural fault. It stands outside the attempt chain, and the student administration enters
// it. The examination rules do not describe it.
@@ -55,6 +53,11 @@ $config['CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME'] = true;
$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)
// The grade column of an imported row. false accepts the note itself (the primary key of
// lehre.tbl_note) only. true also accepts the shorthand from lehre.tbl_note.anmerkung, which the
// Excel grade list uses for the special grades ('nb', 'ea', 'en').
$config['CIS_GESAMTNOTE_IMPORT_NOTENKUERZEL'] = false;
// 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)
+137 -51
View File
@@ -93,7 +93,8 @@ class Noten extends FHCAPI_Controller
}
public function getCisConfig() {
// resolved from tbl_note (Bezeichnung) with config fallback -> single source of truth
// The configuration names the special grades, tbl_note gives their keys. The client compares
// keys, therefore the answer carries the resolved keys, never a Bezeichnung.
$special = $this->VerlaufLib->getSpecialNotes();
$NOTEN_OHNE_ANTRITT = $special['ohneAntritt'];
$NOTEN_OCCURANCE_LIMIT_MAP = $special['limitMap'];
@@ -117,8 +118,9 @@ class Noten extends FHCAPI_Controller
// the default column layout ('antritt' or 'datum'); the user can change it in the tool
'CIS_GESAMTNOTE_PRUEFUNGSSPALTEN' => $this->config->item('CIS_GESAMTNOTE_PRUEFUNGSSPALTEN'),
// used to toggle availability of kommPruef type pruefungen
'CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF' => CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF,
// may this tool CREATE the kommissionelle Prüfung (application/config/noten.php)?
// It always shows one that exists.
'CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF' => $this->VerlaufLib->darfKommPruefAnlegen(),
//technically exists but is never used, could be LE pendant to next flag
// 'CIS_GESAMTNOTE_PRUEFUNG_MOODLE_NOTE' => CIS_GESAMTNOTE_PRUEFUNG_MOODLE_NOTE,
@@ -134,6 +136,9 @@ class Noten extends FHCAPI_Controller
// true they are shown separately
'CIS_GESAMTNOTE_PRUEFUNGSIMPORT' => $this->config->item('CIS_GESAMTNOTE_PRUEFUNGSIMPORT'),
'CIS_GESAMTNOTE_NOTENIMPORT' => $this->config->item('CIS_GESAMTNOTE_NOTENIMPORT'),
// does an imported row accept the shorthand from tbl_note.anmerkung as the grade?
'CIS_GESAMTNOTE_IMPORT_NOTENKUERZEL' => (bool) $this->config->item('CIS_GESAMTNOTE_IMPORT_NOTENKUERZEL'),
// send a mail when approving grades
'CIS_GESAMTNOTE_FREIGABEMAIL_NOTE' => CIS_GESAMTNOTE_FREIGABEMAIL_NOTE,
@@ -975,6 +980,8 @@ class Noten extends FHCAPI_Controller
'terminal' => $verlauf->terminal,
'erstantrittMoeglich' => $verlauf->erstantrittMoeglich,
'naechsteRolle' => $verlauf->naechsteRolle,
// the next attempt is kommissionell and this tool may not create it
'kommPruefGesperrt' => $verlauf->kommPruefGesperrt,
// credited: the row is visible, but you cannot select it and it gets no exams
'angerechnet' => $verlauf->angerechnet,
'hatLvNote' => $hatLvNote // ungefiltert, also inklusive noch nicht freigegebener
@@ -1003,7 +1010,9 @@ class Noten extends FHCAPI_Controller
$pruefungen = $this->VerlaufLib->getPruefungen($student_uid, $lva_id, $stsem);
if(count($pruefungen) === 0) return null;
$verlauf = $this->VerlaufLib->buildVerlauf($pruefungen, null, $zeugnisNote);
// the same input as every other caller, so the same rows give the same history
$lvRow = $this->getLvGesamtnoteRow($lva_id, $student_uid, $stsem);
$verlauf = $this->VerlaufLib->buildVerlauf($pruefungen, $lvRow ? $lvRow->note : null, $zeugnisNote);
// the record being edited
$current = null;
@@ -1050,8 +1059,9 @@ class Noten extends FHCAPI_Controller
}
/**
* Guards a NEW attempt. Rule A is the attempt limit, and nothing follows a final exam. Rule B
* is the chronological order. Rule C is the occurrence limit of a grade.
* Guards a NEW attempt. A blocked kommissionelle Prüfung is refused first. Rule A is the
* attempt limit, and nothing follows a final exam. Rule B is the chronological order. Rule C is
* the occurrence limit of a grade.
*
* @return string|null lokalisierte Fehlermeldung oder null
*/
@@ -1070,6 +1080,11 @@ class Noten extends FHCAPI_Controller
$verlauf = $this->VerlaufLib->buildVerlauf($pruefungen, $lvNote, $zeugnisNote);
// the next attempt is kommissionell, and this tool may not create it
if($verlauf->kommPruefGesperrt) {
return $this->p->t('benotungstool', 'kommPruefNichtErlaubt', [$student_uid]);
}
// A: the first attempt only materialises the course grade, therefore it adds no attempt
if($verlauf->naechsteRolle !== PruefungsverlaufLib::ROLLE_ERSTANTRITT && !$verlauf->canAdd) {
return $this->p->t('benotungstool', 'maxAntritteReached', [$student_uid, $verlauf->maxAntritte]);
@@ -1129,8 +1144,9 @@ class Noten extends FHCAPI_Controller
return $this->p->t('benotungstool', 'c4noteNichtInLehre', [$student_uid]);
}
// as soon as an exam exists the grade belongs to the attempt history and follows its rules
if(count($this->VerlaufLib->getPruefungen($student_uid, $lva_id, $stsem)) > 0) {
// Antritt 1 und die LV-Note sind dieselbe Leistung, deshalb schreibt dieser Weg sie weiter.
// Ab der ersten Wiederholung gehört die Note zum Antritt: dann über den Prüfungsdialog.
if($this->VerlaufLib->hatWiederholung($this->VerlaufLib->getPruefungen($student_uid, $lva_id, $stsem))) {
return $this->p->t('benotungstool', 'c4notenvorschlagGesperrt', [$student_uid]);
}
@@ -1144,6 +1160,46 @@ class Noten extends FHCAPI_Controller
return null;
}
/**
* Guards the date the user picked for the course grade. The release turns the benotungsdatum
* into the date of the first attempt, therefore the date follows the same rules as an exam
* date. No date at all is permitted: the current moment then applies.
*
* @return string|null a translated error message, or null if the date is permitted
*/
private function validateBenotungsdatum($datum, $student_uid, $sem_kurzbz)
{
if($datum === null || $datum === '') return null;
$tag = substr((string) $datum, 0, 10);
$geprueft = DateTime::createFromFormat('Y-m-d', $tag);
if($geprueft === false || $geprueft->format('Y-m-d') !== $tag) {
return $this->p->t('benotungstool', 'benotungsdatumUngueltig', [$student_uid]);
}
// an assessment that did not happen yet has no date
if($tag > date('Y-m-d')) {
return $this->p->t('benotungstool', 'benotungsdatumInZukunft', [$student_uid]);
}
return $this->pruefungsdatumNachFrist($sem_kurzbz, $tag, $student_uid);
}
/**
* The Lehreinheit of one student in this course. Resolve it once and hand it to every consumer:
* the grading person and the exam row both need it.
*
* @return mixed|null
*/
private function lehreinheitFuerStudent($lva_id, $student_uid, $stsem)
{
$resLe = $this->LehrveranstaltungModel->getLeByStudent($student_uid, $stsem, $lva_id);
if(isError($resLe) || !hasData($resLe)) return null;
return current(getData($resLe))->lehreinheit_id;
}
/**
* Checks the DATE of the exam against the deadline. enforceNoteneintragungsfrist compares the
* current time, which is a different question: a user can enter an exam today and give it a
@@ -1228,6 +1284,9 @@ class Noten extends FHCAPI_Controller
$note = $result->note;
$punkte = $result->punkte;
// the day the assessment took place. The dialog sends it, older callers do not.
$datum = property_exists($result, 'datum') ? $result->datum : null;
$this->assertLvAccess($lv_id, $sem_kurzbz);
// examination rules: no entry and no change after the grade entry deadline
@@ -1245,6 +1304,15 @@ class Noten extends FHCAPI_Controller
$fehler = $this->validateNotenvorschlag($lv_id, $student_uid, $sem_kurzbz, $note);
if($fehler !== null) $this->terminateWithError($fehler, 'general');
$fehler = $this->validateBenotungsdatum($datum, $student_uid, $sem_kurzbz);
if($fehler !== null) $this->terminateWithError($fehler, 'general');
// Der gewählte Tag ist das Datum von Antritt 1, nicht das benotungsdatum. Das benotungsdatum
// bleibt der Zeitpunkt der Eingabe, weil die Freigabe es mit dem freigabedatum vergleicht:
// ein Tag in der Vergangenheit liesse die geänderte Note als freigegeben erscheinen.
$erstantrittDatum = $datum === null || $datum === '' ? date("Y-m-d") : substr((string) $datum, 0, 10);
$lvgesamtnote = null;
$result = $this->LvgesamtnoteModel->getLvGesamtNoteVorschlag($lv_id, $student_uid, $sem_kurzbz);
// $this->addMeta('LvgesamtnoteModelresult', $result);
@@ -1296,10 +1364,46 @@ class Noten extends FHCAPI_Controller
$this->logLib->logInfoDB(array('saveNotenvorschlag insert lv gesamtnote',$res, getAuthUID(), getAuthPersonId()));
}
// Ohne geschriebene LV-Note entsteht kein Antritt: eine Prüfung ohne Note ist ein Zustand,
// den jeder andere Pfad ablehnt (c4keineLvNoteEingetragen).
if($lvgesamtnote === null) {
$this->terminateWithError($this->p->t('benotungstool', 'c4pruefungNichtGespeichert', [$student_uid]), 'general');
}
// The course grade IS the first attempt. Write it as its own exam now, or the next exam
// becomes attempt 2 and the legacy type of the whole chain moves one place.
$this->erstantrittBeiUebernahme($lv_id, $student_uid, $sem_kurzbz, $note, $punkte, $erstantrittDatum);
// the client shows the new attempt at once, without a reload
$lvgesamtnote->verlauf = $this->buildVerlaufSummary($student_uid, $lv_id, $sem_kurzbz);
$this->terminateWithSuccess(array($lvgesamtnote));
}
/**
* Writes the first attempt when somebody takes the course grade over, the same way the
* Studierendenverwaltung does it. CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME switches it off.
* A credited transcript grade forbids every exam.
*/
private function erstantrittBeiUebernahme($lv_id, $student_uid, $sem_kurzbz, $note, $punkte, $datum)
{
if(!$this->config->item('CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME')) return;
if($this->VerlaufLib->istAnrechnungsnote($this->getZeugnisnote($lv_id, $student_uid, $sem_kurzbz))) return;
// the person who enters the grade picks this date, therefore it also updates attempt 1
$lehreinheit_id = $this->lehreinheitFuerStudent($lv_id, $student_uid, $sem_kurzbz);
$geschrieben = $this->VerlaufLib->upsertErstantritt(
$student_uid, $lv_id, $sem_kurzbz, $note, $punkte, $datum,
$this->benotenderMitarbeiter($lehreinheit_id), true, $lehreinheit_id
);
if($geschrieben !== null) {
$this->logLib->logInfoDB(array('erstantritt (uebernahme)', $student_uid, getAuthUID(), getAuthPersonId()));
}
}
/**
* POST METHOD
* expects 'sem_kurzbz', 'lv_id', 'noten'
@@ -1327,6 +1431,9 @@ class Noten extends FHCAPI_Controller
foreach($noten as $note)
{
// je Zeile neu: sonst trägt die Variable die Zeile davor, und eine gescheiterte Zeile
// meldet die Note der vorherigen Person zurück
$lvgesamtnote = null;
$result = $this->LvgesamtnoteModel->getLvGesamtNoteVorschlag($lv_id, $note->uid, $sem_kurzbz);
// $this->addMeta($note->uid.'$result', $result);
@@ -1390,6 +1497,17 @@ class Noten extends FHCAPI_Controller
$this->logLib->logInfoDB(array('saveNotenvorschlagBulk insert lv gesamtnote',$res, getAuthUID(), getAuthPersonId()));
}
// Ohne geschriebene LV-Note entsteht kein Antritt, und die Zeile meldet den Fehler
if($lvgesamtnote === null) {
$retLvNoten[$note->uid] = $this->p->t('benotungstool', 'c4pruefungNichtGespeichert', [$note->uid]);
continue;
}
// the same rule as the single dialog: the course grade is attempt 1
$this->erstantrittBeiUebernahme($lv_id, $note->uid, $sem_kurzbz, trim($note->note), $note->punkte, date("Y-m-d"));
$lvgesamtnote->verlauf = $this->buildVerlaufSummary($note->uid, $lv_id, $sem_kurzbz);
$retLvNoten[$note->uid] = $lvgesamtnote;
}
@@ -1534,51 +1652,19 @@ class Noten extends FHCAPI_Controller
{
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;
// The release makes the grade binding. It moves no date: an exam keeps the date it carries,
// and attempt 1 keeps the date that the person picked while entering the grade. Only a
// missing attempt 1 is created, and it is dated on the benotungsdatum.
$lehreinheit_id = $this->lehreinheitFuerStudent($lva_id, $student_uid, $stsem);
$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;
}
// the server finds the lehreinheit_id; it does not use the value from the client
$resLe = $this->LehrveranstaltungModel->getLeByStudent($student_uid, $stsem, $lva_id);
if(isError($resLe) || !hasData($resLe)) return;
$le = current(getData($resLe));
$this->LePruefungModel->insert(
array(
'lehreinheit_id' => $le->lehreinheit_id,
'student_uid' => $student_uid,
'mitarbeiter_uid' => $this->benotenderMitarbeiter($le->lehreinheit_id),
'note' => $note,
'punkte' => $punkte,
'pruefungstyp_kurzbz' => $this->VerlaufLib->legacyTypFuerAntritt(1),
'datum' => $datum,
'anmerkung' => "",
'insertamum' => $jetzt,
'insertvon' => getAuthUID(),
'updateamum' => null,
'updatevon' => null,
'ext_id' => null
)
$geschrieben = $this->VerlaufLib->upsertErstantritt(
$student_uid, $lva_id, $stsem, $note, $punkte, $datum,
$this->benotenderMitarbeiter($lehreinheit_id), false, $lehreinheit_id
);
$this->logLib->logInfoDB(array('erstantritt angelegt (freigabe)', $student_uid, getAuthUID(), getAuthPersonId()));
if($geschrieben !== null) {
$this->logLib->logInfoDB(array('erstantritt (freigabe)', $student_uid, getAuthUID(), getAuthPersonId()));
}
}
/**
+140 -36
View File
@@ -46,6 +46,7 @@ class PruefungsverlaufLib
{
$this->_ci =& get_instance();
$this->_ci->load->model('education/LePruefung_model', 'LePruefungModel');
$this->_ci->load->model('education/Lehrveranstaltung_model', 'LehrveranstaltungModel');
$this->_ci->load->model('education/Note_model', 'NoteModel');
$this->_ci->load->model('education/Pruefungstyp_model', 'PruefungstypModel');
$this->_ci->load->config('noten');
@@ -158,9 +159,108 @@ class PruefungsverlaufLib
$verlauf->naechsteRolle = self::ROLLE_PRUEFUNG;
}
// The next attempt is the kommissionelle one, but this tool may not create it.
$verlauf->kommPruefGesperrt = $verlauf->canAdd
&& $verlauf->naechsteRolle === self::ROLLE_KOMMISSIONELL
&& !$this->darfKommPruefAnlegen();
if ($verlauf->kommPruefGesperrt) $verlauf->canAdd = false;
return $verlauf;
}
/**
* Trägt die Zeile eine Wiederholung, also einen Termin nach Antritt 1?
*
* Antritt 1 und die LV-Note sind dieselbe Leistung. Solange nur er existiert, darf der
* Vorschlagsweg die Note schreiben, und upsertErstantritt hält genau diesen Termin nach. Ab der
* ersten Wiederholung gehört die Note zum Antritt und folgt dessen Regeln.
*
* Die Antwort kommt aus dem Verlauf, nicht aus der Zahl der Zeilen: die Position sagt, der
* wievielte Termin ein Datensatz ist.
*
* @param array $pruefungen die Termine der Zeile, ungeordnet
* @return bool
*/
public function hatWiederholung($pruefungen)
{
foreach ($this->buildVerlauf($pruefungen)->pruefungen as $termin) {
if ($termin->position > 1) return true;
}
return false;
}
/**
* Materialises the first attempt as its own exam row. A course grade without any exam IS
* attempt 1 (impliziterErstantritt). As long as that row is missing, the next exam becomes
* attempt 2, and the legacy type of the whole chain moves one place. The caller therefore
* writes the row as soon as the course grade exists, not only on the release.
*
* Idempotent. With more than one exam the chain has moved on and nothing is written. With
* exactly one exam the grade follows the course grade.
*
* @param bool $mitDatum true also writes $datum into the existing attempt 1. Only the person
* who enters the course grade picks that date; a release must not move
* a date that somebody entered for the exam itself.
* @param mixed $lehreinheit_id the caller resolved it already; null makes this method look it up
* @return stdClass|null the exam row, or null if nothing was written
*/
public function upsertErstantritt($student_uid, $lv_id, $sem_kurzbz, $note, $punkte, $datum, $mitarbeiter_uid = null, $mitDatum = false, $lehreinheit_id = null)
{
$pruefungen = $this->getPruefungen($student_uid, $lv_id, $sem_kurzbz);
if ($this->hatWiederholung($pruefungen)) return null;
$jetzt = date('Y-m-d H:i:s');
$tag = substr((string) $datum, 0, 10);
if (count($pruefungen) === 1) {
$daten = [
'note' => $note,
'punkte' => $punkte,
'updateamum' => $jetzt,
'updatevon' => getAuthUID()
];
if ($mitDatum) $daten['datum'] = $tag;
$this->_ci->LePruefungModel->update($pruefungen[0]->pruefung_id, $daten);
return $this->ladePruefung($pruefungen[0]->pruefung_id);
}
// the server finds the Lehreinheit; it does not use a value from the client
if ($lehreinheit_id === null) {
$resLe = $this->_ci->LehrveranstaltungModel->getLeByStudent($student_uid, $sem_kurzbz, $lv_id);
if (isError($resLe) || !hasData($resLe)) return null;
$lehreinheit_id = current(getData($resLe))->lehreinheit_id;
}
$id = $this->_ci->LePruefungModel->insert([
'lehreinheit_id' => $lehreinheit_id,
'student_uid' => $student_uid,
'mitarbeiter_uid' => $mitarbeiter_uid,
'note' => $note,
'punkte' => $punkte,
'pruefungstyp_kurzbz' => $this->legacyTypFuerAntritt(1),
'datum' => $tag,
'anmerkung' => '',
'insertamum' => $jetzt,
'insertvon' => getAuthUID(),
'updateamum' => null,
'updatevon' => null,
'ext_id' => null
]);
return $id ? $this->ladePruefung($id->retval) : null;
}
/** @return stdClass|null */
private function ladePruefung($pruefung_id)
{
$result = $this->_ci->LePruefungModel->load($pruefung_id);
return (!isError($result) && hasData($result)) ? getData($result)[0] : null;
}
/** 'angerechnet' or 'intern angerechnet'. The TRANSCRIPT grade decides. @return bool */
public function istAnrechnungsnote($note)
{
@@ -187,6 +287,19 @@ class PruefungsverlaufLib
return $max;
}
/**
* Tells you if this tool may create the kommissionelle Prüfung. An installation that enters it
* in another tool sets CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF to false. The exam stays visible
* in the tool either way.
*
* @return bool
*/
public function darfKommPruefAnlegen()
{
$erlaubt = $this->_ci->config->item('CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF');
return $erlaubt === null ? true : (bool) $erlaubt;
}
/**
* The NOTE decides, therefore the same type can occur more than one time. The type decides in
* one case only: a type in PRUEFUNG_TYPEN_OHNE_ANTRITT never uses an attempt.
@@ -261,43 +374,33 @@ class PruefungsverlaufLib
return $this->legacyTypFuerAntritt(max(2, $verlauf->antrittCount + 1));
}
/** @return array{entschuldigt: mixed, ohneAntritt: array, anrechnung: array, limitMap: array} */
/**
* The grades with a special meaning. The configuration names them, this method resolves the
* names in lehre.tbl_note. A primary key means something else in every installation, therefore
* no configuration holds one. A name that no grade carries is left out.
*
* @return array{entschuldigt: mixed, ohneAntritt: array, anrechnung: array, limitMap: array}
*/
public function getSpecialNotes()
{
if ($this->_specialNotes !== null) return $this->_specialNotes;
$cfgEntschuldigt = $this->_ci->config->item('NOTE_ENTSCHULDIGT');
$cfgOhneAntritt = $this->_ci->config->item('NOTEN_OHNE_ANTRITT');
$cfgLimitMap = $this->_ci->config->item('NOTEN_OCCURANCE_LIMIT_MAP');
$bezeichnungen = $this->_ci->config->item('NOTEN_OHNE_ANTRITT_BEZEICHNUNGEN');
if (!is_array($cfgOhneAntritt)) $cfgOhneAntritt = [];
if (!is_array($cfgLimitMap)) $cfgLimitMap = [];
if (!is_array($bezeichnungen)) $bezeichnungen = [];
$entschuldigt = $this->getNoteByBezeichnung($this->_ci->config->item('NOTE_ENTSCHULDIGT_BEZEICHNUNG'));
$resEnt = $this->_ci->NoteModel->getEntschuldigtNote();
$entschuldigt = (!isError($resEnt) && hasData($resEnt)) ? getData($resEnt)[0]->note : $cfgEntschuldigt;
// The name wins against the configured key. Do NOT merge the two sets: the same key has a
// different meaning in each installation (17 is 'entschuldigt' here, but 'nicht zugelassen'
// in the standard data).
$ohneAntritt = [];
foreach ($bezeichnungen as $bezeichnung) {
$note = $this->getNoteByBezeichnung($bezeichnung);
if ($note !== null && !in_array($note, $ohneAntritt)) $ohneAntritt[] = $note;
}
if (count($ohneAntritt) === 0) $ohneAntritt = $cfgOhneAntritt;
$ohneAntritt = $this->resolveNoten('NOTEN_OHNE_ANTRITT_BEZEICHNUNGEN');
if ($entschuldigt !== null && !in_array($entschuldigt, $ohneAntritt)) $ohneAntritt[] = $entschuldigt;
// move the configured limit for 'entschuldigt' to the key that was found
// the limit names a grade, the rules work on its key
$limitMap = [];
foreach ($cfgLimitMap as $k => $v) {
$limitMap[($k == $cfgEntschuldigt) ? $entschuldigt : $k] = $v;
foreach ($this->configArray('NOTEN_OCCURANCE_LIMIT_MAP') as $bezeichnung => $limit) {
$note = $this->getNoteByBezeichnung($bezeichnung);
if ($note !== null) $limitMap[$note] = $limit;
}
$this->_specialNotes = [
'entschuldigt' => $entschuldigt,
'ohneAntritt' => array_values($ohneAntritt),
'anrechnung' => $this->resolveNoten('NOTEN_ANRECHNUNG_BEZEICHNUNGEN', 'NOTEN_ANRECHNUNG'),
'anrechnung' => $this->resolveNoten('NOTEN_ANRECHNUNG_BEZEICHNUNGEN'),
'limitMap' => $limitMap
];
return $this->_specialNotes;
@@ -367,26 +470,25 @@ class PruefungsverlaufLib
return $this->_typen;
}
/** Finds the grades by their name. The configured keys are the fallback only. @return array */
private function resolveNoten($bezeichnungKey, $pkKey)
/** The grades of one configuration key, found by their name. @return array */
private function resolveNoten($bezeichnungKey)
{
$bezeichnungen = $this->_ci->config->item($bezeichnungKey);
if (!is_array($bezeichnungen)) $bezeichnungen = [];
$noten = [];
foreach ($bezeichnungen as $bezeichnung) {
foreach ($this->configArray($bezeichnungKey) as $bezeichnung) {
$note = $this->getNoteByBezeichnung($bezeichnung);
if ($note !== null && !in_array($note, $noten)) $noten[] = $note;
}
if (count($noten) === 0) {
$fallback = $this->_ci->config->item($pkKey);
if (is_array($fallback)) $noten = $fallback;
}
return array_values($noten);
}
/** One configuration value that must be a list. @return array */
private function configArray($key)
{
$wert = $this->_ci->config->item($key);
return is_array($wert) ? $wert : [];
}
/**
* @return stdClass|null
*/
@@ -400,10 +502,12 @@ class PruefungsverlaufLib
}
/**
* @return mixed|null
* @return mixed|null the key of the grade, or null if no grade carries the name
*/
private function getNoteByBezeichnung($bezeichnung)
{
if (!is_string($bezeichnung) || trim($bezeichnung) === '') return null;
$result = $this->_ci->NoteModel->loadWhere(['bezeichnung' => $bezeichnung]);
return (!isError($result) && hasData($result)) ? getData($result)[0]->note : null;
}
+6 -11
View File
@@ -12,24 +12,19 @@ class Note_model extends DB_Model
$this->pk = 'note';
}
// Sorted like the grade list of the Stv
// scale 1-5 by its value first, every other grade alphabetically after it.
public function getAllActive() {
$qry ="SELECT *
FROM lehre.tbl_note
WHERE aktiv = true";
WHERE aktiv = true
ORDER BY CASE WHEN note BETWEEN 1 AND 5 THEN 0 ELSE 1 END,
CASE WHEN note BETWEEN 1 AND 5 THEN note END,
bezeichnung";
return $this->execReadOnlyQuery($qry);
}
// used to determine the primary key of note "entschuldigt" to avoid hardcoded magic numbers
// that might differ in a different installation of fhcomplete
public function getEntschuldigtNote() {
$qry ="SELECT *
FROM lehre.tbl_note
WHERE bezeichnung = 'entschuldigt'";
return $this->execReadOnlyQuery($qry);
}
// used to determine the primary key of note "noch nicht eingetragen" to avoid hardcoded magic numbers
// that might differ in a different installation of fhcomplete
public function getNochNichtEingetragenNote() {
+5 -12
View File
@@ -63,10 +63,7 @@
}
/*
* 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.
* Prueufungszelle: feste cols für Note | Datum | Aktion.
*/
.pruefung-cell {
position: relative;
@@ -75,20 +72,15 @@
align-items: center;
column-gap: 0.5rem;
height: 100%;
padding-left: 32px; /* badge width (20) + its left offset (6) + gap (6) */
padding-left: 44px;
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 */
}
@@ -132,15 +124,16 @@
justify-content: center;
min-width: 20px;
height: 20px;
border-radius: 50%;
padding: 0 5px;
border-radius: 10px;
color: #fff;
font-size: 0.75rem;
font-weight: bold;
white-space: nowrap;
}
.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; }
/* Termin ohne verbrauchten Antritt (entschuldigt, nicht beurteilt) - traegt keine Nummer */
.pruefung-badge.attempt-none::before { background: #767676; }
+4 -2
View File
@@ -42,11 +42,13 @@ export default {
params: { password, noten, lv_id, sem_kurzbz }
};
},
saveNotenvorschlag(lv_id, sem_kurzbz, student_uid, note, punkte = null) {
// datum: das gewählte Benotungsdatum (YYYY-MM-DD). Ohne Wert nimmt der Server den aktuellen
// Zeitpunkt. Die Freigabe macht daraus das Datum des ersten Antritts.
saveNotenvorschlag(lv_id, sem_kurzbz, student_uid, note, punkte = null, datum = null) {
return {
method: 'post',
url: '/api/frontend/v1/Noten/saveNotenvorschlag',
params: { lv_id, sem_kurzbz, student_uid, note, punkte }
params: { lv_id, sem_kurzbz, student_uid, note, punkte, datum }
};
},
// kein Termintyp mehr: welchen Antritt die Prüfung darstellt, leitet der Server aus dem
@@ -57,20 +57,21 @@ export const Benotungstool = {
debouncedFetchPunkteForPruefung: null,
config: null, // cis config
neuesPruefungsdatumModalVisible: false,
freigabeModalVisible: false,
loading: false,
selectedUids: [], // shared selection state
selectedLehreinheit: null,
tabulatorCanBeBuilt: false,
selectedPruefungNote: null,
selectedPruefungDate: new Date(), // v-model for pruefung edit datepicker
uebernahmeStudent: null,
uebernahmeDate: new Date(),
uebernahmeMaxDate: new Date(),
selectedPruefungPunkte: null,
pruefungNoteLocked: false, // grade read-only when a later pruefung exists (date stays editable)
pruefungDateMin: null,
pruefungDateMax: null,
distinctPruefungsDates: null,
// The column layout of the exams: 'antritt' (one column for each attempt number) or 'datum'
// (one column for each exam date). null uses the default from the configuration. The browser
// keeps the choice of the user.
pruefungsspalten: localStorage.getItem('notenToolPruefungsspalten'),
// The students of the current entry that have no course grade. The new exam creates the
// grade, and the dialog shows a hint before that happens.
@@ -470,7 +471,7 @@ export const Benotungstool = {
// check if student antrittCount is too high already
if(!this.canAddPruefung(student)) {
this.$fhcAlert.alertWarning('Student ' + student.uid + ' hat bereits ' + student.hoechsterAntritt + ' Prüfungsantritte abgelegt. Die Zeile wurde übersprungen.')
this.warnKeinAntritt(student, 'Die Zeile wurde übersprungen.')
return
}
@@ -491,6 +492,27 @@ export const Benotungstool = {
validateNotenBulk(noten) {
// in case we need to further validate noten, currently parser does all
},
/**
* The grade of one import row. The value is the note itself, and with
* CIS_GESAMTNOTE_IMPORT_NOTENKUERZEL also the shorthand from tbl_note.anmerkung, which the
* Excel grade list uses for the special grades.
*/
noteAusImportWert(wert) {
const eingabe = String(wert ?? '').trim()
if(eingabe === '') return null
const nachNote = this.notenOptions?.find(n => String(n.note).trim() === eingabe)
if(nachNote) return nachNote
if(!this.config?.CIS_GESAMTNOTE_IMPORT_NOTENKUERZEL) return null
const kuerzel = eingabe.toLowerCase()
const treffer = (this.notenOptions ?? []).filter(
n => String(n.anmerkung ?? '').trim().toLowerCase() === kuerzel
)
return treffer.length === 1 ? treffer[0] : null
},
parseNote(rowParts, notenbulk, rowNum) {
const id = this.identifyUid(rowParts[0])
const idTrimmed = rowParts[0].trim()
@@ -511,16 +533,16 @@ export const Benotungstool = {
if(this.config?.CIS_GESAMTNOTE_PUNKTE) {
punkte = Number.parseFloat(rowParts[1])
} else {
note = rowParts[1]
// find notenoption and check if its allowed to use in lehre
const notenOption = this.notenOptions.find(n => n.note == note)
const notenOption = this.noteAusImportWert(rowParts[1])
if(!notenOption?.lehre) {
this.$fhcAlert.alertWarning(this.$p.t('benotungstool/c4importNoGradeFoundForIdInRow', [rowParts[0], rowNum]))
return
}
note = notenOption.note
}
notenbulk.push({uid: student.uid, note, punkte})
},
parsePruefung(rowParts, pruefungbulk, rowNum) {
@@ -559,14 +581,14 @@ export const Benotungstool = {
if(this.config?.CIS_GESAMTNOTE_PUNKTE) {
punkte = Number.parseFloat(rowParts[2])
} else {
note = rowParts[2]
// find notenoption and check if its allowed to use in lehre
const notenOption = this.notenOptions.find(n => n.note == note)
const notenOption = this.noteAusImportWert(rowParts[2])
if(!notenOption?.lehre) {
this.$fhcAlert.alertWarning(this.$p.t('benotungstool/c4importNoGradeFoundForIdInRow', [rowParts[0], rowNum]))
return
}
note = notenOption.note
}
pruefungbulk.push({uid: student.uid, datum: dateStr, note, punkte, lehreinheit_id: student.lehreinheit_id, dateObj})
@@ -587,6 +609,7 @@ export const Benotungstool = {
s.note_vorschlag = lvn.note
this.applyLvGesamtnote(s, lvn)
if(lvn.verlauf) this.applyVerlauf(s, lvn.verlauf)
})
if(errorList !== '') this.$fhcAlert.alertError(errorList)
@@ -599,6 +622,8 @@ export const Benotungstool = {
)
}
// the import writes attempt 1 for each row, therefore the exam columns can change
this.applyPruefungColumns()
this.$refs.notenTable.tabulator.redraw(true)
}).finally(()=>{
this.loading = false
@@ -691,13 +716,10 @@ export const Benotungstool = {
delete student.verlauf.pruefungen
student.pruefungen = []
delete student['kommPruef']
;(verlauf.pruefungen ?? []).forEach(p => {
p.dateObj = this.parseISODate(p.datum)
// the final exam has its own column, and that column is always the last one
if(p.kommissionell) student['kommPruef'] = p
else student.pruefungen.push(p)
student.pruefungen.push(p)
})
// Update the flat list too. It decides if the user can edit the grade proposal and the
@@ -720,7 +742,6 @@ export const Benotungstool = {
},
/** The field name of the column that shows one exam. */
pruefungField(pruefung, index) {
if(pruefung.kommissionell) return 'kommPruef'
return this.pruefungsspaltenModus === 'antritt' ? ('antritt_' + (index + 1)) : pruefung.datum
},
/** Puts the exams into the column fields. Remove the old fields first, or they stay behind. */
@@ -758,24 +779,23 @@ export const Benotungstool = {
}
},
/**
* 'antritt' makes one column for each attempt number and puts the date into the cell. Use it
* if each student has an own exam date. 'datum' makes one column for each exam date. Use it
* if the students share the exam dates.
* 'antritt' makes one column for each Prüfungstermin of the student and puts the date into
* the cell. Use it if each student has an own exam date. 'datum' makes one column for each
* exam date. Use it if the students share the exam dates.
*/
buildPruefungColumns() {
if(this.pruefungsspaltenModus === 'antritt') {
let count = 0
this.studenten?.forEach(s => {
// Add one more column while this row can get one more attempt. The kommissionelle
// attempt has its own column, so it needs no attempt column.
const kommissionellFaellig = s.verlauf?.naechsteRolle === 'kommissionell'
const frei = this.canAddPruefung(s) && !kommissionellFaellig ? 1 : 0
// Add one more column while this row can get one more Termin. The kommissionelle
// Prüfung is the last Termin, therefore it uses such a column too.
const frei = this.canAddPruefung(s) ? 1 : 0
const needed = (s.pruefungen?.length ?? 0) + frei
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])))
this.pruefungColumnDef('antritt_' + (i + 1), this.$capitalize(this.$p.t('benotungstool/c4terminNr', [i + 1])))
)
}
@@ -797,26 +817,10 @@ export const Benotungstool = {
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
const cols = [...this.notenTableOptions.columns]
pruefungCols.forEach(c => cols.push(c))
if(kommCol) cols.push(kommCol) // abschliessender Termin bleibt die letzte Spalte
table.setColumns(this.restoreColumnLayout(cols))
this.applyKommPruefSpalte()
},
/** Blendet die kommissionelle Spalte ein oder aus. Nach jedem setColumns nötig. */
applyKommPruefSpalte() {
if(!this.config?.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) return
const col = this.$refs.notenTable?.tabulator?.getColumn("kommPruef")
if(!col) return
if(this.kommPruefSpalteNoetig) col.show()
else col.hide()
},
/**
* Keeps the scroll position while a table operation renders the table again.
@@ -1070,7 +1074,7 @@ export const Benotungstool = {
editor: this.liveNumberEditor,
editable: (cell) => {
const rowData = cell.getRow().getData();
if(this.pruefungen?.find(p => p.student_uid == rowData.uid)) return false
if(this.hatWiederholung(rowData)) return false
return true
},
@@ -1099,8 +1103,8 @@ export const Benotungstool = {
const noteOption = this.notenOptions.find(opt => opt.note == rowData.note)
if(!noteOption) return true
// also if student has any pruefungsnote disable noten selection
if(this.pruefungen?.find(p => p.student_uid == rowData.uid)) return false
// from the first repeat on the grade belongs to the exam, not to the proposal
if(this.hatWiederholung(rowData)) return false
return noteOption.lkt_ueberschreibbar
},
@@ -1109,16 +1113,16 @@ export const Benotungstool = {
const value = cell.getValue()
const match = this.notenOptions?.find(opt => opt.note == value)
const val = match ? match.bezeichnung : value
const p = this.pruefungen?.find(p => p.student_uid == rowData.uid)
const gesperrt = this.hatWiederholung(rowData)
let style = ''
if(val === undefined) return ''
if(p || !match?.lkt_ueberschreibbar) style = 'color: gray;font-style: italic; background-color: #f0f0f0;pointer-events: none;opacity: 0.6;user-select: none;cursor: not-allowed;'
if(gesperrt || !match?.lkt_ueberschreibbar) style = 'color: gray;font-style: italic; background-color: #f0f0f0;pointer-events: none;opacity: 0.6;user-select: none;cursor: not-allowed;'
return '<div style="'+style+'">' + val + '</div>'
}
})
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,
cellClick: this.openUebernahmeModal,
variableHeight: true})
columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4lvnote'))), field: 'lv_note',
minWidth: 160,
@@ -1142,16 +1146,6 @@ export const Benotungstool = {
},
headerFilterFunc: this.notenFilterFunc
})
columns.push({title: Vue.computed(() => this.kommPruefSpaltentitel),
field: 'kommPruef', widthGrow: 1,
formatter: this.pruefungFormatter,
sorter: this.pruefungSorter,
topCalc: this.terminCalcFunc,
topCalcFormatter: this.terminCalcFormatter,
hozAlign:"center", minWidth: 200, visible: false,
tooltip: false
})
return columns
},
pruefungSorter(a, b, aRow, bRow, column, dir, params) {
@@ -1319,29 +1313,87 @@ export const Benotungstool = {
return value
},
async saveNote(e, cell) { // Notenvorschlag freigeben
const row = cell.getRow()
const data = row.getData()
/**
* Notenvorschlag übernehmen. The dialog asks for the date of the assessment first. The
* server writes the course grade AND attempt 1 with that date, so the chain is complete
* from the start and no grade carries an implicit date.
*/
openUebernahmeModal(e, cell) {
const data = cell.getRow().getData()
if(!data.note_vorschlag) return
if(data.note_vorschlag == data.lv_note) return
// as soon as exams exist, the grade belongs to the exam history
if(data.pruefungen.length) return
// from the first repeat on the grade belongs to the exam history
if(this.hatWiederholung(data)) return
this.uebernahmeStudent = data
// Antritt 1 der Zeile gewinnt, sonst die erste freie Datumsspalte, sonst heute
this.uebernahmeDate = data.pruefungen[0]?.dateObj ?? this.ersteFreieDatumsspalte(data) ?? new Date()
this.uebernahmeMaxDate = new Date()
this.$refs.modalContainerUebernahme.show()
},
/**
* Das erste Prüfungsdatum der Gruppe, das diese Zeile noch nicht belegt. Der Datumsmodus
* zeigt je Datum eine Spalte, der Vorschlag trifft also die Spalte, die in der Zeile noch
* leer ist. Ein Datum in der Zukunft scheidet aus: es ist kein Benotungsdatum.
*
* @returns {Date|null}
*/
ersteFreieDatumsspalte(student) {
const belegt = new Set((student.pruefungen ?? []).map(p => String(p.datum ?? '').slice(0, 10)))
const heute = this.toISODate(new Date())
const frei = (this.distinctPruefungsDates ?? []).find(datum => {
const tag = String(datum ?? '').slice(0, 10)
return tag !== '' && tag <= heute && !belegt.has(tag)
})
return frei ? this.parseISODate(frei) : null
},
/** Writes the course grade with the chosen date. */
saveNotenvorschlagEingabe() {
const student = this.uebernahmeStudent
if(!student) return
this.loading = true
this.$api.call(ApiNoten.saveNotenvorschlag(this.lv_id, this.sem_kurzbz, data.uid, data.note_vorschlag, data.punkte))
.then((res) => {
this.$api.call(ApiNoten.saveNotenvorschlag(
this.lv_id, this.sem_kurzbz, student.uid, student.note_vorschlag, student.punkte,
this.toISODate(this.uebernahmeDate)
)).then((res) => {
if (res.meta.status === 'success') {
const s = this.studenten.find(s => s.uid === data.uid)
const s = this.studenten.find(s => s.uid === student.uid)
// the same path as after an exam, so that the release state is the same
this.applyLvGesamtnote(s, res.data[0])
row.update({ lv_note: s.lv_note, freigegeben: s.freigegeben })
row.reformat() // trigger reformat of arrow
// the answer carries attempt 1, which the server wrote with the chosen date
if(res.data[0]?.verlauf) this.applyVerlauf(s, res.data[0].verlauf)
const row = this.$refs.notenTable?.tabulator?.rowManager?.getRowFromDataObject(s)?.getComponent()
if(row) {
row.update({ lv_note: s.lv_note, freigegeben: s.freigegeben })
row.reformat() // trigger reformat of arrow
}
this.preserveScroll(() => this.applyPruefungColumns())
}
}).finally(()=>this.loading = false)
}).finally(() => {
this.uebernahmeStudent = null
this.loading = false
})
this.$refs.modalContainerUebernahme.hide()
},
/**
* Attempt 1 and the course grade are the same assessment, therefore the proposal column
* stays open while only attempt 1 exists. From the first repeat on the grade belongs to
* the exam. The server applies the same rule (validateNotenvorschlag).
*/
hatWiederholung(student) {
return (student?.pruefungen?.length ?? 0) > 1
},
teilnotenFormatter(cell) {
const val = cell.getValue()
@@ -1350,6 +1402,21 @@ export const Benotungstool = {
return '<div style="">'+val+'</div>'
},
/**
* Meldet, warum diese Zeile keinen Antritt mehr bekommt. Die gesperrte kommissionelle
* Prüfung nennt einen anderen Grund als die erreichte Grenze.
*/
warnKeinAntritt(student, nachsatz) {
if(student.verlauf?.kommPruefGesperrt) {
this.$fhcAlert.alertWarning(this.$capitalize(this.$p.t('benotungstool/kommPruefNichtErlaubt', [student.uid])))
return
}
this.$fhcAlert.alertWarning(
'Student ' + student.uid + ' hat bereits ' + student.hoechsterAntritt +
' Prüfungsantritte abgelegt. ' + nachsatz
)
},
/** Tells you if this row can get one more attempt. The value comes from the server. */
canAddPruefung(student) {
return NotenRules.canAddPruefung(student, this.config)
@@ -1358,10 +1425,25 @@ export const Benotungstool = {
// an exam with a later date locks the grade; you can still correct the date
if(!pruefung) return false
const alle = [...(student.pruefungen ?? [])]
if(student.kommPruef) alle.push(student.kommPruef)
return (student.pruefungen ?? []).some(p => p.pruefung_id != pruefung.pruefung_id && p.position > pruefung.position)
},
/**
* The text of the badge tooltip. The column is a Prüfungstermin, the badge is an Antritt,
* and the two numbers are different: an exam without a counted attempt (excused, not
* assessed) keeps the Termin but uses no Antritt.
*/
antrittTooltip(student, pruefung) {
const max = student.verlauf?.maxAntritte ?? NotenRules.maxAntrittCount(this.config)
return alle.some(p => p.pruefung_id != pruefung.pruefung_id && p.position > pruefung.position)
if(pruefung.kommissionell) {
return pruefung.antritt_nr
? this.$capitalize(this.$p.t('benotungstool/c4badgeKommAntritt', [pruefung.antritt_nr, max]))
: this.$capitalize(this.$p.t('benotungstool/c4badgeKommOhneAntritt'))
}
return pruefung.antritt_nr
? this.$capitalize(this.$p.t('benotungstool/c4badgeAntritt', [pruefung.antritt_nr, max]))
: this.$capitalize(this.$p.t('benotungstool/c4badgeOhneAntritt'))
},
/** The cell of an exam column. indexPruefungen decides which attempt the cell shows. */
pruefungFormatter(cell) {
@@ -1408,11 +1490,11 @@ export const Benotungstool = {
}
if(studentPruefung) {
// The badge shows the attempt number. A kommissionelle exam shows K, because its number
// follows from the position anyway. An exam that does not count (excused, not assessed)
// has no number and gets a neutral style.
// The badge shows the attempt number. A kommissionelle exam adds the K, so the row shows
// both the number and the role ('3-K'). An exam that does not count (excused, not
// assessed) has no number and gets a neutral style.
const attemptLabel = studentPruefung.kommissionell
? 'K'
? (studentPruefung.antritt_nr ? studentPruefung.antritt_nr + '-K' : 'K')
: (studentPruefung.antritt_nr ? String(studentPruefung.antritt_nr) : '–')
const attemptClass = studentPruefung.kommissionell
? 'attempt-k'
@@ -1422,6 +1504,7 @@ export const Benotungstool = {
rowDiv.setAttribute('data-attempt', attemptLabel)
rowDiv.setAttribute('data-cy', 'pruefung-cell')
rowDiv.setAttribute('data-note', studentPruefung.note)
rowDiv.title = this.antrittTooltip(data, studentPruefung)
// The student administration owns the escalation (zusKommPruef), therefore that cell has
// no action button and the space belongs to the grade name.
@@ -1450,19 +1533,15 @@ export const Benotungstool = {
return rowDiv
}
// An empty cell gets an add button only if one more attempt is possible and if the column
// is after all exams that exist.
// An empty cell gets an add button only if one more Termin is possible and if the column
// is after all exams that exist. The kommissionelle Prüfung needs no own column: the
// server decides the role of the new exam, the cell then shows the K badge.
if(!this.canAddPruefung(data)) return ''
// The last attempt is kommissionell, therefore only the kommPruef column may take it. All
// other columns stay empty at that point.
const kommissionellFaellig = data.verlauf?.naechsteRolle === 'kommissionell'
if(field === 'kommPruef' ? !kommissionellFaellig : kommissionellFaellig) return ''
if(antrittModus && field !== 'kommPruef') {
// only the next free attempt column shows an add button
if(antrittModus) {
// only the next free Termin column shows an add button
if(field !== ('antritt_' + ((data.pruefungen?.length ?? 0) + 1))) return ''
} else if(field !== 'kommPruef') {
} else {
// no new exam before an exam that exists
if((data.pruefungen ?? []).some(p => p.datum >= field)) return ''
}
@@ -1481,6 +1560,13 @@ export const Benotungstool = {
if(parts.length !== 3) return null
return new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]))
},
/** Date -> 'YYYY-MM-DD'. The API takes the day, never a timestamp. */
toISODate(date) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0') // Months are 0-based
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
},
formatDatumDMY(datum) {
// 'YYYY-MM-DD[ ...]' -> 'DD.MM.YYYY'
const parts = (datum ?? '').slice(0, 10).split('-')
@@ -1495,7 +1581,7 @@ export const Benotungstool = {
getPruefungDateBounds(student, pruefung, fallbackDate) {
// the exam date must stay strictly between the dates of the chronologically adjacent
// pruefungen so the attempt order is preserved. Returns inclusive datepicker bounds.
// A column name that is not a date ('antritt_2', 'kommPruef') means a new exam after all
// A column name that is not a date ('antritt_2') means a new exam after all
// existing ones. Use a date in the far future, so every existing exam is a lower bound.
const raw = (pruefung?.datum ?? fallbackDate ?? '').slice(0, 10)
const refDate = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? raw : '9999-12-31'
@@ -1579,7 +1665,7 @@ export const Benotungstool = {
let style = 'display: flex; justify-content: center; align-items: center; height: 100%;'
if(!data.note_vorschlag || (data.note_vorschlag == data.lv_note) || data.pruefungen.length) {
if(!data.note_vorschlag || (data.note_vorschlag == data.lv_note) || this.hatWiederholung(data)) {
// arrow to ambiguous in meaning, use str8 forward worded button here instead
// uncolored arrow
// return '<div style="'+style+'">' +
@@ -1644,13 +1730,8 @@ export const Benotungstool = {
p.dateObj = this.parseISODate(p.datum)
// the final exam has its own column, and that column is always the last one
if(p.kommissionell) {
student['kommPruef'] = p
} else {
student.pruefungen.push(p)
if(!this.distinctPruefungsDates.includes(p.datum)) this.distinctPruefungsDates.push(p.datum)
}
student.pruefungen.push(p)
if(!this.distinctPruefungsDates.includes(p.datum)) this.distinctPruefungsDates.push(p.datum)
})
this.distinctPruefungsDates.sort()
@@ -1927,10 +2008,7 @@ export const Benotungstool = {
return
}
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 dateStr = `${year}-${month}-${day}`;
const dateStr = this.toISODate(this.selectedPruefungDate);
// when the grade is locked (later pruefung exists) keep the existing note untouched
const note = this.pruefungNoteLocked && this.pruefung
@@ -2029,6 +2107,7 @@ export const Benotungstool = {
return loaded
})
}).finally(() => {
this.password = '' // das Passwort verlässt den Speicher mit dem Dialog
this.loading = false
})
@@ -2068,7 +2147,7 @@ export const Benotungstool = {
// check if student antrittCount is too high already
if(!this.canAddPruefung(student)) {
this.$fhcAlert.alertWarning('Student ' + student.uid + ' hat bereits ' + student.hoechsterAntritt + ' Prüfungsantritte abgelegt. Es wird keine Prüfung angelegt.')
this.warnKeinAntritt(student, 'Es wird keine Prüfung angelegt.')
return
}
@@ -2196,9 +2275,6 @@ export const Benotungstool = {
this.selectedLehreinheit = null
}
},
kommPruefSpalteNoetig() {
this.applyKommPruefSpalte()
},
selectedPruefungNote(newVal, oldVal) {
if (!newVal || !this.pruefungStudent) return
@@ -2210,9 +2286,8 @@ export const Benotungstool = {
const limit = limitMap[note]
if (limit == null) return
// all exams of the history; the row holds the final exam separately
// all exams of the history, the kommissionelle Prüfung included
const allPruefungen = [...this.pruefungStudent.pruefungen]
if (this.pruefungStudent.kommPruef) allPruefungen.push(this.pruefungStudent.kommPruef)
// count existing occurrences of this note, excluding the pruefung being edited
// (its note is about to be replaced by this very selection)
@@ -2287,39 +2362,6 @@ export const Benotungstool = {
.map(r => r.getData())
.filter(s => s.selectable)
},
getKommPruefCount(){
let counter = 0
this.studenten?.forEach(s => {if(s['kommPruef']){counter++}})
return counter
},
/**
* Titel der kommissionellen Spalte. Im Datumsmodus tragen alle anderen Spalten ihr Datum im
* Kopf, daher bekommt diese es auch - aber nur, wenn alle Termine darin auf denselben Tag
* fallen. Bei verschiedenen Daten steht das Datum weiterhin in der Zelle.
*/
kommPruefSpaltentitel() {
const titel = this.$capitalize(this.$p.t('benotungstool/c4kommPruef'))
if(this.pruefungsspaltenModus !== 'datum') return titel
const daten = new Set(
(this.studenten ?? [])
.map(s => (s.kommPruef?.datum ?? '').slice(0, 10))
.filter(d => d !== '')
)
if(daten.size !== 1) return titel
return `${titel} - ${this.formatDatumDMY([...daten][0])}`
},
/**
* Die kommissionelle Spalte wird gebraucht, sobald ein Termin darin steht ODER bei einer
* Zeile der letzte Antritt fällig ist - sonst fehlt der Platz zum Anlegen.
*/
kommPruefSpalteNoetig() {
if(!this.config?.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) return false
if(this.getKommPruefCount > 0) return true
return (this.studenten ?? []).some(s => s.verlauf?.naechsteRolle === 'kommissionell')
},
getSaveBtnClass() {
return this.changedNoten?.length ? "btn btn-primary ml-2" : "btn btn-secondary ml-2"
},
@@ -2364,10 +2406,16 @@ export const Benotungstool = {
return this.$capitalize(this.$p.t('benotungstool/notenfreigabeHinweistextv4'))
},
getPruefungimportHinweistext() {
return this.$capitalize(this.$p.t('benotungstool/notenimportHinweistextv6'))
return this.$capitalize(this.$p.t('benotungstool/notenimportHinweistextv6')) + this.getNotenkuerzelHinweis
},
getNotenimportHinweistext() {
return this.$capitalize(this.$p.t('benotungstool/notenimportHinweistextv5'))
return this.$capitalize(this.$p.t('benotungstool/notenimportHinweistextv5')) + this.getNotenkuerzelHinweis
},
/** One more bullet for both import dialogs, as long as the shorthand is allowed. */
getNotenkuerzelHinweis() {
if(!this.config?.CIS_GESAMTNOTE_IMPORT_NOTENKUERZEL) return ''
return '• ' + this.$capitalize(this.$p.t('benotungstool/c4importNotenkuerzel')) + '<br>'
},
freezableColumnOptions() {
// the identity columns the user may pin to the left (matches freezableColumnFields)
@@ -2522,7 +2570,37 @@ export const Benotungstool = {
</template>
</bs-modal>
<bs-modal data-cy="modal-freigabe" ref="modalContainerNotenSpeichern" class="bootstrap-prompt" dialogClass="modal-lg" bodyClass="px-4 py-4">
<bs-modal data-cy="modal-uebernahme" ref="modalContainerUebernahme" class="bootstrap-prompt" bodyClass="px-3 py-3">
<template v-slot:title>{{$capitalize($p.t('benotungstool/c4notenvorschlagUebernehmen'))}}: {{uebernahmeStudent?.vorname}} {{uebernahmeStudent?.nachname}}</template>
<template v-slot:default>
<div class="d-flex align-items-center gap-2">
<span class="text-nowrap">{{$capitalize($p.t('benotungstool/c4benotungsdatum'))}}:</span>
<div class="flex-grow-1" data-cy="uebernahme-datum">
<datepicker
v-model="uebernahmeDate"
:clearable="false"
:enableTimePicker="false"
format="dd.MM.yyyy"
placeholder="TT.MM.JJJJ"
:max-date="uebernahmeMaxDate"
:text-input="true"
:auto-apply="true"
autocomplete="off">
</datepicker>
</div>
</div>
<div class="text-muted small mt-2" data-cy="uebernahme-hinweis">
{{$capitalize($p.t('benotungstool/c4benotungsdatumHinweis'))}}
</div>
</template>
<template v-slot:footer>
<button type="button" data-cy="uebernahme-submit" class="btn btn-primary" @click="saveNotenvorschlagEingabe">{{ $capitalize($p.t('global/speichern')) }}</button>
</template>
</bs-modal>
<bs-modal data-cy="modal-freigabe" ref="modalContainerNotenSpeichern" class="bootstrap-prompt" dialogClass="modal-lg" bodyClass="px-4 py-4"
@hideBsModal="freigabeModalVisible = false"
@showBsModal="freigabeModalVisible = true">
<template v-slot:title>{{ $p.t('benotungstool/noteneingabeSpeichern') }}</template>
<template v-slot:default>
<div class="row justify-content-center">
@@ -2558,7 +2636,12 @@ export const Benotungstool = {
<div class="col-12 text-center text-muted" data-cy="freigabe-summary-empty">{{ $p.t('benotungstool/c4freigabeSummaryEmpty') }}</div>
</div>
<div class="row mt-3 justify-content-center">
<div class="col-auto" data-cy="freigabe-passwort">
<!--
Das Passwortfeld entsteht erst mit dem geöffneten Dialog. Sonst steht es dauerhaft
im Dokument, und der Passwortmanager bietet seine Zugangsdaten über jedem anderen
Textfeld an, zum Beispiel über dem Datumsfeld der Notenübernahme.
-->
<div v-if="freigabeModalVisible" class="col-auto" data-cy="freigabe-passwort">
<Password v-model="password" :feedback="false" showIcon="fa fa-eye" :toggleMask="true" :promptLabel="$p.t('benotungstool/passwort')"></Password>
</div>
</div>
@@ -2751,9 +2834,9 @@ export const Benotungstool = {
<div class="btn-group ml-2" role="group">
<button type="button"
:class="pruefungsspaltenModus === 'antritt' ? 'btn btn-primary' : 'btn btn-outline-primary'"
:title="$capitalize($p.t('benotungstool/c4spaltenAntrittHint'))"
:title="$capitalize($p.t('benotungstool/c4spaltenTerminHint'))"
@click="setPruefungsspalten('antritt')">
{{$capitalize($p.t('benotungstool/c4spaltenAntritt'))}}
{{$capitalize($p.t('benotungstool/c4spaltenTermin'))}}
</button>
<button type="button"
:class="pruefungsspaltenModus === 'datum' ? 'btn btn-primary' : 'btn btn-outline-primary'"
@@ -36,7 +36,8 @@ export const antrittCountStudent = (student, config, notenOptions) => {
/** Tells you if the student can get one more attempt. */
export const canAddPruefung = (student, config) => {
if (student.verlauf) return student.verlauf.canAdd;
return !student.kommPruef && antrittCountStudent(student, config) < maxAntrittCount(config);
const hatKommPruef = (student.pruefungen ?? []).some((p) => p.kommissionell);
return !hatKommPruef && antrittCountStudent(student, config) < maxAntrittCount(config);
};
/**
+209 -9
View File
@@ -61461,6 +61461,106 @@ I have been informed that I am under no obligation to consent to the transmissio
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'kommPruefNichtErlaubt',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Die kommissionelle Prüfung für {0} wird nicht in diesem Werkzeug angelegt.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'The oral examination for {0} is not created in this tool.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'c4benotungsdatum',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Benotungsdatum',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Assessment date',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'c4benotungsdatumHinweis',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Die Freigabe macht dieses Datum zum Datum des ersten Antritts.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'The release turns this date into the date of the first attempt.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'benotungsdatumUngueltig',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Das Benotungsdatum für {0} ist ungültig.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'The assessment date for {0} is not valid.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'benotungsdatumInZukunft',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Das Benotungsdatum für {0} darf nicht in der Zukunft liegen.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'The assessment date for {0} must not be in the future.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
@@ -62067,6 +62167,26 @@ I have been informed that I am under no obligation to consent to the transmissio
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'c4importNotenkuerzel',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Statt der Note können Sie das Kürzel aus der Notenliste eintragen. Beispiel: nb für die Note Nicht beurteilt.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Instead of the grade you can enter the shorthand from the grade list. Example: nb for the grade Nicht beurteilt.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
@@ -62170,18 +62290,18 @@ I have been informed that I am under no obligation to consent to the transmissio
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'c4antrittNr',
'phrase' => 'c4terminNr',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => '{0}. Antritt',
'text' => '{0}. Prüfungstermin',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Attempt {0}',
'text' => 'Exam session {0}',
'description' => '',
'insertvon' => 'system'
)
@@ -62190,18 +62310,18 @@ I have been informed that I am under no obligation to consent to the transmissio
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'c4spaltenAntritt',
'phrase' => 'c4spaltenTermin',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Antritte',
'text' => 'Termine',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Attempts',
'text' => 'Exam sessions',
'description' => '',
'insertvon' => 'system'
)
@@ -62210,18 +62330,98 @@ I have been informed that I am under no obligation to consent to the transmissio
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'c4spaltenAntrittHint',
'phrase' => 'c4spaltenTerminHint',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Eine Spalte je Antritt, das Prüfungsdatum steht in der Zelle.',
'text' => 'Eine Spalte je Prüfungstermin. 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.',
'text' => 'One column per exam session. The cell shows the exam date.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'c4badgeAntritt',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => '{0}. Antritt von {1}. Die Spalte zeigt den Prüfungstermin. Das Symbol zeigt den Antritt.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Attempt {0} of {1}. The column shows the exam date. The badge shows the attempt.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'c4badgeKommAntritt',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Kommissionelle Prüfung. {0}. Antritt von {1}.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Oral examination. Attempt {0} of {1}.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'c4badgeOhneAntritt',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Dieser Prüfungstermin zählt keinen Antritt.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'This exam date uses no attempt.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'c4badgeKommOhneAntritt',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Kommissionelle Prüfung. Dieser Prüfungstermin zählt keinen Antritt.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Oral examination. This exam date uses no attempt.',
'description' => '',
'insertvon' => 'system'
)