From 6d2cbf489602cb4f6876972774e35a587c79c7be Mon Sep 17 00:00:00 2001 From: Johann Hoffmann Date: Thu, 10 Sep 2026 13:04:11 +0200 Subject: [PATCH] =?UTF-8?q?adapt=20import=20to=20also=20parse=20note.anmer?= =?UTF-8?q?kung=20(entschuldigt=20-=20en,=20nicht=20beurteilt=20-=20nb,=20?= =?UTF-8?q?etc)=20if=20configured;=20new=20config=20if=20the=20benotungsto?= =?UTF-8?q?ol=20is=20allowed=20to=20create=20kommissionelle=20pr=C3=BCfung?= =?UTF-8?q?en=20or=20if=20that=20is=20a=20job=20for=20assistenz=20in=20stv?= =?UTF-8?q?;=20coded=20a=20kommPruef=20as=20the=20last=20pruefungsattempt?= =?UTF-8?q?=20and=20not=20a=20special=20case=20so=20it=20follows=20the=20s?= =?UTF-8?q?ame=20ordering=20rules=20in=20the=20table;=20changed=20headers?= =?UTF-8?q?=20from=20"Antritte"=20to=20"Pr=C3=BCfungstermine"=20to=20keep?= =?UTF-8?q?=20the=20wording=20aligned=20with=20the=20domain=20objects=20de?= =?UTF-8?q?finitions;=20tooltip=20explaining=20the=20current=20antritt=20w?= =?UTF-8?q?hile=20hovering=20over=20the=20badge=20(1,2,3-K)=20(maybe=20mak?= =?UTF-8?q?e=20the=20badges=20contents=20configurable=3F);=20changed=20the?= =?UTF-8?q?=20notenOption=20sort=20to=20resemble=20stv:=20order=201-5=20by?= =?UTF-8?q?=20value=20and=20afterwards=20alphabetically;=20actually=20remo?= =?UTF-8?q?ved=20redundant=20configs=20for=20defining=20which=20noten=20co?= =?UTF-8?q?unt=20for=20antritte=20->=20only=20go=20by=20bezeichnung=20and?= =?UTF-8?q?=20fetch=20the=20matching=20primary=20keys=20from=20each=20db?= =?UTF-8?q?=20as=20they=20can=20have=20mixed=20pk's;=20vorschlag=20=C3=BCb?= =?UTF-8?q?ernehmen=20now=20upserts=20the=20first=20pr=C3=BCfungsattempt,?= =?UTF-8?q?=20aswell=20as=20the=20notenfreigabe(if=20no=20other=20pruefung?= =?UTF-8?q?en=20exist)=20which=20should=20fix=20the=20possibility=20of=20c?= =?UTF-8?q?reating=20a=20pruefungseries=20that=20starts=20with=20termin2,?= =?UTF-8?q?=20vorschlag=20also=20carries=20a=20datepicker=20writing=20the?= =?UTF-8?q?=20date=20of=20that=20first=20pr=C3=BCfung=20but=20not=20the=20?= =?UTF-8?q?benotungsdatum=20->=20that=20needs=20to=20stay=20the=20entry=20?= =?UTF-8?q?date=20since=20the=20freigabemechanismus=20hinges=20on=20it;=20?= =?UTF-8?q?added=20a=20flag=20handling=20the=20password=20inputs=20browser?= =?UTF-8?q?=20prompt=20to=20not=20appear=20on=20other=20inputs;=20WIP=20co?= =?UTF-8?q?ming=20up=20with=20even=20more=20configurability=20in=20order?= =?UTF-8?q?=20to=20cover=20uncertain=20workflow=20this=20tool=20is=20suppo?= =?UTF-8?q?sed=20to=20represent;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- application/config/noten.php | 35 +- .../controllers/api/frontend/v1/Noten.php | 188 ++++++--- application/libraries/PruefungsverlaufLib.php | 176 +++++++-- application/models/education/Note_model.php | 17 +- public/css/Cis4/Benotungstool.css | 17 +- public/js/api/factory/noten.js | 6 +- .../Cis/Benotungstool/Benotungstool.js | 369 +++++++++++------- .../Cis/Benotungstool/notenRules.js | 3 +- system/phrasesupdate.php | 218 ++++++++++- 9 files changed, 748 insertions(+), 281 deletions(-) diff --git a/application/config/noten.php b/application/config/noten.php index 81f467a13..dd901d803 100644 --- a/application/config/noten.php +++ b/application/config/noten.php @@ -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) diff --git a/application/controllers/api/frontend/v1/Noten.php b/application/controllers/api/frontend/v1/Noten.php index 6a7d0f640..e272757d6 100644 --- a/application/controllers/api/frontend/v1/Noten.php +++ b/application/controllers/api/frontend/v1/Noten.php @@ -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())); + } } /** diff --git a/application/libraries/PruefungsverlaufLib.php b/application/libraries/PruefungsverlaufLib.php index e0087e527..fae5ec05c 100644 --- a/application/libraries/PruefungsverlaufLib.php +++ b/application/libraries/PruefungsverlaufLib.php @@ -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; } diff --git a/application/models/education/Note_model.php b/application/models/education/Note_model.php index e92bb8452..5e154e18e 100644 --- a/application/models/education/Note_model.php +++ b/application/models/education/Note_model.php @@ -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() { diff --git a/public/css/Cis4/Benotungstool.css b/public/css/Cis4/Benotungstool.css index 225cfadf8..da778c2c3 100644 --- a/public/css/Cis4/Benotungstool.css +++ b/public/css/Cis4/Benotungstool.css @@ -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; } \ No newline at end of file diff --git a/public/js/api/factory/noten.js b/public/js/api/factory/noten.js index bf78a2d3b..870c4c42f 100644 --- a/public/js/api/factory/noten.js +++ b/public/js/api/factory/noten.js @@ -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 diff --git a/public/js/components/Cis/Benotungstool/Benotungstool.js b/public/js/components/Cis/Benotungstool/Benotungstool.js index a4272a597..f77d30d2a 100644 --- a/public/js/components/Cis/Benotungstool/Benotungstool.js +++ b/public/js/components/Cis/Benotungstool/Benotungstool.js @@ -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 '
' + val + '
' } }) 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 '
'+val+'
' }, + /** + * 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 '
' + @@ -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')) + '
' }, freezableColumnOptions() { // the identity columns the user may pin to the left (matches freezableColumnFields) @@ -2522,7 +2570,37 @@ export const Benotungstool = { - + + + + + + +