From 32de4aac6b74679aae532c49e1be35db6e577815 Mon Sep 17 00:00:00 2001 From: Johann Hoffmann Date: Thu, 23 Jul 2026 16:59:40 +0200 Subject: [PATCH] =?UTF-8?q?assistenz=20recht=20&=20features=20gesamtnotene?= =?UTF-8?q?ingabe;=20load=20stg=20array=20for=20assistenzen=20to=20choose?= =?UTF-8?q?=20any=20lva=20from;=20added=20configurable=20datelimit=20for?= =?UTF-8?q?=20each=20semester=20with=20defaults=20as=20per=20upcoming=20pr?= =?UTF-8?q?=C3=BCfungsordnung;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- application/config/noten.php | 12 +- application/controllers/Cis/Benotungstool.php | 2 +- .../controllers/api/frontend/v1/Noten.php | 509 ++++++++++++++++-- .../education/Lehrveranstaltung_model.php | 46 ++ public/js/api/factory/noten.js | 14 + .../Cis/Benotungstool/Benotungstool.js | 335 ++++++++---- system/checksystem.php | 1 + system/phrasesupdate.php | 100 ++++ 8 files changed, 869 insertions(+), 150 deletions(-) diff --git a/application/config/noten.php b/application/config/noten.php index 6fdee8b86..7bdfce1ad 100644 --- a/application/config/noten.php +++ b/application/config/noten.php @@ -14,4 +14,14 @@ $config['NOTE_ENTSCHULDIGT'] = 17; // availability of the two Benotungstool import flows. When both are true they are shown as // separate buttons/dialogs. $config['CIS_GESAMTNOTE_PRUEFUNGSIMPORT'] = true; // dated import that creates a pruefung per row -$config['CIS_GESAMTNOTE_NOTENIMPORT'] = false; // classic note-only import (uid + note, no date) \ No newline at end of file +$config['CIS_GESAMTNOTE_NOTENIMPORT'] = false; // classic note-only import (uid + note, no date) + +$config['SHOW_BENOTUNGSDATUM_ON_NOTENVORSCHLAG_UEBERNAHME'] = true; + +// Noteneintragungsfrist (Prüfungsordnung §1): grade/pruefung entry is only permitted up to this +// deadline. The month/day below is applied to the studiensemester's year: +// Sommersemester (SSyyyy) -> deadline in the SAME calendar year (default 15th November) +// Wintersemester (WSyyyy) -> deadline in the FOLLOWING calendar year (default 15th May) +$config['CIS_GESAMTNOTE_NOTENEINTRAGUNGSFRIST'] = false; // switch to use the window enforcement +$config['NOTENEINTRAGUNGSFRIST_SS'] = ['month' => 11, 'day' => 15]; // Sommersemester deadline (same year) +$config['NOTENEINTRAGUNGSFRIST_WS'] = ['month' => 5, 'day' => 15]; // Wintersemester deadline (following year) \ No newline at end of file diff --git a/application/controllers/Cis/Benotungstool.php b/application/controllers/Cis/Benotungstool.php index 94faeff98..fe01f3595 100644 --- a/application/controllers/Cis/Benotungstool.php +++ b/application/controllers/Cis/Benotungstool.php @@ -13,7 +13,7 @@ class Benotungstool extends Auth_Controller public function __construct() { parent::__construct([ - 'index' => self::PERM_LOGGED + 'index' => array('lehre/benotungstool:rw', 'lehre/benotungstool_assistenz:rw') ]); $this->_ci =& get_instance(); diff --git a/application/controllers/api/frontend/v1/Noten.php b/application/controllers/api/frontend/v1/Noten.php index 0cabb639f..98ba825a8 100644 --- a/application/controllers/api/frontend/v1/Noten.php +++ b/application/controllers/api/frontend/v1/Noten.php @@ -28,18 +28,21 @@ class Noten extends FHCAPI_Controller */ public function __construct() { + $permissions = array('lehre/benotungstool:rw', 'lehre/benotungstool_assistenz:rw'); parent::__construct([ - 'getStudentenNoten' => array('lehre/benotungstool:rw'), - 'getNoten' => array('lehre/benotungstool:rw'), - 'saveStudentenNoten' => array('lehre/benotungstool:rw'), - 'getNotenvorschlagStudent' => array('lehre/benotungstool:rw'), - 'saveNotenvorschlag' => array('lehre/benotungstool:rw'), - 'saveStudentPruefung' => array('lehre/benotungstool:rw'), - 'createPruefungen' => array('lehre/benotungstool:rw'), - 'saveNotenvorschlagBulk' => array('lehre/benotungstool:rw'), - 'savePruefungenBulk' => array('lehre/benotungstool:rw'), - 'getCisConfig' => array('lehre/benotungstool:rw'), - 'getNoteByPunkte' => array('lehre/benotungstool:rw') + 'getStudentenNoten' => $permissions, + 'getNoten' => $permissions, + 'saveStudentenNoten' => $permissions, + 'getNotenvorschlagStudent' => $permissions, + 'saveNotenvorschlag' => $permissions, + 'saveStudentPruefung' => $permissions, + 'createPruefungen' => $permissions, + 'saveNotenvorschlagBulk' => $permissions, + 'savePruefungenBulk' => $permissions, + 'getCisConfig' => $permissions, + 'getNoteByPunkte' => $permissions, + 'getBenotungstoolContext' => $permissions, + 'getLvForStudiengang' => $permissions ]); $this->load->library('AuthLib', null, 'AuthLib'); @@ -72,6 +75,7 @@ class Noten extends FHCAPI_Controller $this->load->model('education/Lvgesamtnote_model', 'LvgesamtnoteModel'); $this->load->model('education/Lehrveranstaltung_model', 'LehrveranstaltungModel'); $this->load->model('education/Notenschluesselaufteilung_model', 'NotenschluesselaufteilungModel'); + $this->load->model('education/Note_model', 'NoteModel'); $this->load->model('person/Person_model', 'PersonModel'); $this->load->model('organisation/Studienplan_model', 'StudienplanModel'); $this->load->model('crm/Student_model', 'StudentModel'); @@ -84,9 +88,11 @@ class Noten extends FHCAPI_Controller } public function getCisConfig() { - $NOTEN_OHNE_ANTRITT = $this->config->item('NOTEN_OHNE_ANTRITT'); - $NOTEN_OCCURANCE_LIMIT_MAP = $this->config->item('NOTEN_OCCURANCE_LIMIT_MAP'); - $NOTE_ENTSCHULDIGT = $this->config->item('NOTE_ENTSCHULDIGT'); + // resolved from tbl_note (Bezeichnung) with config fallback -> single source of truth + $special = $this->resolveSpecialNotes(); + $NOTEN_OHNE_ANTRITT = $special['ohneAntritt']; + $NOTEN_OCCURANCE_LIMIT_MAP = $special['limitMap']; + $NOTE_ENTSCHULDIGT = $special['entschuldigt']; $this->terminateWithSuccess( array( @@ -99,12 +105,12 @@ class Noten extends FHCAPI_Controller // only relevant in punkte calculation in backend // 'CIS_GESAMTNOTE_GEWICHTUNG' => CIS_GESAMTNOTE_GEWICHTUNG, - // this one should always be set true since fh prüfungsordnung requires at least 3 antritte (t1+t2+kP) - // send it anyway to use in maxAntritte calculation + // enables the first in-tool retake (Termin2). + // Used in the maxAntritte calculation. 'CIS_GESAMTNOTE_PRUEFUNG_TERMIN2' => CIS_GESAMTNOTE_PRUEFUNG_TERMIN2, - - // should in 99% of cases be kept true to enable 4 antritte in total, but if a certain - // fh still works with 3 antritte per note this can limit the max number of antritte accordingly + + // legacy: enables a SECOND in-tool retake (Termin3) for other installations whose ruleset + // uses three explicit Nachprüfungs-Termine. Each enabled retake type raises the maxAntritte cap. 'CIS_GESAMTNOTE_PRUEFUNG_TERMIN3' => CIS_GESAMTNOTE_PRUEFUNG_TERMIN3, // used to toggle availability of kommPruef type pruefungen @@ -133,11 +139,144 @@ class Noten extends FHCAPI_Controller 'NOTEN_OCCURANCE_LIMIT_MAP' => $NOTEN_OCCURANCE_LIMIT_MAP, // pk of the 'entschuldigt' note; used to preserve excused Termine on new pruefung creation - 'NOTE_ENTSCHULDIGT' => $NOTE_ENTSCHULDIGT + 'NOTE_ENTSCHULDIGT' => $NOTE_ENTSCHULDIGT, + + // show the Benotungsdatum of the first Antritt in the "Ursprüngliche Zeugnisnote" column + 'SHOW_BENOTUNGSDATUM_ON_NOTENVORSCHLAG_UEBERNAHME' => $this->config->item('SHOW_BENOTUNGSDATUM_ON_NOTENVORSCHLAG_UEBERNAHME'), + + // Noteneintragungsfrist window (enforced server-side; also surfaced so the UI can hint at it) + 'CIS_GESAMTNOTE_NOTENEINTRAGUNGSFRIST' => $this->config->item('CIS_GESAMTNOTE_NOTENEINTRAGUNGSFRIST') ) ); } + /** + * GET METHOD + * expects (optional) 'sem_kurzbz' + * Single role-determining entry point for the tool: the server decides which flow the current user + * gets and returns exactly the initial dropdown data for it. + * A teacher (holds lehre/benotungstool) gets their assigned + * Lehrveranstaltungen directly. Assistenz (only lehre/benotungstool_assistenz) gets the + * Studiengänge they are entitled for and picks one before an LV is loaded. + */ + public function getBenotungstoolContext() { + $sem_kurzbz = $this->input->get("sem_kurzbz", TRUE); + $lv_id = $this->input->get("lv_id", TRUE); // optional: deep-link target, used to preselect + + $this->load->library('PermissionLib'); + + // teachers keep the classic assigned-LV flow; the Studiengang flow is only for Assistenz. + // Role determination mirrors assertLvAccess, which scopes each role's actual data + // access (teachers to their own LVs, Assistenzen to their entitled Studiengänge). + $isLektor = $this->permissionlib->isBerechtigt('lehre/benotungstool'); + $entitledStgs = $this->permissionlib->getSTG_isEntitledFor('lehre/benotungstool_assistenz'); + $isAssistenz = !$isLektor && is_array($entitledStgs) && count($entitledStgs) > 0; + + $studiengaenge = array(); + $lehrveranstaltungen = array(); + $preselectStudiengang_kz = null; + + if (isset($sem_kurzbz) && !isEmptyString($sem_kurzbz)) { + if ($isAssistenz) { + $this->load->model('organisation/Studiengang_model', 'StudiengangModel'); + $result = $this->StudiengangModel->getByStgs($entitledStgs, $sem_kurzbz); + if (!isError($result)) $studiengaenge = getData($result) ?? array(); + + // deep-link: resolve the Studiengang of the requested LV so the frontend can preselect + // the Studiengang dropdown (and then its LV) - only if the Assistenz is entitled for it + if (isset($lv_id) && !isEmptyString($lv_id)) { + $res = $this->LehrveranstaltungModel->load($lv_id); + if (!isError($res) && hasData($res)) { + $stg = getData($res)[0]->studiengang_kz; + if (in_array($stg, $entitledStgs)) $preselectStudiengang_kz = $stg; + } + } + } else { + $result = $this->LehrveranstaltungModel->getLvForLektorInSemester($sem_kurzbz, getAuthUID()); + if (!isError($result)) $lehrveranstaltungen = getData($result) ?? array(); + } + } + + $this->terminateWithSuccess(array( + 'isAssistenz' => $isAssistenz, + 'studiengaenge' => $studiengaenge, + 'lehrveranstaltungen' => $lehrveranstaltungen, + 'preselectStudiengang_kz' => $preselectStudiengang_kz + )); + } + + /** + * GET METHOD + * expects 'studiengang_kz', 'sem_kurzbz' + * Returns the Lehrveranstaltungen of a Studiengang in a Studiensemester for the Assistenz flow. + * The caller may only load Studiengänge they are entitled for via lehre/benotungstool_assistenz. + */ + public function getLvForStudiengang() { + $studiengang_kz = $this->input->get("studiengang_kz", TRUE); + $sem_kurzbz = $this->input->get("sem_kurzbz", TRUE); + + if (!isset($studiengang_kz) || isEmptyString($studiengang_kz) + || !isset($sem_kurzbz) || isEmptyString($sem_kurzbz)) { + $this->terminateWithError($this->p->t('global', 'wrongParameters'), 'general'); + } + + $this->load->library('PermissionLib'); + $entitledStgs = $this->permissionlib->getSTG_isEntitledFor('lehre/benotungstool_assistenz'); + $isAdmin = $this->permissionlib->isBerechtigt('admin'); + + if (!$isAdmin && (!is_array($entitledStgs) || !in_array($studiengang_kz, $entitledStgs))) { + $this->terminateWithError($this->p->t('ui', 'keineBerechtigung'), 'general'); + } + + $result = $this->LehrveranstaltungModel->getLvForStudiengangInSemester($sem_kurzbz, $studiengang_kz); + $data = $this->getDataOrTerminateWithError($result); + $this->terminateWithSuccess($data); + } + + /** + * Scopes data access to the Lehrveranstaltungen a user may act on, so a shared/guessed URL cannot be + * used to read or edit foreign grades. A teacher (lehre/benotungstool) may + * only touch LVs they are assigned to teach in the given Studiensemester. Assistenz may + * touch LVs of a Studiengang they are entitled for. + */ + private function assertLvAccess($lv_id, $sem_kurzbz = null) + { + $this->load->library('PermissionLib'); + + // admins keep full access + if ($this->permissionlib->isBerechtigt('admin')) return; + + // teachers: only their own LVs (assigned as lehreinheitmitarbeiter in this semester) + if ($this->permissionlib->isBerechtigt('lehre/benotungstool')) { + $res = $this->LehrveranstaltungModel->getLektorIsTeachingLva($lv_id, getAuthUID(), $sem_kurzbz); + $rows = getData($res); + if (!isError($res) && !empty($rows) && $rows[0]->teaches > 0) return; + // not a teacher of this LV -> fall through (a both-role user may still be entitled as Assistenz) + } + + // (pure or additional) Assistenz: only LVs of an entitled Studiengang + $entitledStgs = $this->permissionlib->getSTG_isEntitledFor('lehre/benotungstool_assistenz'); + $lv = null; + if (is_array($entitledStgs) && count($entitledStgs) > 0) { + $res = $this->LehrveranstaltungModel->load($lv_id); + if (!isError($res) && hasData($res)) { + $lv = getData($res)[0]; + if (in_array($lv->studiengang_kz, $entitledStgs)) return; + } + } + + if ($lv === null) { + $res = $this->LehrveranstaltungModel->load($lv_id); + if (!isError($res) && hasData($res)) $lv = getData($res)[0]; + } + $bezeichnung = $lv !== null ? $lv->bezeichnung : $lv_id; + + $this->terminateWithError( + $this->p->t('benotungstool', 'keineBerechtigungNoten', [$bezeichnung, $sem_kurzbz]), + 'general' + ); + } + /** * GET METHOD * expects 'lv_id', 'sem_kurzbz' @@ -153,13 +292,15 @@ class Noten extends FHCAPI_Controller if (!isset($lv_id) || isEmptyString($lv_id) || !isset($sem_kurzbz) || isEmptyString($sem_kurzbz)) $this->terminateWithError($this->p->t('global', 'wrongParameters'), 'general'); - + + $this->assertLvAccess($lv_id, $sem_kurzbz); + // get studenten for lva & sem with zeugnisnote if available $studenten = $this->LehrveranstaltungModel->getStudentsByLv($sem_kurzbz, $lv_id); $studentenData = $this->getDataOrTerminateWithError($studenten); if(count($studentenData) == 0) { - $this->terminateWithError('No students found for lva and semester'); + $this->terminateWithError($this->p->t('benotungstool', 'c4keineStudentenGefunden')); } $func = function ($value) { @@ -325,9 +466,11 @@ class Noten extends FHCAPI_Controller $lv_id = $result->lv_id; $sem_kurzbz = $result->sem_kurzbz; - + + $this->assertLvAccess($lv_id, $sem_kurzbz); + $ret = []; - + $res = $this->LehrveranstaltungModel->load($lv_id); if(isError($res) || !hasData($res)) { $this->terminateWithError($this->p->t('benotungstool', 'noValidLvFoundForId', [$lv_id])); @@ -370,7 +513,7 @@ class Noten extends FHCAPI_Controller " . $this->p->t('lehre','studiengang') . "\n " . $this->p->t('benotungstool','c4nachname') . "\n " . $this->p->t('benotungstool','c4vorname') . "\n"; - if(defined(CIS_GESAMTNOTE_PUNKTE) && CIS_GESAMTNOTE_PUNKTE) { + if(defined('CIS_GESAMTNOTE_PUNKTE') && CIS_GESAMTNOTE_PUNKTE) { $studlist .= "" . $this->p->t('benotungstool','c4punkte') . "\n"; } $studlist .= "" . $this->p->t('benotungstool','c4grade') . "\n"; @@ -382,7 +525,6 @@ class Noten extends FHCAPI_Controller foreach($result->noten as $note) { $resultLVGes = $this->LvgesamtnoteModel->getLvGesamtNoteVorschlag($lv_id, $note->uid, $sem_kurzbz); -// $this->addMeta($note->uid.'$resultLVGes', $resultLVGes); if (!isError($resultLVGes) && hasData($resultLVGes)) { $lvgesamtnote = getData($resultLVGes)[0]; @@ -416,7 +558,7 @@ class Noten extends FHCAPI_Controller $studlist .= "" . trim($note->nachname) . ""; $studlist .= "" . trim($note->vorname) . ""; - if(defined(CIS_GESAMTNOTE_PUNKTE) && CIS_GESAMTNOTE_PUNKTE) { + if(defined('CIS_GESAMTNOTE_PUNKTE') && CIS_GESAMTNOTE_PUNKTE) { $studlist .= "" . trim($lvgesamtnote->punkte) . ""; } $studlist .= "" .$note->noteBezeichnung. ""; @@ -493,10 +635,9 @@ class Noten extends FHCAPI_Controller || $lv_id === NULL || trim((string)$lv_id) === '') { $this->terminateWithError($this->p->t('global', 'missingParameters'), 'general'); } + + $this->assertLvAccess($lv_id, $sem_kurzbz); - // TODO: we need a zuordnungscheck here? any lektor can get any grades? - // what about assistenz with different rights doing lectors job once again? - // students checking their own grades? $result = $this->LvgesamtnoteModel->getLvGesamtNoteVorschlag($lv_id, $uid, $sem_kurzbz); $data = $this->getDataOrTerminateWithError($result); @@ -540,6 +681,11 @@ class Noten extends FHCAPI_Controller $stsem = $result->sem_kurzbz; $typ = $result->typ; + $this->assertLvAccess($lva_id, $stsem); + + // Prüfungsordnung §1: no entry/change past the Noteneintragungsfrist + $this->enforceNoteneintragungsfrist($stsem); + $jetzt = date("Y-m-d H:i:s"); if(CIS_GESAMTNOTE_PUNKTE && isset($punkte) && $punkte >= 0) { @@ -572,19 +718,28 @@ class Noten extends FHCAPI_Controller $this->terminateWithError($editError, 'general'); } + // for a NEW attempt (no pruefung_id) enforce the add rules server-side + // maxAntritte calc, chronological order, occurrence limit for entschuldigt + if($pruefung_id === null || $pruefung_id === '') { + $addError = $this->validatePruefungAdd($student_uid, $lva_id, $stsem, $note, $datum); + if($addError !== null) { + $this->terminateWithError($addError, 'general'); + } + } + $this->load->model('education/Lehrveranstaltung_model', 'LehrveranstaltungModel'); $this->load->model('organisation/Studiengang_model', 'StudiengangModel'); $res = $this->LehrveranstaltungModel->load($lva_id); if(isError($res) || !hasData($res)) { - $this->terminateWithError('Keine gültige Lehrveranstaltung gefunden für ID: '.$lva_id); + $this->terminateWithError($this->p->t('benotungstool', 'noValidLvFoundForId', [$lva_id])); } - + $studiengang_kz = getData($res)[0]->studiengang_kz; $res = $this->StudiengangModel->load($studiengang_kz); if(isError($res) || !hasData($res)) { - $this->terminateWithError('Kein gültiger Studiengang gefunden für ID: '.$studiengang_kz); + $this->terminateWithError($this->p->t('benotungstool', 'noValidStudiengangFoundForId', [$studiengang_kz])); } @@ -670,7 +825,7 @@ class Noten extends FHCAPI_Controller /** * private helper method to update/insert pruefungstermine */ - private function savePruefungstermin($typ, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte = '', $datum, $origLvNote = null, $origLvPunkte = null, $origBenotungsdatum = null) + private function savePruefungstermin($typ, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum, $origLvNote = null, $origLvPunkte = null, $origBenotungsdatum = null) { // extra check if the student has lvnote and a zeugnisnote in the relevant lva @@ -697,9 +852,17 @@ class Noten extends FHCAPI_Controller ); if(count($status) > 0 && $status[0] == true) { - $this->load->model('education/Note_model', 'NoteModel'); - $result = $this->NoteModel->getEntschuldigtNote(); - $note = getData($result)[0]->note; + $entschuldigtNote = $this->resolveSpecialNotes()['entschuldigt']; + + // the occurrence limit was validated on the PRE-override note, so re-check it here: only + // apply the auto-excuse if it would not exceed the allowed number of excused Termine + if($entschuldigtNote !== null) { + $allRes = $this->LePruefungModel->getPruefungenByUidTypLvStudiensemester($student_uid, null, $lva_id, $stsem); + $existing = (!isError($allRes) && hasData($allRes)) ? getData($allRes) : []; + if(!$this->exceedsNoteOccuranceLimit($existing, $entschuldigtNote, null)) { + $note = $entschuldigtNote; + } + } } $jetzt = date("Y-m-d H:i:s"); @@ -923,7 +1086,7 @@ class Noten extends FHCAPI_Controller */ private function isEntschuldigtNote($note) { - $entschuldigt = $this->config->item('NOTE_ENTSCHULDIGT'); + $entschuldigt = $this->resolveSpecialNotes()['entschuldigt']; return $entschuldigt !== null && $note == $entschuldigt; } @@ -998,9 +1161,231 @@ class Noten extends FHCAPI_Controller return $this->p->t('benotungstool', 'pruefungDatumOutOfRange', [$student_uid]); } + // an edit may also change the note (e.g. to 'entschuldigt') -> keep the occurrence limit + if($this->exceedsNoteOccuranceLimit($pruefungen, $newNote, $current->pruefung_id)) { + return $this->p->t('benotungstool', 'noteOccuranceLimitReached', [$student_uid]); + } + return null; } + /** + * Validates ADDING a new pruefung (attempt). Returns an error string if the add is not + * allowed, or null if it is.. + * + * Rules (Prüfungsordnung §1): + * - Rule A: the number of counting Prüfungsantritte may not exceed the configured maximum + * - Rule B: attempts are taken in chronological order + * - Rule C: a note carrying an occurrence limit (entschuldigt) may not exceed it. + * + * @param string $student_uid + * @param int $lva_id + * @param string $stsem + * @param int $note the (already resolved) note being saved + * @param string $datum the datum being saved (Y-m-d) + * @return string|null + */ + private function validatePruefungAdd($student_uid, $lva_id, $stsem, $note, $datum) + { + $result = $this->LePruefungModel->getPruefungenByUidTypLvStudiensemester($student_uid, null, $lva_id, $stsem); + $pruefungen = (!isError($result) && hasData($result)) ? getData($result) : []; + + // current LV note (the implicit first Antritt), read before it gets mutated by the caller + $lvNote = null; + $resLv = $this->LvgesamtnoteModel->getLvGesamtNoten($lva_id, $student_uid, $stsem); + if(!isError($resLv) && hasData($resLv)) $lvNote = getData($resLv)[0]->note; + + // Rule A: max Antritte + if($this->computeAntrittCount($pruefungen, $lvNote) >= $this->computeMaxAntritte()) { + return $this->p->t('benotungstool', 'maxAntritteReached', [$student_uid, $this->computeMaxAntritte()]); + } + + // Rule B: no new attempt on/before an existing dated attempt + $newDate = substr((string)$datum, 0, 10); + foreach($pruefungen as $p) { + $d = substr((string)$p->datum, 0, 10); + if($d !== '' && $d >= $newDate) { + return $this->p->t('benotungstool', 'pruefungDatumBeforeExisting', [$student_uid]); + } + } + + // Rule C: occurrence limit (e.g. only one 'entschuldigt' across the fixed Antritte) + if($this->exceedsNoteOccuranceLimit($pruefungen, $note, null)) { + return $this->p->t('benotungstool', 'noteOccuranceLimitReached', [$student_uid]); + } + + return null; + } + + /** + * Number of counting Prüfungsantritte for a student, mirroring the frontend getAntrittCountStudent: + * a kommPruef caps the count at 4, notes in NOTEN_OHNE_ANTRITT (entschuldigt / noch nicht + * eingetragen) do not count, and when no pruefung counts yet the original LV note counts as the + * first Antritt (unless it is a non-lehre note such as 'angerechnet'). + */ + private function computeAntrittCount($pruefungen, $lvNote) + { + $notenOhneAntritt = $this->resolveSpecialNotes()['ohneAntritt']; + + foreach($pruefungen as $p) { + if($p->pruefungstyp_kurzbz == 'kommPruef') return 4; + } + + $count = 0; + foreach($pruefungen as $p) { + if(!in_array($p->note, $notenOhneAntritt)) $count++; + } + + if($count === 0 && $lvNote !== null && $this->isLehreNote($lvNote)) return 1; + + return $count; + } + + /** + * Maximum number of counting Prüfungsantritte enterable IN THIS TOOL, mirroring the frontend + * maxAntrittCount: the original note (always 1) plus each enabled in-tool retake type (Termin2 and + * the legacy Termin3). kommPruef is the terminal attempt, entered elsewhere, and is intentionally + * not part of this cap. For our installation (Termin2 on, Termin3 off) this is 2; excused Termine do + * not count (see computeAntrittCount), which is what lets Termin2 occur twice within the cap. + */ + private function computeMaxAntritte() + { + $max = 1; + if(defined('CIS_GESAMTNOTE_PRUEFUNG_TERMIN2') && CIS_GESAMTNOTE_PRUEFUNG_TERMIN2) $max++; + if(defined('CIS_GESAMTNOTE_PRUEFUNG_TERMIN3') && CIS_GESAMTNOTE_PRUEFUNG_TERMIN3) $max++; + return $max; + } + + /** + * Whether the given note is flagged as a 'lehre' note in tbl_note. + */ + private function isLehreNote($note) + { + $res = $this->NoteModel->load($note); + if(isError($res) || !hasData($res)) return false; + return (bool) getData($res)[0]->lehre; + } + + /** + * Single source of truth for the "special" note PKs (entschuldigt & noch nicht eingetragen). + * tbl_note is resolved by Bezeichnung (install-independent) and takes precedence; the hardcoded + * config PKs (NOTE_ENTSCHULDIGT / NOTEN_OHNE_ANTRITT) are only a fallback. This removes the second + * source of truth so the entschuldigt-preservation and Antritt-counting logic can't disagree with + * the Bezeichnung lookups on installs whose PKs differ. Cached per request. + * + * @return array{entschuldigt: mixed, ohneAntritt: array, limitMap: array} + */ + private function resolveSpecialNotes() + { + static $cache = null; + if($cache !== null) return $cache; + + $cfgEntschuldigt = $this->config->item('NOTE_ENTSCHULDIGT'); + $cfgOhneAntritt = $this->config->item('NOTEN_OHNE_ANTRITT'); + $cfgLimitMap = $this->config->item('NOTEN_OCCURANCE_LIMIT_MAP'); + if(!is_array($cfgOhneAntritt)) $cfgOhneAntritt = []; + if(!is_array($cfgLimitMap)) $cfgLimitMap = []; + + // resolve by Bezeichnung, fall back to the config PKs + $resEnt = $this->NoteModel->getEntschuldigtNote(); + $entschuldigt = (!isError($resEnt) && hasData($resEnt)) ? getData($resEnt)[0]->note : $cfgEntschuldigt; + + $resNn = $this->NoteModel->getNochNichtEingetragenNote(); + $nochNicht = (!isError($resNn) && hasData($resNn)) ? getData($resNn)[0]->note : ($cfgOhneAntritt[0] ?? null); + + $ohneAntritt = array_values(array_filter([$nochNicht, $entschuldigt], function($v){ return $v !== null; })); + + // re-key the configured entschuldigt limit onto the resolved PK (keep any other entries as-is) + $limitMap = []; + foreach($cfgLimitMap as $k => $v) { + $limitMap[($k == $cfgEntschuldigt) ? $entschuldigt : $k] = $v; + } + + $cache = [ + 'entschuldigt' => $entschuldigt, + 'ohneAntritt' => $ohneAntritt, + 'limitMap' => $limitMap + ]; + return $cache; + } + + /** + * Check for configured occurrence limit (NOTEN_OCCURANCE_LIMIT_MAP) among the given pruefungen. + * $excludePruefungId skips the record currently being edited + * Noten without a configured limit never exceed. + */ + private function exceedsNoteOccuranceLimit($pruefungen, $note, $excludePruefungId = null) + { + $limitMap = $this->resolveSpecialNotes()['limitMap']; + if(!is_array($limitMap)) return false; + + $limit = null; + foreach($limitMap as $limitNote => $limitVal) { + if($limitNote == $note) { $limit = $limitVal; break; } + } + if($limit === null) return false; + + $count = 0; + foreach($pruefungen as $p) { + if($excludePruefungId !== null && $p->pruefung_id == $excludePruefungId) continue; + if($p->note == $note) $count++; + } + + return ($count + 1) > $limit; + } + + /** + * Enforces the Noteneintragungsfrist (Prüfungsordnung §1) for grade/pruefung entry. Terminates the + * request with an error when the deadline for the given studiensemester has passed. Does nothing + * when the window enforcement is disabled or the deadline cannot be determined. + */ + private function enforceNoteneintragungsfrist($sem_kurzbz) + { + if(!$this->config->item('CIS_GESAMTNOTE_NOTENEINTRAGUNGSFRIST')) return; + + $deadline = $this->computeNoteneintragungsfrist($sem_kurzbz); + if($deadline === null) return; + + if(new DateTime() > $deadline) { + $this->terminateWithError( + $this->p->t('benotungstool', 'noteneintragungsfristVorbei', [$deadline->format('d.m.Y')]), + 'general' + ); + } + } + + /** + * Computes the Noteneintragungsfrist deadline (end of day) for a studiensemester_kurzbz of the + * form '{SS|WS}yyyy'. SS -> deadline in the same calendar year, WS -> in the following year. The + * month/day come from the noten config. Returns a DateTime, or null when it can't be determined. + */ + private function computeNoteneintragungsfrist($sem_kurzbz) + { + if(!is_string($sem_kurzbz) || strlen($sem_kurzbz) < 6) return null; + + $type = strtoupper(substr($sem_kurzbz, 0, 2)); + $year = (int) substr($sem_kurzbz, 2, 4); + if($year <= 0) return null; + + if($type === 'SS') { + $cfg = $this->config->item('NOTENEINTRAGUNGSFRIST_SS'); + $deadlineYear = $year; + } elseif($type === 'WS') { + $cfg = $this->config->item('NOTENEINTRAGUNGSFRIST_WS'); + $deadlineYear = $year + 1; + } else { + return null; + } + + $month = (is_array($cfg) && isset($cfg['month'])) ? (int)$cfg['month'] : ($type === 'SS' ? 11 : 5); + $day = (is_array($cfg) && isset($cfg['day'])) ? (int)$cfg['day'] : 15; + + $deadline = new DateTime(); + $deadline->setDate($deadlineYear, $month, $day); + $deadline->setTime(23, 59, 59); + return $deadline; + } + /** * POST METHOD * expects 'sem_kurzbz', 'lv_id', 'student_uid', 'note' @@ -1020,7 +1405,12 @@ class Noten extends FHCAPI_Controller $sem_kurzbz = $result->sem_kurzbz; $note = $result->note; $punkte = $result->punkte; - + + $this->assertLvAccess($lv_id, $sem_kurzbz); + + // Prüfungsordnung §1: no entry/change past the Noteneintragungsfrist + $this->enforceNoteneintragungsfrist($sem_kurzbz); + $result = $this->LvgesamtnoteModel->getLvGesamtNoteVorschlag($lv_id, $student_uid, $sem_kurzbz); // $this->addMeta('LvgesamtnoteModelresult', $result); @@ -1092,7 +1482,12 @@ class Noten extends FHCAPI_Controller $lv_id = $result->lv_id; $sem_kurzbz = $result->sem_kurzbz; $noten = $result->noten; - + + $this->assertLvAccess($lv_id, $sem_kurzbz); + + // Prüfungsordnung §1: no entry/change past the Noteneintragungsfrist + $this->enforceNoteneintragungsfrist($sem_kurzbz); + $retLvNoten = []; foreach($noten as $note) @@ -1177,21 +1572,34 @@ class Noten extends FHCAPI_Controller $uids = $result->uids; $datum = $result->datum; $lva_id = $result->lva_id; - + $stsem = $result->sem_kurzbz; - + + $this->assertLvAccess($lva_id, $stsem); + + // Prüfungsordnung §1: no entry past the Noteneintragungsfrist + $this->enforceNoteneintragungsfrist($stsem); + $ret = []; $this->load->model('education/Note_model', 'NoteModel'); $result = $this->NoteModel->getNochNichtEingetragenNote(); $note = getData($result)[0]->note; - + foreach ($uids as $student) { $student_uid = $student->uid; $typ = $student->typ; $punkte = null; // new pruefungen never have punkte, $lehreinheit_id = $student->lehreinheit_id; + + // server-side add guards (max Antritte, chronological order, occurrence limit) + $addError = $this->validatePruefungAdd($student_uid, $lva_id, $stsem, $note, $datum); + if($addError !== null) { + $ret[$student->uid] = $addError; // string result is surfaced per-student in the UI + continue; + } + $ret[$student->uid] = $this->savePruefungstermin($typ, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum); } @@ -1216,7 +1624,12 @@ class Noten extends FHCAPI_Controller $lv_id = $result->lv_id; $sem_kurzbz = $result->sem_kurzbz; $pruefungen = $result->pruefungen; - + + $this->assertLvAccess($lv_id, $sem_kurzbz); + + // Prüfungsordnung §1: no entry past the Noteneintragungsfrist + $this->enforceNoteneintragungsfrist($sem_kurzbz); + $ret = []; foreach ($pruefungen as $pruefung) { @@ -1235,6 +1648,14 @@ class Noten extends FHCAPI_Controller $punkte = $pruefung->punkte; $lehreinheit_id = $pruefung->lehreinheit_id; + + // server-side add guards (max Antritte, chronological order, occurrence limit) + $addError = $this->validatePruefungAdd($student_uid, $lv_id, $sem_kurzbz, $note, $datum); + if($addError !== null) { + $ret[$student_uid] = $addError; // string result is surfaced per-student in the UI + continue; + } + $ret[$student_uid] = $this->savePruefungstermin($typ, $student_uid, $lv_id, $sem_kurzbz, $lehreinheit_id, $note, $punkte, $datum); } diff --git a/application/models/education/Lehrveranstaltung_model.php b/application/models/education/Lehrveranstaltung_model.php index c616e260c..1925730cf 100644 --- a/application/models/education/Lehrveranstaltung_model.php +++ b/application/models/education/Lehrveranstaltung_model.php @@ -1369,4 +1369,50 @@ class Lehrveranstaltung_model extends DB_Model return $this->execReadOnlyQuery($qry, array($sem_kurzbz, $uid)); } + + /** + * All Lehrveranstaltungen of a Studiengang offered in a Studiensemester. + */ + public function getLvForStudiengangInSemester($sem_kurzbz, $studiengang_kz) { + $qry = "SELECT DISTINCT (tbl_lehrveranstaltung.lehrveranstaltung_id), + UPPER(tbl_studiengang.typ::varchar(1) || tbl_studiengang.kurzbz) as stg_kurzbz, + tbl_studiengang.studiengang_kz, + tbl_lehrveranstaltung.semester as lv_semester, + tbl_lehrveranstaltung.bezeichnung as lv_bezeichnung, + tbl_lehrveranstaltung.orgform_kurzbz as orgform + FROM + lehre.tbl_lehrveranstaltung JOIN public.tbl_studiengang USING(studiengang_kz) + WHERE + tbl_lehrveranstaltung.studiengang_kz = ? + AND tbl_lehrveranstaltung.aktiv = TRUE + AND EXISTS ( + SELECT 1 FROM lehre.tbl_lehreinheit le + WHERE le.lehrveranstaltung_id = tbl_lehrveranstaltung.lehrveranstaltung_id + AND le.studiensemester_kurzbz = ? + ) + ORDER BY stg_kurzbz,lv_semester,lv_bezeichnung"; + + return $this->execReadOnlyQuery($qry, array($studiengang_kz, $sem_kurzbz)); + } + + /** + * Number of Lehreinheit assignments the given mitarbeiter has for a Lehrveranstaltung (i.e. whether + * they teach it), optionally restricted to a Studiensemester. Returns a single row with a `teaches` + * count - check `teaches > 0`. Used to scope teacher access in the Benotungstool to their own LVs. + */ + public function getLektorIsTeachingLva($lva_id, $ma_uid, $studiensemester_kurzbz = null) + { + $params = array($lva_id, $ma_uid); + $query = "SELECT COUNT(*) AS teaches + FROM lehre.tbl_lehreinheitmitarbeiter + JOIN lehre.tbl_lehreinheit USING (lehreinheit_id) + JOIN lehre.tbl_lehrveranstaltung USING (lehrveranstaltung_id) + WHERE lehrveranstaltung_id = ? AND mitarbeiter_uid = ?"; + if ($studiensemester_kurzbz !== null && $studiensemester_kurzbz !== '') { + $query .= " AND tbl_lehreinheit.studiensemester_kurzbz = ?"; + $params[] = $studiensemester_kurzbz; + } + + return $this->execReadOnlyQuery($query, $params); + } } diff --git a/public/js/api/factory/noten.js b/public/js/api/factory/noten.js index d0b8940ff..964cc5e73 100644 --- a/public/js/api/factory/noten.js +++ b/public/js/api/factory/noten.js @@ -83,5 +83,19 @@ export default { url: '/api/frontend/v1/Noten/getNoteByPunkte', params: { punkte, lv_id, sem_kurzbz } }; + }, + getBenotungstoolContext(sem_kurzbz, lv_id = null) { + return { + method: 'get', + url: '/api/frontend/v1/Noten/getBenotungstoolContext', + params: { sem_kurzbz, lv_id } + }; + }, + getLvForStudiengang(studiengang_kz, sem_kurzbz) { + return { + method: 'get', + url: '/api/frontend/v1/Noten/getLvForStudiengang', + params: { studiengang_kz, sem_kurzbz } + }; } } \ No newline at end of file diff --git a/public/js/components/Cis/Benotungstool/Benotungstool.js b/public/js/components/Cis/Benotungstool/Benotungstool.js index cb2af33e7..b0b5969b8 100644 --- a/public/js/components/Cis/Benotungstool/Benotungstool.js +++ b/public/js/components/Cis/Benotungstool/Benotungstool.js @@ -1,7 +1,5 @@ import {CoreFilterCmpt} from "../../filter/Filter.js"; -import ApiLehre from "../../../api/factory/lehre.js"; import ApiNoten from "../../../api/factory/noten.js"; -import ApiAuthinfo from "../../../api/factory/authinfo.js"; import ApiStudiensemester from "../../../api/factory/studiensemester.js"; import BsModal from '../../Bootstrap/Modal.js'; import BsOffcanvas from '../../Bootstrap/Offcanvas.js'; @@ -42,7 +40,6 @@ export const Benotungstool = { }, data() { return { - uid: null, headerFiltersRestored: false, filtersRestored: false, filteredRows: null, @@ -85,6 +82,9 @@ export const Benotungstool = { pruefungen: null, studiensemester: null, selectedSemester: null, + isAssistenz: false, + assistenzStudiengaenge: null, + selectedStudiengang: null, lehrveranstaltungen: null, selectedLehrveranstaltung: null, tableBuiltResolve: null, @@ -206,10 +206,6 @@ export const Benotungstool = { ]}; }, methods: { - async fetchAuthUID() { - const authIdResponse = await this.$api.call(ApiAuthinfo.getAuthUID()); - this.uid = authIdResponse.data.uid; - }, loadState() { return JSON.parse(localStorage.getItem(this.persistenceID) || "null"); }, @@ -621,6 +617,18 @@ export const Benotungstool = { this.$api.call(ApiNoten.saveStudentPruefungBulk(this.lv_id, this.sem_kurzbz, pruefungenbulk)) .then((res)=> { if(res.meta.status === 'success') { + // separate per-row backend rejections (localized string messages) from actual saves + let errorList = '' + Object.keys(res.data ?? {}).forEach(uid => { + const entry = res.data[uid] + if(!entry?.savedPruefung && !entry?.extraPruefung) { + errorList += entry + '\n' + } + }) + if(errorList !== '') { + this.$fhcAlert.alertError(errorList) + } + this.$fhcAlert.alertDefault( 'success', 'Info', @@ -637,12 +645,17 @@ export const Benotungstool = { const pruefungen = res.data uids.forEach(entry => { - const saved = pruefungen[entry.uid].savedPruefung?.[0] - const extra = pruefungen[entry.uid].extraPruefung?.[0] + const rowResult = pruefungen[entry.uid] + const saved = rowResult?.savedPruefung?.[0] + const extra = rowResult?.extraPruefung?.[0] const student = this.studenten.find(s => s.uid == entry.uid) if(!student) return + // a per-row backend rejection returns a localized error string (or produced nothing) -> + // skip it here; callers surface those messages to the user separately + if(!saved) return + // check for extra pruefung (termin1) to add before if(extra) { student["Termin1"] = extra @@ -1381,9 +1394,21 @@ export const Benotungstool = { // no actions on kommPruef allowed // no actions on termin1 aka pruefung 0 aka ursprüngliche note erlaubt - if(field === 'kommPruef' || field === "Termin1") { + if(field === 'kommPruef' || field === "Termin1") { + // Prüfungsordnung §2: for the original Zeugnisnote (Termin1 = first Antritt) optionally + // show its Benotungsdatum inline next to the grade + if(field === "Termin1" + && this.config?.SHOW_BENOTUNGSDATUM_ON_NOTENVORSCHLAG_UEBERNAHME + && studentPruefung?.datum) { + const dateDiv = createCol(this.formatDatumDMY(studentPruefung.datum), 'col-auto d-flex justify-content-center align-items-center') + dateDiv.classList.add('text-muted') + dateDiv.style.fontSize = '0.75rem' + dateDiv.style.whiteSpace = 'nowrap' + dateDiv.style.marginLeft = '6px' + rowDiv.appendChild(dateDiv) + } return rowDiv - } + } if(data[field]?.pruefungstyp_kurzbz !== 'Termin1') { // once a later-dated or higher-attempt pruefung exists (e.g. a Termin3/kommPruef has @@ -1431,6 +1456,12 @@ export const Benotungstool = { if(parts.length !== 3) return null return new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2])) }, + formatDatumDMY(datum) { + // 'YYYY-MM-DD[ ...]' -> 'DD.MM.YYYY' + const parts = (datum ?? '').slice(0, 10).split('-') + if(parts.length !== 3) return '' + return `${parts[2]}.${parts[1]}.${parts[0]}` + }, addDays(date, days) { const d = new Date(date) d.setDate(d.getDate() + days) @@ -1775,22 +1806,73 @@ export const Benotungstool = { this.notenTableOptions.height = window.visualViewport.height - rect.top - 50 this.$refs.notenTable.tabulator.setHeight(this.notenTableOptions.height) }, - loadLehrveranstaltungen(sem_kurzbz, preselectLvId = null) { - return this.$api.call(ApiLehre.getZugewieseneLv(this.uid, sem_kurzbz)).then(res => { - this.lehrveranstaltungen = res.data - this.lehrveranstaltungen.forEach(lva => { - lva.fullString = `${lva.stg_kurzbz} - ${lva.lv_semester} - ${lva.orgform}: ${lva.lv_bezeichnung}` - }) - this.selectedLehrveranstaltung = preselectLvId - ? this.lehrveranstaltungen.find(lva => lva.lehrveranstaltung_id == preselectLvId) ?? null - : null + setLehrveranstaltungen(lvaData, preselectLvId = null) { + this.lehrveranstaltungen = lvaData + this.lehrveranstaltungen.forEach(lva => { + lva.fullString = `${lva.stg_kurzbz} - ${lva.lv_semester} - ${lva.orgform}: ${lva.lv_bezeichnung}` }) + this.selectedLehrveranstaltung = preselectLvId + ? this.lehrveranstaltungen.find(lva => lva.lehrveranstaltung_id == preselectLvId) ?? null + : null + }, + // Decides how the LV dropdown is fed for the current user: Assistenzen pick a Studiengang first + // (LVs scoped by entitlement), teachers get their assigned LVs directly. Also loads the + // entitled Studiengänge for the Studiengang dropdown when in Assistenz mode. + setupLvSource(sem_kurzbz, preselectLvId = null) { + return this.$api.call(ApiNoten.getBenotungstoolContext(sem_kurzbz, preselectLvId)).then(res => { + this.isAssistenz = !!res.data?.isAssistenz + + if (this.isAssistenz) { + this.setAssistenzStudiengaenge(res.data?.studiengaenge ?? []) + + // deep-link: preselect the requested LV's Studiengang, load its LVs and preselect the LV. + // Without a (resolvable) deep-link, wait for a manual Studiengang selection. + const preStgKz = res.data?.preselectStudiengang_kz + this.selectedStudiengang = preStgKz + ? this.assistenzStudiengaenge.find(s => s.studiengang_kz == preStgKz) ?? null + : null + + if (this.selectedStudiengang) { + return this.loadLehrveranstaltungenForStudiengang(this.selectedStudiengang.studiengang_kz, sem_kurzbz, preselectLvId) + } + + this.lehrveranstaltungen = null + this.selectedLehrveranstaltung = null + } else { + // teacher: assigned LVs delivered by the same context call (deep-linked lv_id preselected) + this.setLehrveranstaltungen(res.data?.lehrveranstaltungen ?? [], preselectLvId) + } + }) + }, + setAssistenzStudiengaenge(studiengaenge) { + this.assistenzStudiengaenge = studiengaenge + this.assistenzStudiengaenge.forEach(stg => { + stg.fullString = `${stg.kuerzel} - ${stg.bezeichnung}` + }) + }, + loadLehrveranstaltungenForStudiengang(studiengang_kz, sem_kurzbz, preselectLvId = null) { + return this.$api.call(ApiNoten.getLvForStudiengang(studiengang_kz, sem_kurzbz)).then(res => { + this.setLehrveranstaltungen(res.data, preselectLvId) + }) + }, + stgChanged(e) { + const sem = this.selectedSemester?.studiensemester_kurzbz ?? this.sem_kurzbz + const stg = e.value?.studiengang_kz ?? null + + this.selectedLehrveranstaltung = null + if (stg && sem) { + this.loading = true + this.loadLehrveranstaltungenForStudiengang(stg, sem).finally(() => this.loading = false) + } else { + this.lehrveranstaltungen = null + } + }, + getOptionLabelStg(option) { + return option.fullString }, async setupCreated() { this.loading = true - await this.fetchAuthUID() - this.debouncedFetchPunkteForPruefung = debounce(this.fetchNoteForPunktePruefung, 500) // fetch cis config regarding gesamtnoteneingabe, needs to be fetched before setup can finish @@ -1818,7 +1900,7 @@ export const Benotungstool = { this.selectedSemester = defaultSem ?? this.studiensemester[0] ?? null const sem = this.selectedSemester?.studiensemester_kurzbz - if (sem) this.loadLehrveranstaltungen(sem, this.lv_id) + if (sem) this.setupLvSource(sem, this.lv_id) }) LehreinheitenModule.setupContext(this.$.appContext.config.globalProperties) @@ -1864,23 +1946,45 @@ export const Benotungstool = { ssChanged(e) { const sem = e.value.studiensemester_kurzbz const keepLvId = this.selectedLehrveranstaltung?.lehrveranstaltung_id ?? this.lv_id + const keepStg = this.selectedStudiengang?.studiengang_kz this.loading = true - this.loadLehrveranstaltungen(sem, keepLvId).then(() => { - const lvId = this.selectedLehrveranstaltung?.lehrveranstaltung_id ?? null + this.$api.call(ApiNoten.getBenotungstoolContext(sem)).then(res => { + this.isAssistenz = !!res.data?.isAssistenz - this.$router.push({ - name: "Benotungstool", - params: { sem_kurzbz: sem, lv_id: lvId ?? undefined } - }) + if (this.isAssistenz) { + // keep the selected Studiengang if it still exists this semester, then reload its LVs + this.setAssistenzStudiengaenge(res.data?.studiengaenge ?? []) + this.selectedStudiengang = keepStg + ? this.assistenzStudiengaenge.find(s => s.studiengang_kz == keepStg) ?? null + : null - if (lvId) { - this.loadNoten(lvId, sem) - } else if (this.$refs.notenTable?.tabulator) { - this.$refs.notenTable.tabulator.setData([]) + const loadLvs = this.selectedStudiengang + ? this.loadLehrveranstaltungenForStudiengang(this.selectedStudiengang.studiengang_kz, sem, keepLvId) + : Promise.resolve(this.lehrveranstaltungen = null) + + return loadLvs.then(() => this.afterSemesterChange(sem)) } + + // teacher: assigned LVs for the new semester come from the same context call + this.setLehrveranstaltungen(res.data?.lehrveranstaltungen ?? [], keepLvId) + return this.afterSemesterChange(sem) }).finally(() => this.loading = false) }, + afterSemesterChange(sem) { + const lvId = this.selectedLehrveranstaltung?.lehrveranstaltung_id ?? null + + this.$router.push({ + name: "Benotungstool", + params: { sem_kurzbz: sem, lv_id: lvId ?? undefined } + }) + + if (lvId) { + this.loadNoten(lvId, sem) + } else if (this.$refs.notenTable?.tabulator) { + this.$refs.notenTable.tabulator.setData([]) + } + }, getOptionLabel(option) { return option.studiensemester_kurzbz }, @@ -1890,21 +1994,24 @@ export const Benotungstool = { getOptionLabelLe(option) { return option.infoString }, - getPruefungstypForStudentByAntritt(student) { - // when adding new pruefungen, determine the next pruefungstyp by using the antritt counter - switch (student.hoechsterAntritt) { - case 0: - return "Termin2" - break - case 1: - return "Termin2" - break - case 2: - return "Termin3" - break - default: - return "" - } + getPruefungstypForStudentByAntritt(student) { + // Determine the next in-tool pruefungstyp when adding a new attempt. The order of retake + // types is driven by config so each installation keeps its own ruleset: + // - Termin1 (the original Zeugnisnote) is attempt 1 and is not offered here. + // - each enabled retake type is the next offered attempt: Termin2, then legacy Termin3. + // - kommPruef is the terminal attempt and is entered elsewhere, so it is never offered here. + // Our installation enables only Termin2 (Termin3 is legacy for other unis), so every offered + // retake resolves to Termin2 - which is also why Termin2 can occur twice: the excused Termin + // does not increment hoechsterAntritt (see getAntrittCountStudent), so the next offered attempt + // stays Termin2 until a counting Termin2 exists. + const retakeTypes = [] + if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_TERMIN2) retakeTypes.push("Termin2") + if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_TERMIN3) retakeTypes.push("Termin3") + + // hoechsterAntritt is the number of counting attempts already taken (>=1 once an original + // note exists). The next retake sits at index hoechsterAntritt-1 (attempt 1 -> first retake). + const idx = Math.max(0, student.hoechsterAntritt - 1) + return retakeTypes[idx] ?? "" }, savePruefungEingabe() { // keep the date within the neighbouring exam dates (a typed value can bypass the picker bounds) @@ -2279,14 +2386,17 @@ export const Benotungstool = { }).finally(()=> this.loading = false) }, getAntrittCountStudent(student) { - // checks for existence of a prüfung with a note that resolves to a - // "angetretene Prüfung" -> anything except "entschuldigt" & "noch nicht eingetragen" - // and returns the next allowed pruefungstyp from the number of taken pruefungen - - // 1 -> reguläre note - // 2 -> erste Nachprüfung / Termin2 - // 3 -> 2te Nachprüfung / Termin3 - // 4 -> kommPruef + // Number of counting Prüfungsantritte the student has taken. Counting is by NOTE, not by + // pruefungstyp: any note except the "antrittslose" ones (entschuldigt & noch nicht eingetragen, + // see NOTEN_OHNE_ANTRITT) counts as an attempt. That is exactly what makes an excused Termin2 + // not consume an attempt, so Termin2 can be entered a second time. + // + // Antritt numbering in our installation: 1 = original note, 2 = Termin2 (may be entered twice, + // one excused), terminal = kommPruef. (Termin3 is a legacy retake for other installations and + // is simply counted like any other pruefung when enabled.) + // + // A present kommPruef is terminal: return a sentinel (4) that is >= any maxAntrittCount, so no + // further attempts can be added here regardless of installation. if(student['kommPruef']) return 4 let pruefungsAntrittCount = 0 @@ -2410,11 +2520,17 @@ export const Benotungstool = { + ': ' + (this.studenten?.length || 0) + ''; }, maxAntrittCount() { + // Maximum number of counting Prüfungsantritte enterable IN THIS TOOL: the original note + // (always 1) plus each enabled in-tool retake type. kommPruef is the terminal attempt and is + // entered elsewhere, so it is intentionally NOT part of this cap. For our installation + // (Termin2 on, Termin3 off) this is 2 (original + Termin2); Termin3 is legacy for other unis. + // Excused Termine do not count (see getAntrittCountStudent), which is what lets Termin2 occur + // twice within this cap. let maxAntritte = 1; - + if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_TERMIN2) maxAntritte++ if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_TERMIN3) maxAntritte++ - + return maxAntritte; }, getFreigabeCounter() { @@ -2684,53 +2800,64 @@ export const Benotungstool = { -
-
-

{{$capitalize($p.t('benotungstool/benotungstoolTitle'))}}

-
{{ selectedLehrveranstaltung?.lv_bezeichnung }}
+
+
+

{{$capitalize($p.t('benotungstool/benotungstoolTitle'))}}

+
{{ selectedLehrveranstaltung?.lv_bezeichnung }}
- -
- -
-
- - - -
- -
- -
-
- - - -
- -
- -
-
- - - + +
+
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
diff --git a/system/checksystem.php b/system/checksystem.php index 1f7968282..8d95d3ce5 100644 --- a/system/checksystem.php +++ b/system/checksystem.php @@ -151,6 +151,7 @@ $berechtigungen = array( array('lehre/abgabetool','Projektabgabetool, Studentenansicht'), array('lehre/abgabetool:download','Download von Projektarbeitsabgaben'), array('lehre/benotungstool','Cis4 Gesamtnoteneingabe'), + array('lehre/benotungstool_assistenz','Cis4 Gesamtnoteneingabe für Assistenz'), array('lehre/freifach','Freifachverwaltung'), array('lehre/lehrfach','Lehrfachverwaltung'), array('lehre/lehrfach:begrenzt','Lehrfachverwaltung - nur aktiv aenderbar, nur aktive LF werden angezeigt'), diff --git a/system/phrasesupdate.php b/system/phrasesupdate.php index 8c8579342..b9269528d 100644 --- a/system/phrasesupdate.php +++ b/system/phrasesupdate.php @@ -59258,6 +59258,106 @@ I have been informed that I am under no obligation to consent to the transmissio ) ) ), + array( + 'app' => 'core', + 'category' => 'benotungstool', + 'phrase' => 'maxAntritteReached', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Für Studierenden {0} sind bereits die maximal zulässigen Prüfungsantritte ({1}) vergeben. Es wurde keine Prüfung angelegt.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Student {0} has already used the maximum number of exam attempts ({1}). No exam was created.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'benotungstool', + 'phrase' => 'pruefungDatumBeforeExisting', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Das Prüfungsdatum für Studierenden {0} darf nicht vor oder am selben Tag wie ein bereits bestehender Prüfungstermin liegen.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'The exam date for student {0} must not be on or before an existing exam date.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'benotungstool', + 'phrase' => 'noteOccuranceLimitReached', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Die gewählte Note darf für Studierenden {0} nicht so oft vergeben werden. Es wurde keine Prüfung angelegt.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'The selected grade may not be assigned that many times for student {0}. No exam was created.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'benotungstool', + 'phrase' => 'noteneintragungsfristVorbei', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Die Noteneintragungsfrist ({0}) für dieses Studiensemester ist abgelaufen. Es können keine Noten oder Prüfungen mehr eingetragen werden.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'The grade entry deadline ({0}) for this study semester has passed. Grades and exams can no longer be entered.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'benotungstool', + 'phrase' => 'keineBerechtigungNoten', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Keine Berechtigung, um in der Lehrveranstaltung "{0}" im Semester {1} Noten einzutragen.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'No permission to enter grades for the course "{0}" in semester {1}.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), array( 'app' => 'core', 'category' => 'benotungstool',