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 '