. */ if (! defined('BASEPATH')) exit('No direct script access allowed'); use CI3_Events as Events; class Noten extends FHCAPI_Controller { /** tbl_note by PK, read once per request. @see aktiveNoten() */ private $aktiveNotenCache = null; /** The teachers of one Lehreinheit. They do not change during one request. */ private $lehrendeCache = array(); /** The Lehreinheiten of one student in one course. A bulk path asks for them several times. */ private $lehreinheitCache = array(); /** Transcript grades of one student in one course. This tool never writes them. */ private $zeugnisnoteCache = array(); public function __construct() { $permissions = self::berechtigungenAusMatrix(); parent::__construct([ 'getStudentenNoten' => $permissions, 'getNoten' => $permissions, 'saveStudentenNoten' => $permissions, 'getNotenvorschlagStudent' => $permissions, 'saveNotenvorschlag' => $permissions, 'saveStudentPruefung' => $permissions, 'createPruefungen' => $permissions, 'saveNotenvorschlagBulk' => $permissions, 'savePruefungenBulk' => $permissions, 'getCisConfig' => $permissions, 'getNoteByPunkte' => $permissions, 'getBenotungstoolContext' => $permissions, 'getLehreinheitenFuerLv' => $permissions, 'getLehrendeFuerLehreinheit' => $permissions, 'getLvForStudiengang' => $permissions ]); $this->load->library('AuthLib', null, 'AuthLib'); $this->load->library('PhrasesLib'); $this->load->library('PruefungsverlaufLib', null, 'VerlaufLib'); // Loads LogLib with different debug trace levels to get data of the job that extends this class // It also specify parameters to set database fields $this->load->library('LogLib', array( 'classIndex' => 5, 'functionIndex' => 5, 'lineIndex' => 4, 'dbLogType' => 'API', // required 'dbExecuteUser' => 'RESTful API', 'requestId' => 'API', 'requestDataFormatter' => function ($data) { return json_encode($data); } ), 'logLib'); // Loads phrases system $this->loadPhrases([ 'global', 'person', 'benotungstool', 'lehre', 'ui', 'password' ]); $this->load->model('education/LePruefung_model', 'LePruefungModel'); $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'); $this->load->model('codex/Mobilitaet_model', 'MobilitaetModel'); $this->load->model('organisation/Erhalter_model', 'ErhalterModel'); $this->load->model('organisation/Studiengang_model', 'StudiengangModel'); $this->load->config('noten'); $this->load->helper('hlp_sancho_helper'); } /** * POST 'uids', 'datum', optional 'note'/'punkte'. One exam for several students; without a * grade it writes "Noch nicht eingetragen". */ public function createPruefungen() { // role first: a caller without the action fails on the right, not on missing parameters $this->assertAktion('pruefung'); $payload = $this->getPostJSON(); if(!property_exists($payload, 'uids') || !property_exists($payload, 'datum') || !property_exists($payload, 'lva_id') || !property_exists($payload, 'sem_kurzbz')) { $this->terminateWithError($this->p->t('global', 'missingParameters'), 'general'); } $uids = $payload->uids; $datum = $payload->datum; $lva_id = $payload->lva_id; $stsem = $payload->sem_kurzbz; // every entry must name a student; the Lehreinheit is optional, the server looks it up if(!is_array($uids) || count($uids) === 0) { $this->terminateWithError($this->p->t('global', 'wrongParameters'), 'general'); } foreach($uids as $student) { if(!is_object($student) || !property_exists($student, 'uid') || isEmptyString($student->uid)) { $this->terminateWithError($this->p->t('global', 'wrongParameters'), 'general'); } } $this->assertLvAccess($lva_id, $stsem); // examination rules: no entry after the grade entry deadline $this->enforceNoteneintragungsfrist($stsem); $ret = []; $note = property_exists($payload, 'note') ? $payload->note : null; $punkte = property_exists($payload, 'punkte') ? $payload->punkte : null; // the points win: the grade comes from the grading scale; points without a grade refuse the entry if(CIS_GESAMTNOTE_PUNKTE && $punkte !== null && $punkte !== '') { $note = $this->noteAusPunkten($punkte, $lva_id, $stsem, implode(', ', array_column($uids, 'uid'))); if(is_string($note)) $this->terminateWithError($note, 'general'); } // without a selection the exam has no grade if($note === null || $note === '') { // config names the grade, the lib resolves it in tbl_note $note = $this->VerlaufLib->getNoteNichtEingetragen(); $punkte = null; } // the dialog sends the teacher when all selected students share one Lehreinheit $mitarbeiter_uid = property_exists($payload, 'mitarbeiter_uid') ? $payload->mitarbeiter_uid : null; // the same core as the dialog; each row gets its own error message foreach ($uids as $student) { $lehreinheit_id = property_exists($student, 'lehreinheit_id') ? $student->lehreinheit_id : null; $ret[$student->uid] = $this->savePruefungFuerStudent( null, $student->uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum, $mitarbeiter_uid ); } $this->logLib->logInfoDB(array('createPruefungen',$ret, getAuthUID(), getAuthPersonId())); $this->terminateWithSuccess($ret); } /** * GET, optional 'sem_kurzbz'. Role-determining entry point: a teacher gets their own courses, * an Assistenz gets the degree programmes they may pick from. */ 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. A teacher sees the own courses, an assistant sees the entitled degree programmes. $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) { $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 // only digits are a course id if (isset($lv_id) && ctype_digit((string) $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 )); } public function getCisConfig() { // 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']; $NOTE_ENTSCHULDIGT = $special['entschuldigt']; $this->terminateWithSuccess( array( // show the points during the grade entry 'CIS_GESAMTNOTE_PUNKTE' => CIS_GESAMTNOTE_PUNKTE, // basically on/of toggle for the points/grade col and the arrow button 'CIS_GESAMTNOTE_UEBERSCHREIBEN' => CIS_GESAMTNOTE_UEBERSCHREIBEN, // only relevant in punkte calculation in backend // 'CIS_GESAMTNOTE_GEWICHTUNG' => CIS_GESAMTNOTE_GEWICHTUNG, // The maximum number of attempts that count in this tool. The server derives it from the // configuration or from the old TERMIN2/TERMIN3 flags. The client does not calculate it. 'CIS_GESAMTNOTE_MAX_ANTRITTE' => $this->VerlaufLib->getMaxAntritte(), // attempt from which the exam is kommissionell, or null; 'letzter' already resolved 'CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT' => $this->VerlaufLib->getKommissionellAbAntritt(), // legacy type the kommissionell role writes; Stv reads that column 'PRUEFUNG_TYP_KOMMISSIONELL' => $this->config->item('PRUEFUNG_TYP_KOMMISSIONELL'), // the default column layout ('antritt' or 'datum'); the user can change it in the tool 'CIS_GESAMTNOTE_PRUEFUNGSSPALTEN' => $this->config->item('CIS_GESAMTNOTE_PRUEFUNGSSPALTEN'), // 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, // basically a toggle for "use teilnoten" and the source is always moodle // setting this to false breaks legacy tool and if that was fixed it wouldnt render any table at all // anyway so not sure why this even is a config at all. placebo at best // toggles availability of the teilnoten column... existas but do we really need this? 'CIS_GESAMTNOTE_PRUEFUNG_MOODLE_LE_NOTE' => CIS_GESAMTNOTE_PRUEFUNG_MOODLE_LE_NOTE, // availability of the two import flows (application/config/noten.php); when both are // 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'), // shape of an imported row and the badge in the exam cell 'CIS_GESAMTNOTE_IMPORT_SPALTEN_NOTEN' => $this->config->item('CIS_GESAMTNOTE_IMPORT_SPALTEN_NOTEN'), 'CIS_GESAMTNOTE_IMPORT_SPALTEN_PRUEFUNG' => $this->config->item('CIS_GESAMTNOTE_IMPORT_SPALTEN_PRUEFUNG'), 'CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT' => $this->config->item('CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT'), 'CIS_GESAMTNOTE_ANTRITT_ZEICHEN' => $this->config->item('CIS_GESAMTNOTE_ANTRITT_ZEICHEN'), // weighting of the partial grades; server applies, client only shows 'CIS_GESAMTNOTE_GEWICHTUNG' => defined('CIS_GESAMTNOTE_GEWICHTUNG') && CIS_GESAMTNOTE_GEWICHTUNG, // send a mail when approving grades 'CIS_GESAMTNOTE_FREIGABEMAIL_NOTE' => CIS_GESAMTNOTE_FREIGABEMAIL_NOTE, // actions this caller may perform; the client only hides buttons, the server decides 'CIS_GESAMTNOTE_AKTIONEN' => $this->erlaubteAktionen(), // release: password required, and final? 'CIS_GESAMTNOTE_FREIGABE_PASSWORT' => $this->config->item('CIS_GESAMTNOTE_FREIGABE_PASSWORT') !== false, 'CIS_GESAMTNOTE_FREIGABE_FINAL' => (bool) $this->config->item('CIS_GESAMTNOTE_FREIGABE_FINAL'), // release mail on or off; the test suite releases only without it 'CIS_GESAMTNOTE_FREIGABEMAIL' => $this->config->item('CIS_GESAMTNOTE_FREIGABEMAIL') !== false, // takeover and release write attempt 1 as an exam row 'CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME' => (bool) $this->config->item('CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME'), 'NOTEN_OHNE_ANTRITT' => $NOTEN_OHNE_ANTRITT, '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, // Noteneintragungsfrist window (enforced server-side; also surfaced so the UI can hint at it) 'CIS_GESAMTNOTE_NOTENEINTRAGUNGSFRIST' => $this->config->item('CIS_GESAMTNOTE_NOTENEINTRAGUNGSFRIST'), // the two deadlines, fallback already resolved 'CIS_GESAMTNOTE_FRIST_EINGABE' => $this->fristAktiv('CIS_GESAMTNOTE_FRIST_EINGABE'), 'CIS_GESAMTNOTE_FRIST_PRUEFUNGSDATUM' => $this->fristAktiv('CIS_GESAMTNOTE_FRIST_PRUEFUNGSDATUM'), // does THIS user carry an entry-deadline exception? 'CIS_GESAMTNOTE_FRIST_AUSNAHME_GILT' => $this->darfFristUeberschreiten(), // exam date guards; server enforces, client only hides buttons 'CIS_GESAMTNOTE_TERMIN_GLEICHER_TAG' => (bool) $this->config->item('CIS_GESAMTNOTE_TERMIN_GLEICHER_TAG'), 'CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETEREM_TERMIN' => $this->config->item('CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETEREM_TERMIN') === null ? true : (bool) $this->config->item('CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETEREM_TERMIN'), 'CIS_GESAMTNOTE_DATUM_ZUKUNFT' => (bool) $this->config->item('CIS_GESAMTNOTE_DATUM_ZUKUNFT'), 'CIS_GESAMTNOTE_ANTRITT_MIN_ABSTAND_TAGE' => $this->config->item('CIS_GESAMTNOTE_ANTRITT_MIN_ABSTAND_TAGE'), 'CIS_GESAMTNOTE_ANTRITT_MAX_ABSTAND_TAGE' => $this->config->item('CIS_GESAMTNOTE_ANTRITT_MAX_ABSTAND_TAGE'), // when a grade closes the chain; resolved keys, never a Bezeichnung 'NOTEN_ABSCHLIESSEND' => $special['abschliessend'], 'CIS_GESAMTNOTE_NOTENVERBESSERUNG' => $this->VerlaufLib->darfVerbessern(), 'CIS_GESAMTNOTE_VERBESSERUNG_BESSERE_GEWINNT' => (bool) $this->config->item('CIS_GESAMTNOTE_VERBESSERUNG_BESSERE_GEWINNT'), // the remaining rule keys, as the server applies them; the test suite reads a rule only from here 'NOTEN_ANRECHNUNG' => $special['anrechnung'], 'NOTEN_RANGFOLGE' => $special['rangfolge'], 'NOTE_NICHT_EINGETRAGEN' => $special['nichtEingetragen'], 'PRUEFUNG_KOMMISSIONELL_TYPEN' => $this->VerlaufLib->getKommissionellTypen(), 'PRUEFUNG_TYPEN_OHNE_ANTRITT' => $this->VerlaufLib->getTypenOhneAntritt(), 'PRUEFUNG_TYP_JE_ANTRITT' => $this->VerlaufLib->getTypJeAntritt(), 'NOTENEINTRAGUNGSFRIST_SS' => $this->fristMonatTag('SS'), 'NOTENEINTRAGUNGSFRIST_WS' => $this->fristMonatTag('WS'), 'CIS_GESAMTNOTE_FRIST_AUSNAHME' => (array) $this->config->item('CIS_GESAMTNOTE_FRIST_AUSNAHME'), 'CIS_GESAMTNOTE_ROLLENMATRIX' => (array) $this->config->item('CIS_GESAMTNOTE_ROLLENMATRIX'), 'CIS_GESAMTNOTE_LEKTOR_NUR_EIGENE_LV' => $this->config->item('CIS_GESAMTNOTE_LEKTOR_NUR_EIGENE_LV') !== false, 'CIS_GESAMTNOTE_FREIGABEMAIL_EMPFAENGER' => $this->freigabeEmpfaengerEintraege(), 'CIS_GESAMTNOTE_FREIGABEMAIL_VORLAGE' => $this->freigabeVorlage(), 'CIS_GESAMTNOTE_PRUEFUNG_HEBT_FREIGABE_AUF' => $this->config->item('CIS_GESAMTNOTE_PRUEFUNG_HEBT_FREIGABE_AUF') !== false, 'CIS_GESAMTNOTE_VORSCHLAG_RUNDUNG' => $this->rundungsModus(), 'CIS_GESAMTNOTE_VORSCHLAG_PUNKTE_STELLEN' => $this->punkteNachkommastellen(), 'CIS_GESAMTNOTE_VORSCHLAG_NUR_LEHRENOTEN' => $this->config->item('CIS_GESAMTNOTE_VORSCHLAG_NUR_LEHRENOTEN') !== false, 'CIS_GESAMTNOTE_VORSCHLAG_NACH_WIEDERHOLUNG' => $this->config->item('CIS_GESAMTNOTE_VORSCHLAG_NACH_WIEDERHOLUNG') === true, 'CIS_GESAMTNOTE_IMPORT_ABBRUCH' => $this->importBrichtAb(), 'NOTEN_SORTIERUNG' => $this->config->item('NOTEN_SORTIERUNG') === 'bezeichnung' ? 'bezeichnung' : 'skala' ) ); } /** GET 'lv_id', 'sem_kurzbz'. All Lehreinheiten of the course, for the filter and the grade lists. */ public function getLehreinheitenFuerLv() { $lv_id = $this->input->get('lv_id'); $sem_kurzbz = $this->input->get('sem_kurzbz'); $this->assertLvAccess($lv_id, $sem_kurzbz); $this->load->model('education/Lehreinheit_model', 'LehreinheitModel'); $result = $this->LehreinheitModel->getLehreinheitenForLv($lv_id, $sem_kurzbz); $this->terminateWithSuccess($this->getDataOrTerminateWithError($result)); } /** * GET 'lehreinheit_id', 'lv_id', 'sem_kurzbz'. Teachers of one Lehreinheit; the dialog offers a * choice when there is more than one. */ public function getLehrendeFuerLehreinheit() { $lehreinheit_id = $this->input->get('lehreinheit_id'); $lv_id = $this->input->get('lv_id'); $sem_kurzbz = $this->input->get('sem_kurzbz'); if(!ctype_digit((string) $lehreinheit_id) || !$lv_id || !$sem_kurzbz) { $this->terminateWithError($this->p->t('global', 'missingParameters'), 'general'); } $this->assertLvAccess($lv_id, $sem_kurzbz); // the Lehreinheit must belong to this course $this->load->model('education/Lehreinheit_model', 'LehreinheitModel'); $le = $this->LehreinheitModel->loadWhere(array( 'lehreinheit_id' => $lehreinheit_id, 'lehrveranstaltung_id' => $lv_id, 'studiensemester_kurzbz' => $sem_kurzbz )); if(!hasData($le)) $this->terminateWithError($this->p->t('global', 'wrongParameters'), 'general'); $lehrende = array(); foreach($this->lehrendeDerLehreinheit($lehreinheit_id) as $l) { $lehrende[] = array( 'mitarbeiter_uid' => $l->mitarbeiter_uid, 'vorname' => $l->vorname, 'nachname' => $l->nachname ); } $this->terminateWithSuccess($lehrende); } /** * GET 'studiengang_kz', 'sem_kurzbz'. Courses of one Studiengang for the Assistenz flow; only * programmes the caller is entitled for. */ 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); } public function getNoteByPunkte() { $result = $this->getPostJSON(); if(!property_exists($result, 'punkte') || !property_exists($result, 'lv_id') || !property_exists($result, 'sem_kurzbz')) { $this->terminateWithError($this->p->t('global', 'missingParameters'), 'general'); } $punkte = $result->punkte; $lv_id = $result->lv_id; $sem_kurzbz = $result->sem_kurzbz; // the grading scale belongs to the course, therefore the same scope as every other endpoint $this->assertLvAccess($lv_id, $sem_kurzbz); $result = $this->NotenschluesselaufteilungModel->getNote($punkte, $lv_id, $sem_kurzbz); $data = $this->getDataOrTerminateWithError($result); $this->terminateWithSuccess($data); } /** * GET METHOD * returns List of all available & active NotenOptions */ public function getNoten() { $this->load->model('education/Note_model', 'NoteModel'); // the controller holds the configuration; the model only builds the order $result = $this->NoteModel->getAllActive($this->config->item('NOTEN_SORTIERUNG')); $noten = $this->getDataOrTerminateWithError($result); $this->terminateWithSuccess($noten); } /** * GET METHOD * should return Notenvorschlag for single Students, not used anywhere but required as per * https://openproject.technikum-wien.at/projects/fh-complete/work_packages/60873/activity */ public function getNotenvorschlagStudent() { $uid = $this->input->get("uid",TRUE); // if uid is missing or empty, fall back to getAuthUID() if ($uid === NULL || trim((string)$uid) === '') { $uid = getAuthUID(); } $sem_kurzbz = $this->input->get("sem_kurzbz",TRUE); $lv_id = $this->input->get("lv_id",TRUE); if ($uid === NULL || trim((string)$uid) === '' || $sem_kurzbz === NULL || trim((string)$sem_kurzbz) === '' || $lv_id === NULL || trim((string)$lv_id) === '') { $this->terminateWithError($this->p->t('global', 'missingParameters'), 'general'); } $this->assertLvAccess($lv_id, $sem_kurzbz); $result = $this->LvgesamtnoteModel->getLvGesamtNoteVorschlag($lv_id, $uid, $sem_kurzbz); $data = $this->getDataOrTerminateWithError($result); // TODO: moodle teilnote but it seems they only work for a whole course? // get anw% of student by prestudent_id // $anwresult = $this->getAnwesenheiten($prestudent_ids, $lv_id, $sem_kurzbz); $this->terminateWithSuccess($data); } /** * GET 'lv_id', 'sem_kurzbz'. Students of the course with their grades, the Teilnoten from * getExternalGrades, the averaged Notenvorschlag and every exam of the semester. */ public function getStudentenNoten() { $lv_id = $this->input->get("lv_id",TRUE); $sem_kurzbz = $this->input->get("sem_kurzbz",TRUE); 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($this->p->t('benotungstool', 'c4keineStudentenGefunden')); } $func = function ($value) { return $value->uid; }; $grades = array(); $student_uids = array_map($func, $studentenData); $funcpre = function ($value) { return $value->prestudent_id; }; $prestudent_ids = array_map($funcpre, $studentenData); if(count($student_uids) > 0) { $mobres = $this->MobilitaetModel->getMobilityZusatzForUids($student_uids); $mobData = $this->getDataOrTerminateWithError($mobres); $result = $this->ErhalterModel->load(); $erhalter = getData($result)[0]; $erhalter_kz = '9' . sprintf("%03s", $erhalter->erhalter_kz); foreach($mobData as $mob) { $grades[$mob->uid]['mobility_zusatz'] = $this->MobilitaetModel->formatZusatz($mob, $erhalter_kz); } } // All course grades of the course in one query, without the release filter: getLvGesamtNoten() // gives released grades only. The history below needs the same rows. $lvNotenRows = array(); $resLvNoten = $this->LvgesamtnoteModel->getByLvStudiensemester($lv_id, $sem_kurzbz); foreach((hasData($resLvNoten) ? getData($resLvNoten) : array()) as $row) $lvNotenRows[$row->student_uid] = $row; foreach($student_uids as $uid) { $grades[$uid]['grades'] = []; $lvgesamtnote = $lvNotenRows[$uid] ?? null; if($lvgesamtnote !== null) { $grades[$uid]['note_lv'] = $lvgesamtnote->note; $grades[$uid]['freigabedatum'] = $lvgesamtnote->freigabedatum; $grades[$uid]['benotungsdatum'] = $lvgesamtnote->benotungsdatum; $grades[$uid]['punkte_lv'] = $lvgesamtnote->punkte; } else { $grades[$uid]['note_lv'] = null; $grades[$uid]['freigabedatum'] = null; $grades[$uid]['benotungsdatum'] = null; $grades[$uid]['punkte_lv'] = null; } } // send $grades reference to moodle addon try { Events::trigger( 'getExternalGrades', function & () use (&$grades) { return $grades; }, [ 'lvid' => $lv_id, 'stsem' => $sem_kurzbz ] ); } catch (Throwable $t) { // $this->addMeta('throwable', $t->getTrace()); $this->addMeta('getExternalGradesError', $t->getMessage()); } // assign the anw% to the students in the studentData loop $anwresult = $this->getAnwesenheiten($prestudent_ids, $lv_id, $sem_kurzbz); // calculate the grade proposals from the partial grades $notenJePunkte = array(); foreach($studentenData as $student) { // null when the Anwesenheiten addon is absent - the column stays empty in the UI $student->anwquote = $anwresult[$student->prestudent_id] ?? null; $g = $grades[$student->uid]['grades']; $note_lv = $grades[$student->uid]['note_lv']; // overwrite any calculation with lv note once available if(!is_null($note_lv)) { $student->note_vorschlag = $note_lv; } else if(count($g) > 0) { $notensumme = 0; $notensumme_gewichtet = 0; $gewichtsumme = 0; $punktesumme = 0; $punktesumme_gewichtet = 0; $anzahlnoten = 0; foreach($g as $teilnote) { $note = $teilnote['grade'] ?? null; $punkte = $teilnote['points'] ?? null; $gewicht = is_numeric($teilnote['weight'] ?? null) ? $teilnote['weight'] : 0; $hatNote = is_numeric($note); $hatPunkte = is_numeric($punkte); // A partial entry counts only for the value that the mode uses. A row without a // grade adds zero in the grade mode and still raises the divisor, which makes the // average better than the performance. if(!(CIS_GESAMTNOTE_PUNKTE ? $hatPunkte : $hatNote)) continue; if($hatNote) { $notensumme += $note; $notensumme_gewichtet += $note * $gewicht; } if($hatPunkte) { $punktesumme += $punkte; $punktesumme_gewichtet += $punkte * $gewicht; } $gewichtsumme += $gewicht; $anzahlnoten += 1; } // Without a partial grade that counts there is no average. Without this guard the // division uses zero. PHP 7 gives INF, and INF gives the best grade. PHP 8 stops with // a fatal error and the full grade table stays empty. $gewichtet = defined('CIS_GESAMTNOTE_GEWICHTUNG') && CIS_GESAMTNOTE_GEWICHTUNG; $divisor = $gewichtet ? $gewichtsumme : $anzahlnoten; if ($divisor > 0) { if (CIS_GESAMTNOTE_PUNKTE) { $punkte_vorschlag = round(($gewichtet ? $punktesumme_gewichtet : $punktesumme) / $divisor, $this->punkteNachkommastellen()); // equal points give an equal grade, so ask the grading scale once for each value if(!array_key_exists((string) $punkte_vorschlag, $notenJePunkte)) { $notenJePunkte[(string) $punkte_vorschlag] = $this->getDataOrTerminateWithError( $this->NotenschluesselaufteilungModel->getNote($punkte_vorschlag, $lv_id, $sem_kurzbz) ); } $note_vorschlag = $notenJePunkte[(string) $punkte_vorschlag]; } else { $note_vorschlag = $this->rundeNote(($gewichtet ? $notensumme_gewichtet : $notensumme) / $divisor); } $student->note_vorschlag = $note_vorschlag; } } } // get all exams with grades of that semester and that course $pruefungen = $this->LePruefungModel->getPruefungenByLvStudiensemester($lv_id, $sem_kurzbz); $pruefungenData = getData($pruefungen); // the server derives the history for each student; the client only reads it $proStudent = []; foreach($pruefungenData ?: [] as $p) { $proStudent[$p->student_uid][] = $p; } // the transcript grades come with the student list (tbl_zeugnisnote.note) $zeugnisnoten = []; foreach($studentenData as $s) $zeugnisnoten[$s->uid] = $s->note; $pruefungenAbgeleitet = []; foreach(array_unique(array_merge($student_uids, array_keys($proStudent))) as $uid) { $lvNote = isset($grades[$uid]) ? ($grades[$uid]['note_lv'] ?? null) : null; $verlauf = $this->VerlaufLib->buildVerlauf($proStudent[$uid] ?? [], $lvNote, $zeugnisnoten[$uid] ?? null); foreach($verlauf->pruefungen as $p) $pruefungenAbgeleitet[] = $p; if(isset($grades[$uid])) { // the row comes from the loop above; a second read gives the same answer $grades[$uid]['verlauf'] = $this->verlaufSummary( $verlauf, false, isset($lvNotenRows[$uid]) && $lvNotenRows[$uid] !== null ); } } $this->terminateWithSuccess(array($studentenData, $pruefungenAbgeleitet, DOMAIN, $grades, $anwresult)); } /** * POST 'sem_kurzbz', 'lv_id', 'student_uid', 'note'. Writes the LV-Note and the benotungsdatum, * which drives the offen/changed/freigegeben state. */ public function saveNotenvorschlag() { // role first: a caller without the action fails on the right, not on missing parameters $this->assertAktion('vorschlag'); $result = $this->getPostJSON(); if(!property_exists($result, 'lv_id') || !property_exists($result, 'sem_kurzbz') || !property_exists($result, 'student_uid') || !property_exists($result, 'note')) { $this->terminateWithError($this->p->t('global', 'missingParameters'), 'general'); } $lv_id = $result->lv_id; $student_uid = $result->student_uid; $sem_kurzbz = $result->sem_kurzbz; $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 $this->enforceNoteneintragungsfrist($sem_kurzbz); // only a participant of the course gets a grade if(!$this->lehreinheitenFuerStudent($lv_id, $student_uid, $sem_kurzbz)) { $this->terminateWithError($this->p->t('benotungstool', 'c4pruefungNichtGespeichert', [$student_uid]), 'general'); } // In the points mode the grading scale decides, not the grade from the client. If not, you // get a course grade that contradicts its own points. Without points there is nothing to // derive (for example a proposal from Moodle partial grades), then the given grade applies. if(CIS_GESAMTNOTE_PUNKTE && $punkte !== null && $punkte !== '') { $abgeleitet = $this->noteAusPunkten($punkte, $lv_id, $sem_kurzbz, $student_uid); if(is_string($abgeleitet)) $this->terminateWithError($abgeleitet, 'general'); $note = $abgeleitet; } $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; // the course grade and attempt 1 are one change $this->sperreStudent($student_uid, $lv_id, $sem_kurzbz); $result = $this->LvgesamtnoteModel->getLvGesamtNoteVorschlag($lv_id, $student_uid, $sem_kurzbz); // $this->addMeta('LvgesamtnoteModelresult', $result); if(!isError($result) && hasData($result)) { $lvgesamtnote = getData($result)[0]; $id = $this->LvgesamtnoteModel->update( [$lvgesamtnote->student_uid, $lvgesamtnote->studiensemester_kurzbz, $lvgesamtnote->lehrveranstaltung_id], array( 'note' => $note, 'punkte' => $punkte, 'benotungsdatum' => date("Y-m-d H:i:s"), 'updateamum' => date("Y-m-d H:i:s"), 'updatevon' => getAuthUID() ) ); $res = null; if($id) { $res = $this->LvgesamtnoteModel->load($id->retval); if(hasData($res)) $lvgesamtnote = getData($res)[0]; } $this->logLib->logInfoDB(array('saveNotenvorschlag update lv gesamtnote',$res, getAuthUID(), getAuthPersonId())); } else if(!isError($result) && !hasData($result)) { $id = $this->LvgesamtnoteModel->insert( array( 'student_uid' => $student_uid, 'lehrveranstaltung_id' => $lv_id, 'studiensemester_kurzbz' => $sem_kurzbz, 'note' => $note, 'punkte' => $punkte, 'mitarbeiter_uid' => $this->benotenderMitarbeiterFuerStudent($lv_id, $student_uid, $sem_kurzbz), 'benotungsdatum' => date("Y-m-d H:i:s"), 'freigabedatum' => null, 'freigabevon_uid' => null, 'bemerkung' => null, 'updateamum' => null, 'updatevon' => null, 'insertamum' => date("Y-m-d H:i:s"), 'insertvon' => getAuthUID() ) ); $res = null; if($id) { $res = $this->LvgesamtnoteModel->load($id->retval); if(hasData($res)) $lvgesamtnote = getData($res)[0]; } $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->db->trans_rollback(); $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); if(!$this->entsperreStudent()) { $this->terminateWithError($this->p->t('benotungstool', 'c4pruefungNichtGespeichert', [$student_uid]), 'general'); } // the client shows the new attempt at once, without a reload. The row is the one just written. $lvgesamtnote->verlauf = $this->buildVerlaufSummary($student_uid, $lv_id, $sem_kurzbz, $lvgesamtnote); $this->terminateWithSuccess(array($lvgesamtnote)); } /** * POST 'sem_kurzbz', 'lv_id', 'noten'. Bulk saveNotenvorschlag for the CSV import; the answer * is keyed by uid and holds the course grade or an error per row. */ public function saveNotenvorschlagBulk() { // role first: a caller without the action fails on the right, not on missing parameters $this->assertAktion('import'); $result = $this->getPostJSON(); if(!property_exists($result, 'lv_id') || !property_exists($result, 'sem_kurzbz') || !property_exists($result, 'noten')) { $this->terminateWithError($this->p->t('global', 'missingParameters'), 'general'); } $lv_id = $result->lv_id; $sem_kurzbz = $result->sem_kurzbz; $noten = $result->noten; $this->assertLvAccess($lv_id, $sem_kurzbz); // examination rules: no entry and no change after the grade entry deadline $this->enforceNoteneintragungsfrist($sem_kurzbz); $retLvNoten = []; 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; // only a participant of the course gets a grade if(!$this->lehreinheitenFuerStudent($lv_id, $note->uid, $sem_kurzbz)) { $retLvNoten[$note->uid] = $this->p->t('benotungstool', 'c4pruefungNichtGespeichert', [$note->uid]); if($this->importBrichtAb()) break; continue; } if(CIS_GESAMTNOTE_PUNKTE) { $abgeleitet = $this->noteAusPunkten($note->punkte, $lv_id, $sem_kurzbz, $note->uid); // no grade can be derived: skip the row, but do not stop the full request if(is_string($abgeleitet)) { $retLvNoten[$note->uid] = $abgeleitet; if($this->importBrichtAb()) break; continue; } $note->note = $abgeleitet; } // one bad row must not stop the import, so the message goes into this row $fehler = $this->validateNotenvorschlag($lv_id, $note->uid, $sem_kurzbz, $note->note); if($fehler !== null) { $retLvNoten[$note->uid] = $fehler; // stop here, or keep writing the remaining rows if($this->importBrichtAb()) break; continue; } // the course grade and attempt 1 are one change $this->sperreStudent($note->uid, $lv_id, $sem_kurzbz); $result = $this->LvgesamtnoteModel->getLvGesamtNoteVorschlag($lv_id, $note->uid, $sem_kurzbz); if(!isError($result) && hasData($result)) { $lvgesamtnote = getData($result)[0]; $id = $this->LvgesamtnoteModel->update( [$lvgesamtnote->student_uid, $lvgesamtnote->studiensemester_kurzbz, $lvgesamtnote->lehrveranstaltung_id], array( 'note' => trim($note->note), 'punkte' => $note->punkte, 'benotungsdatum' => date("Y-m-d H:i:s"), 'updateamum' => date("Y-m-d H:i:s"), 'updatevon' => getAuthUID() ) ); $res = null; if($id) { $res = $this->LvgesamtnoteModel->load($id->retval); if(hasData($res)) $lvgesamtnote = getData($res)[0]; } $this->logLib->logInfoDB(array('saveNotenvorschlagBulk update lv gesamtnote',$res, getAuthUID(), getAuthPersonId())); } else if(!isError($result) && !hasData($result)) { $id = $this->LvgesamtnoteModel->insert( array( 'student_uid' => $note->uid, 'lehrveranstaltung_id' => $lv_id, 'studiensemester_kurzbz' => $sem_kurzbz, 'note' => trim($note->note), 'punkte' => $note->punkte, 'mitarbeiter_uid' => $this->benotenderMitarbeiterFuerStudent($lv_id, $note->uid, $sem_kurzbz), 'benotungsdatum' => date("Y-m-d H:i:s"), 'freigabedatum' => null, 'freigabevon_uid' => null, 'bemerkung' => null, 'updateamum' => null, 'updatevon' => null, 'insertamum' => date("Y-m-d H:i:s"), 'insertvon' => getAuthUID() ) ); $res = null; if($id) { $res = $this->LvgesamtnoteModel->load($id->retval); if(hasData($res)) $lvgesamtnote = getData($res)[0]; } $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) { $this->db->trans_rollback(); $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")); if(!$this->entsperreStudent()) { $retLvNoten[$note->uid] = $this->p->t('benotungstool', 'c4pruefungNichtGespeichert', [$note->uid]); continue; } $lvgesamtnote->verlauf = $this->buildVerlaufSummary($note->uid, $lv_id, $sem_kurzbz, $lvgesamtnote); $retLvNoten[$note->uid] = $lvgesamtnote; } $this->terminateWithSuccess($retLvNoten); } /** * POST METHOD * expects 'lv_id', 'sem_kurzbz', 'pruefungen' * Bulk variant of saveStudentPruefung, used when importing pruefungsdata from csv with available noten. */ public function savePruefungenBulk() { // role first: a caller without the action fails on the right, not on missing parameters $this->assertAktion('import'); $result = $this->getPostJSON(); if(!property_exists($result, 'lv_id') || !property_exists($result, 'sem_kurzbz') || !property_exists($result, 'pruefungen')) { $this->terminateWithError($this->p->t('global', 'missingParameters'), 'general'); } $lv_id = $result->lv_id; $sem_kurzbz = $result->sem_kurzbz; $pruefungen = $result->pruefungen; $this->assertLvAccess($lv_id, $sem_kurzbz); // examination rules: no entry after the grade entry deadline $this->enforceNoteneintragungsfrist($sem_kurzbz); $ret = []; foreach ($pruefungen as $pruefung) { if(CIS_GESAMTNOTE_PUNKTE) { $note = $this->noteAusPunkten($pruefung->punkte, $lv_id, $sem_kurzbz, $pruefung->uid); // no grade can be derived: skip the row, but do not stop the full request if(is_string($note)) { $ret[$pruefung->uid] = $note; if($this->importBrichtAb()) break; continue; } $pruefung->note = $note; } // the same as the dialog in the table, but for each import row $ret[$pruefung->uid] = $this->savePruefungFuerStudent( null, $pruefung->uid, $lv_id, $sem_kurzbz, $pruefung->lehreinheit_id, $pruefung->note, $pruefung->punkte, $pruefung->datum, property_exists($pruefung, 'mitarbeiter_uid') ? $pruefung->mitarbeiter_uid : null ); } $this->logLib->logInfoDB(array('savePruefungenBulk',$ret, getAuthUID(), getAuthPersonId())); $this->terminateWithSuccess($ret); } /** * POST 'lv_id', 'sem_kurzbz', 'password', 'noten'. Releases the grades: sets freigabedatum, * which drives the offen/changed/freigegeben state, and mails a confirmation table. */ public function saveStudentenNoten() { // role first: a caller without the action fails on the right, not on missing parameters $this->assertAktion('freigabe'); $result = $this->getPostJSON(); if(!property_exists($result, 'sem_kurzbz') || !property_exists($result, 'lv_id') || !property_exists($result, 'password') || !property_exists($result, 'noten')) { $this->terminateWithError($this->p->t('global', 'missingParameters'), 'general'); } // second factor for a binding grade; an installation may drop it if($this->config->item('CIS_GESAMTNOTE_FREIGABE_PASSWORT') !== false && !$this->AuthLib->checkUserAuthByUsernamePassword(getAuthUID(), $result->password)->retval) { $this->terminateWithError($this->p->t('password', 'wrongPassword'), 'general'); } $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])); } $lv = getData($res)[0]; $studiengang_kz = $lv->studiengang_kz; $res = $this->StudiengangModel->load($studiengang_kz); if(isError($res) || !hasData($res)) { $this->terminateWithError($this->p->t('benotungstool', 'noValidStudiengangFoundForId', [$studiengang_kz])); } $sg = getData($res)[0]; $lvaFullName = $sg->kurzbzlang . ' ' . $lv->semester . '.Semester ' . $lv->bezeichnung . " - " .$lv->lehrform_kurzbz. " " . $lv->orgform_kurzbz . " - " . $sem_kurzbz; $emails = explode(', ', $sg->email); $res = $this->PersonModel->load(getAuthPersonId()); if(isError($res) || !hasData($res)) { $this->terminateWithError($this->p->t('benotungstool', 'noValidPersonFoundForId', [getAuthPersonId()])); } $pers = getData($res)[0]; $lektorFullName = $pers->anrede.' '.$pers->vorname.' '.$pers->nachname; //.' ('.$pers->kurzbz.')'; $res = $this->StudienplanModel->getStudienplanByLvaSemKurzbz($lv_id, $sem_kurzbz); $data = getData($res); $studienplan_bezeichnung = ''; foreach ($data as $row) { $studienplan_bezeichnung .= $row->bezeichnung . ' '; } $betreff = $this->p->t('benotungstool','notenfreigabe').' ' . $lv->bezeichnung . ' ' . $lv->orgform_kurzbz . ' - ' . $studienplan_bezeichnung; // The mail names the person and the released grade. Both come from the database: a value from // the request could carry markup, and it could name a grade that was never released. $studenten = array(); $resStud = $this->LehrveranstaltungModel->getStudentsByLv($sem_kurzbz, $lv_id); if(!isError($resStud) && hasData($resStud)) { foreach(getData($resStud) as $s) $studenten[$s->uid] = $s; } $notenBezeichnungen = $this->aktiveNoten(); $studlist = ""; if (defined('CIS_GESAMTNOTE_FREIGABEMAIL_NOTE') && CIS_GESAMTNOTE_FREIGABEMAIL_NOTE) { $studlist .= "\n \n \n \n"; if(defined('CIS_GESAMTNOTE_PUNKTE') && CIS_GESAMTNOTE_PUNKTE) { $studlist .= "\n"; } $studlist .= "\n"; $studlist .= "\n"; } else { $studlist .= "\n"; } foreach($result->noten as $note) { // read, release and attempt 1 are one change $this->sperreStudent($note->uid, $lv_id, $sem_kurzbz); $resultLVGes = $this->LvgesamtnoteModel->getLvGesamtNoteVorschlag($lv_id, $note->uid, $sem_kurzbz); if (!isError($resultLVGes) && hasData($resultLVGes)) { $lvgesamtnote = getData($resultLVGes)[0]; // only what changed since the last release; same as the old tool if ($lvgesamtnote->benotungsdatum > $lvgesamtnote->freigabedatum) { $id = $this->LvgesamtnoteModel->update( [$lvgesamtnote->student_uid, $lvgesamtnote->studiensemester_kurzbz, $lvgesamtnote->lehrveranstaltung_id], array( 'note' => $lvgesamtnote->note, 'freigabevon_uid' => getAuthUID(), 'freigabedatum' => date("Y-m-d H:i:s"), 'updateamum' => date("Y-m-d H:i:s"), 'updatevon' => getAuthUID() ) ); if($id) { $res = $this->LvgesamtnoteModel->load($id->retval); if(hasData($res)) { $lvgesamtnote = getData($res)[0]; // The release makes the grade binding, therefore the first exam starts // here. A new exam never creates a second exam in addition. $this->upsertErstantritt( $lv_id, $lvgesamtnote->student_uid, $sem_kurzbz, $lvgesamtnote->note, $lvgesamtnote->punkte, $lvgesamtnote->benotungsdatum ); // The verlauf goes back with the answer, so the table shows the new exam // at once. Without it the row updates only after a reload. $ret[] = array( 'uid' => $note->uid, 'freigabedatum' => $lvgesamtnote->freigabedatum, 'benotungsdatum' => $lvgesamtnote->benotungsdatum, // the row is the one the release just wrote 'verlauf' => $this->buildVerlaufSummary($note->uid, $lv_id, $sem_kurzbz, $lvgesamtnote) ); } } if (defined('CIS_GESAMTNOTE_FREIGABEMAIL_NOTE') && CIS_GESAMTNOTE_FREIGABEMAIL_NOTE) { $stud = isset($studenten[$note->uid]) ? $studenten[$note->uid] : null; $noteKey = (string) $lvgesamtnote->note; $noteBez = isset($notenBezeichnungen[$noteKey]) ? $notenBezeichnungen[$noteKey]->bezeichnung : $noteKey; $studlist .= ""; $studlist .= ""; $studlist .= ""; $studlist .= ""; if(defined('CIS_GESAMTNOTE_PUNKTE') && CIS_GESAMTNOTE_PUNKTE) { $studlist .= ""; } $studlist .= ""; $studlist .= ""; } else { $studlist .= "\n"; } } } $this->entsperreStudent(); } $studlist .= "
" . $this->p->t('person','personenkennzeichen') . "" . $this->p->t('lehre','studiengang') . "" . $this->p->t('benotungstool','c4nachname') . "" . $this->p->t('benotungstool','c4vorname') . "" . $this->p->t('benotungstool','c4punkte') . "" . $this->p->t('benotungstool','c4grade') . "" . $this->p->t('ui','bearbeitetVon') . "
" . $this->p->t('person','uid') . "
" . $this->mailZelle($stud ? $stud->matrikelnr : $note->uid) . "" . $this->mailZelle($stud ? $stud->kuerzel : '') . "" . $this->mailZelle($stud ? $stud->nachname : '') . "" . $this->mailZelle($stud ? $stud->vorname : '') . "" . $this->mailZelle($lvgesamtnote->punkte) . "" . $this->mailZelle($noteBez) . "" . $this->mailZelle($lvgesamtnote->mitarbeiter_uid); if ($lvgesamtnote->updatevon != '') $studlist .= " (" . $this->mailZelle($lvgesamtnote->updatevon) . ")"; $studlist .= "
" . $this->mailZelle($note->uid) . "
"; $this->logLib->logInfoDB(array('saveStudentenNoten', array( 'updatevon' => getAuthUID(), 'updateamum' => date('Y-m-d H:i:s') ), getAuthUID(), getAuthPersonId(), array($result->noten, $lv_id, $sem_kurzbz))); // config toggles the mail itself; FREIGABEMAIL_NOTE toggles how much it carries if($this->config->item('CIS_GESAMTNOTE_FREIGABEMAIL') !== false) { $this->sendFreigabeEmail($lektorFullName, $lvaFullName, count($result->noten), $emails, $studlist, $betreff); } $this->terminateWithSuccess($ret); } /** * POST 'datum' (YYYY-MM-DD), 'lva_id', 'student_uid', 'note'. Inserts or updates one exam and * the course grade. Never writes the Zeugnisnote - Stv does that. */ public function saveStudentPruefung() { // einzelne pruefung speichern // role first: a caller without the action fails on the right, not on missing parameters $this->assertAktion('pruefung'); $result = $this->getPostJSON(); if(!property_exists($result, 'datum') || !property_exists($result, 'lva_id') || !property_exists($result, 'student_uid') || !property_exists($result, 'note')) { $this->terminateWithError($this->p->t('global', 'missingParameters'), 'general'); } $student_uid = $result->student_uid; $note = $result->note; $punkte = $result->punkte; $datum = $result->datum; $lva_id = $result->lva_id; $lehreinheit_id = $result->lehreinheit_id; // pruefung_id identifies the record being edited; null when a new pruefung is added $pruefung_id = property_exists($result, 'pruefung_id') ? $result->pruefung_id : null; $stsem = $result->sem_kurzbz; $this->assertLvAccess($lva_id, $stsem); // examination rules: no entry and no change after the grade entry deadline $this->enforceNoteneintragungsfrist($stsem); $jetzt = date("Y-m-d H:i:s"); if(CIS_GESAMTNOTE_PUNKTE && $punkte !== null && $punkte !== '') { // the grading scale decides; points without a grade refuse the entry, as in the bulk paths $note = $this->noteAusPunkten($punkte, $lva_id, $stsem, $student_uid); if(is_string($note)) $this->terminateWithError($note, 'general'); } // TODO: more sophisticated empty check if($note=='') { // config names the grade, the lib resolves it in tbl_note $note = $this->VerlaufLib->getNoteNichtEingetragen(); } // the dialog sends the teacher when the Lehreinheit has more than one $mitarbeiter_uid = property_exists($result, 'mitarbeiter_uid') ? $result->mitarbeiter_uid : null; $result = $this->savePruefungFuerStudent($pruefung_id, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum, $mitarbeiter_uid); // validation errors and write errors come back as a translated message if(is_string($result)) $this->terminateWithError($result, 'general'); $savedPruefung = $result['savedPruefung'] ?? []; $savedPruefungData = count($savedPruefung) > 0 ? $savedPruefung[0] : null; $lvgesamtnote = $result['lvgesamtnote'] ?? null; $this->terminateWithSuccess(array($savedPruefungData, $lvgesamtnote, $result['verlauf'] ?? null)); } /** Active grades by PK; getNoten() sends the client the same set. @return array note => tbl_note row */ private function aktiveNoten() { if($this->aktiveNotenCache !== null) return $this->aktiveNotenCache; $this->aktiveNotenCache = array(); $result = $this->NoteModel->getAllActive($this->config->item('NOTEN_SORTIERUNG')); if(!isError($result) && hasData($result)) { foreach(getData($result) as $n) $this->aktiveNotenCache[(string)$n->note] = $n; } return $this->aktiveNotenCache; } /** Stops the request when no role of the caller carries this action. */ private function assertAktion($aktion) { if ($this->darfAktion($aktion)) return; $this->terminateWithError($this->p->t('benotungstool', 'aktionNichtErlaubt', [$aktion]), 'general'); } /** * Scopes access so a guessed URL cannot reach foreign grades: a teacher only their own courses * in that Studiensemester, an Assistenz the courses of a Studiengang they are entitled for. * * The Studiensemester is mandatory. Without it a teacher who taught the course in ANY semester * passes the check for every semester. */ private function assertLvAccess($lv_id, $sem_kurzbz) { // an empty id widens load() and the model filters to all courses if(!ctype_digit((string) $lv_id) || (int) $lv_id < 1 || !is_string($sem_kurzbz) || trim($sem_kurzbz) === '') { $this->terminateWithError($this->p->t('global', 'wrongParameters'), 'general'); } $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->config->item('CIS_GESAMTNOTE_LEKTOR_NUR_EIGENE_LV') === false && $this->permissionlib->isBerechtigt('lehre/benotungstool')) { return; // this installation lets a teacher grade every course } 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' ); } /** * uid of the grading person for tbl_pruefung and tbl_lvgesamtnote. An assistant may type it in, * but the grade comes from the teacher; insertvon/updatevon keep the caller. * * Order: valid selection, caller if they teach it, the only teacher, the first teacher, caller. * * @return string */ private function benotenderMitarbeiter($lehreinheit_id, $gewaehlt = null) { $uids = array(); foreach($this->lehrendeDerLehreinheit($lehreinheit_id) as $lehrend) $uids[] = $lehrend->mitarbeiter_uid; if(count($uids) === 0) return getAuthUID(); if($gewaehlt !== null && $gewaehlt !== '' && in_array($gewaehlt, $uids)) return $gewaehlt; if(in_array(getAuthUID(), $uids)) return getAuthUID(); return $uids[0]; } /** Same, for a course grade: the student's Lehreinheit decides. @return string */ private function benotenderMitarbeiterFuerStudent($lva_id, $student_uid, $stsem, $gewaehlt = null) { $lehreinheit_id = null; $resLe = $this->LehrveranstaltungModel->getLeByStudent($student_uid, $stsem, $lva_id); if(!isError($resLe) && hasData($resLe)) $lehreinheit_id = current(getData($resLe))->lehreinheit_id; return $this->benotenderMitarbeiter($lehreinheit_id, $gewaehlt); } /** * Object initialization */ /** * Permissions that open this tool = keys of CIS_GESAMTNOTE_ROLLENMATRIX. * Reads the file directly: the constructor runs before CI can load a config. * * @return array */ private static function berechtigungenAusMatrix() { $config = array(); $datei = APPPATH . 'config/noten.php'; if (is_file($datei)) include $datei; $matrix = isset($config['CIS_GESAMTNOTE_ROLLENMATRIX']) ? $config['CIS_GESAMTNOTE_ROLLENMATRIX'] : null; if (!is_array($matrix) || count($matrix) === 0) { return array('lehre/benotungstool:rw', 'lehre/benotungstool_assistenz:rw'); } $berechtigungen = array(); foreach (array_keys($matrix) as $rolle) $berechtigungen[] = $rolle . ':rw'; return $berechtigungen; } /** * The history for the client. Each write answer contains it, so the client calculates nothing. * * The caller hands over what it already read. Only the exams are read again, because the write * just changed them. false = not given, null = read and absent. * * @param stdClass|null|false $lvRow * @param mixed|null|false $zeugnisNote */ private function buildVerlaufSummary($student_uid, $lva_id, $stsem, $lvRow = false, $zeugnisNote = false) { if($lvRow === false) $lvRow = $this->getLvGesamtnoteRow($lva_id, $student_uid, $stsem); if($zeugnisNote === false) $zeugnisNote = $this->getZeugnisnote($lva_id, $student_uid, $stsem); $verlauf = $this->VerlaufLib->getVerlauf( $student_uid, $lva_id, $stsem, $lvRow ? $lvRow->note : null, $zeugnisNote ); return $this->verlaufSummary($verlauf, true, $lvRow !== null); } /** The deadline from '{SS|WS}yyyy': SS in the same year, WS in the next year. @return DateTime|null */ 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') { $deadlineYear = $year; } elseif($type === 'WS') { $deadlineYear = $year + 1; } else { return null; } $tag = $this->fristMonatTag($type); $deadline = new DateTime(); $deadline->setDate($deadlineYear, $tag['month'], $tag['day']); $deadline->setTime(23, 59, 59); return $deadline; } /** Creates a course grade. The student administration writes it to the transcript. @return stdClass|null */ private function createLvGesamtnote($lva_id, $student_uid, $stsem, $note, $punkte, $lehreinheit_id = null, $mitarbeiter_uid = null) { $jetzt = date("Y-m-d H:i:s"); // the caller knows the Lehreinheit here, so no second lookup is needed $benotender = $lehreinheit_id ? $this->benotenderMitarbeiter($lehreinheit_id, $mitarbeiter_uid) : $this->benotenderMitarbeiterFuerStudent($lva_id, $student_uid, $stsem, $mitarbeiter_uid); $id = $this->LvgesamtnoteModel->insert( array( 'student_uid' => $student_uid, 'lehrveranstaltung_id' => $lva_id, 'studiensemester_kurzbz' => $stsem, 'note' => $note, 'punkte' => $punkte, 'mitarbeiter_uid' => $benotender, 'benotungsdatum' => $jetzt, 'freigabedatum' => null, 'freigabevon_uid' => null, 'bemerkung' => null, 'updateamum' => null, 'updatevon' => null, 'insertamum' => $jetzt, 'insertvon' => getAuthUID() ) ); if(!$id) return null; $res = $this->LvgesamtnoteModel->load($id->retval); return hasData($res) ? getData($res)[0] : null; } /** * May the caller perform this action? Several roles -> union. Empty matrix = no restriction. * * @return bool */ private function darfAktion($aktion) { $matrix = $this->config->item('CIS_GESAMTNOTE_ROLLENMATRIX'); if (!is_array($matrix) || count($matrix) === 0) return true; $this->load->library('PermissionLib'); foreach ($matrix as $rolle => $aktionen) { if (!is_array($aktionen) || !in_array($aktion, $aktionen)) continue; if ($this->permissionlib->isBerechtigt($rolle)) return true; } return false; } /** May the caller enter late? ENTRY only - an exam does not happen retroactively. @return bool */ private function darfFristUeberschreiten() { $rollen = $this->config->item('CIS_GESAMTNOTE_FRIST_AUSNAHME'); if(!is_array($rollen) || count($rollen) === 0) return false; $this->load->library('PermissionLib'); foreach($rollen as $rolle) { if($this->permissionlib->isBerechtigt($rolle)) return true; } return false; } /** Stops the request if the grade entry deadline of the semester has passed. */ private function enforceNoteneintragungsfrist($sem_kurzbz) { if(!$this->fristAktiv('CIS_GESAMTNOTE_FRIST_EINGABE')) return; // a named role may enter late; the exam date stays bound either way if($this->darfFristUeberschreiten()) 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' ); } } /** Commits the write of one student, or rolls it back after a failed statement. @return bool */ private function entsperreStudent() { if($this->db->trans_status() === false) { $this->db->trans_rollback(); return false; } return $this->db->trans_commit(); } /** All actions of the caller, for the client. @return array */ private function erlaubteAktionen() { $matrix = $this->config->item('CIS_GESAMTNOTE_ROLLENMATRIX'); if (!is_array($matrix)) return array(); $aktionen = array(); foreach ($matrix as $aktionenDerRolle) { if (!is_array($aktionenDerRolle)) continue; foreach ($aktionenDerRolle as $aktion) { if (!in_array($aktion, $aktionen) && $this->darfAktion($aktion)) $aktionen[] = $aktion; } } return $aktionen; } /** * 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())); } } /** * Recipients of the release mail: 'studiengang', 'aufrufer', or any entry containing '@'. * * @return array */ private function freigabeEmpfaenger($studiengangAdressen) { $adressen = array(); foreach($this->freigabeEmpfaengerEintraege() as $eintrag) { if($eintrag === 'studiengang') { foreach($studiengangAdressen as $adresse) { if(trim($adresse) !== '') $adressen[] = trim($adresse); } continue; } if($eintrag === 'aufrufer') { $adressen[] = getAuthUID() . '@' . DOMAIN; continue; } if(strpos((string) $eintrag, '@') !== false) $adressen[] = trim($eintrag); } return array_values(array_unique($adressen)); } /** The configured recipient entries of the release mail. @return array */ private function freigabeEmpfaengerEintraege() { $eintraege = $this->config->item('CIS_GESAMTNOTE_FREIGABEMAIL_EMPFAENGER'); return is_array($eintraege) ? $eintraege : array('studiengang', 'aufrufer'); } /** Sancho template of the release mail. @return string */ private function freigabeVorlage() { $vorlage = $this->config->item('CIS_GESAMTNOTE_FREIGABEMAIL_VORLAGE'); return (is_string($vorlage) && $vorlage !== '') ? $vorlage : 'Notenfreigabe'; } /** Month and day of the grade entry deadline for 'SS' or 'WS'. @return array */ private function fristMonatTag($type) { $cfg = $this->config->item($type === 'SS' ? 'NOTENEINTRAGUNGSFRIST_SS' : 'NOTENEINTRAGUNGSFRIST_WS'); return array( '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 ); } /** * The grade 'entschuldigt' if an addon reports the date as excused, else null. A failure of that * addon must not stop the grade entry. * * Runs BEFORE the transaction: a failed addon query would abort it. * * @return mixed|null */ private function entschuldigungsNote($student_uid, $datum) { $status = []; try { Events::trigger( 'getEntschuldigungsStatusForStudentOnDate', function & () use (&$status) { return $status; }, [ 'student_uid' => $student_uid, 'datum' => $datum ] ); } catch (Throwable $t) { $this->addMeta('getEntschuldigungsStatusError', $t->getMessage()); return null; } if(count($status) === 0 || $status[0] != true) return null; return $this->VerlaufLib->getSpecialNotes()['entschuldigt']; } /** Does one of the two deadline checks apply? Falls back to the old combined key. @return bool */ private function fristAktiv($key) { $wert = $this->config->item($key); if($wert !== null) return (bool) $wert; return (bool) $this->config->item('CIS_GESAMTNOTE_NOTENEINTRAGUNGSFRIST'); } private function getAnwesenheiten($prestudent_ids, $lv_id, $sem_kurzbz) { $anwesenheiten = []; try { $downloadFunc = function ($anwesenheitenResult) use (&$anwesenheiten) { // map result rows by prestudent_uid to retrieve them by that key later on foreach ($anwesenheitenResult as $anw) { $anwesenheiten[$anw->prestudent_id] = $anw->sum; } }; Events::trigger( 'getAnwesenheitenForLvAndSemester', $prestudent_ids, $lv_id, $sem_kurzbz, $downloadFunc ); } catch (Throwable $t) { $this->addMeta('getAnwesenheitenForLvAndSemester', $t->getMessage()); } return $anwesenheiten; } /** * Reads the course grade WITHOUT the filter. getLvGesamtNoten() uses 'freigabedatum < NOW()' * and therefore hides a new grade. In this tool this wrapper is always the correct one. */ private function getLvGesamtnoteRow($lva_id, $student_uid, $stsem) { $res = $this->LvgesamtnoteModel->getLvGesamtNoteVorschlag($lva_id, $student_uid, $stsem); return (!isError($res) && hasData($res)) ? getData($res)[0] : null; } /** The transcript grade or null. Credited grades are there, not in the course grade. @return mixed|null */ private function getZeugnisnote($lva_id, $student_uid, $stsem) { $key = $lva_id . '|' . $student_uid . '|' . $stsem; if(array_key_exists($key, $this->zeugnisnoteCache)) return $this->zeugnisnoteCache[$key]; $this->load->model('education/Zeugnisnote_model', 'ZeugnisnoteModel'); $res = $this->ZeugnisnoteModel->load([ 'studiensemester_kurzbz' => $stsem, 'student_uid' => $student_uid, 'lehrveranstaltung_id' => $lva_id ]); return $this->zeugnisnoteCache[$key] = (!isError($res) && hasData($res)) ? getData($res)[0]->note : null; } /** Does a bulk path stop at the first rejected row? @return bool */ private function importBrichtAb() { return (bool) $this->config->item('CIS_GESAMTNOTE_IMPORT_ABBRUCH'); } /** Is this course grade row released? @return bool */ private function istFreigegeben($lvRow) { return $lvRow !== null && $lvRow->freigabedatum !== null && $lvRow->freigabedatum !== ''; } /** Lehreinheiten of one student in the course, lowest id first. Empty = no participant. @return array */ private function lehreinheitenFuerStudent($lva_id, $student_uid, $stsem) { $key = $lva_id . '|' . $student_uid . '|' . $stsem; if(!array_key_exists($key, $this->lehreinheitCache)) { $res = $this->LehrveranstaltungModel->getLeIdsByStudent($student_uid, $stsem, $lva_id); $this->lehreinheitCache[$key] = hasData($res) ? array_column(getData($res), 'lehreinheit_id') : array(); } return $this->lehreinheitCache[$key]; } /** Lehreinheit of one student; the grading person and the exam row both need it. @return mixed|null */ private function lehreinheitFuerStudent($lva_id, $student_uid, $stsem) { $lehreinheiten = $this->lehreinheitenFuerStudent($lva_id, $student_uid, $stsem); return $lehreinheiten ? $lehreinheiten[0] : null; } /** * The teachers of one Lehreinheit, from lehre.tbl_lehreinheitmitarbeiter. * * @return array */ private function lehrendeDerLehreinheit($lehreinheit_id) { if(!$lehreinheit_id) return array(); $key = (string) $lehreinheit_id; if(isset($this->lehrendeCache[$key])) return $this->lehrendeCache[$key]; $this->lehrendeCache[$key] = array(); $this->load->model('education/Lehreinheitmitarbeiter_model', 'LehreinheitmitarbeiterModel'); $result = $this->LehreinheitmitarbeiterModel->getLektorenByLe($lehreinheit_id); if(isError($result) || !hasData($result)) return array(); $lehrende = getData($result); usort($lehrende, function($a, $b) { return strcmp($a->mitarbeiter_uid, $b->mitarbeiter_uid); }); $this->lehrendeCache[$key] = $lehrende; return $lehrende; } /** One cell of the release mail. The mail is HTML, therefore no value may carry markup. @return string */ private function mailZelle($wert) { return htmlspecialchars(trim((string) $wert), ENT_QUOTES, 'UTF-8'); } /** Grade from points for ONE bulk row, or a translated error so the caller skips it. @return mixed|string */ private function noteAusPunkten($punkte, $lv_id, $sem_kurzbz, $uid) { $result = $this->NotenschluesselaufteilungModel->getNote($punkte, $lv_id, $sem_kurzbz); if(isError($result)) return getError($result); $note = getData($result); if($note === null || $note === '') { return $this->p->t('benotungstool', 'c4punkteKeineNoteErmittelt', [$uid]); } return $note; } /** * Exam DATE against the deadline. enforceNoteneintragungsfrist asks the other question: the * time of entry. * * @return string|null translated error or null */ private function pruefungsdatumNachFrist($sem_kurzbz, $datum, $student_uid) { if(!$this->fristAktiv('CIS_GESAMTNOTE_FRIST_PRUEFUNGSDATUM')) return null; $deadline = $this->computeNoteneintragungsfrist($sem_kurzbz); if($deadline === null) return null; $tag = substr((string) $datum, 0, 10); if($tag === '' || $tag <= $deadline->format('Y-m-d')) return null; return $this->p->t('benotungstool', 'pruefungsdatumNachFrist', [$student_uid, $deadline->format('d.m.Y')]); } /** Decimals of the points average before the scale applies. @return int */ private function punkteNachkommastellen() { $stellen = $this->config->item('CIS_GESAMTNOTE_VORSCHLAG_PUNKTE_STELLEN'); return is_numeric($stellen) && (int) $stellen >= 0 ? (int) $stellen : 2; } /** * Turns a rule answer into a text. Every message names the student first. * * @param array|null $fehler [phraseKey, extraParams] * @return string|null */ private function regelFehlertext($fehler, $student_uid) { if($fehler === null) return null; return $this->p->t('benotungstool', $fehler[0], array_merge([$student_uid], $fehler[1])); } /** * Rounds the partial-grade average. Smaller number = better grade, so 'besser' floors. * * @return int */ private function rundeNote($wert) { $modus = $this->rundungsModus(); if($modus === 'besser') return (int) floor($wert); if($modus === 'schlechter') return (int) ceil($wert); return (int) round($wert); } /** Rounding of the proposal: 'besser', 'schlechter' or 'kaufmaennisch'. @return string */ private function rundungsModus() { $modus = $this->config->item('CIS_GESAMTNOTE_VORSCHLAG_RUNDUNG'); return in_array($modus, array('besser', 'schlechter'), true) ? $modus : 'kaufmaennisch'; } /** * One exam entry for ONE student: validate, write course grade, write exam. Zeugnisnote is * never touched. * * Both writes run in ONE transaction. A course grade without its exam is a state that no rule * describes, and the client would show an attempt that does not exist. * * @return array|string ['savedPruefung', 'lvgesamtnote', 'verlauf'] or an error message */ private function savePruefungFuerStudent($pruefung_id, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum, $mitarbeiter_uid = null) { // the rules compare Y-m-d strings; another format breaks the order of the attempts $tag = substr((string) $datum, 0, 10); $geprueft = DateTime::createFromFormat('Y-m-d', $tag); if(!$geprueft || $geprueft->format('Y-m-d') !== $tag) return $this->p->t('benotungstool', 'pruefungsdatumUngueltig', [$student_uid]); $datum = $tag; // §7 and §11: every exam must take place before the grade entry deadline. The kommissionelle // Prüfung is the important case, because it decides if the student loses a semester. $fristError = $this->pruefungsdatumNachFrist($stsem, $datum, $student_uid); if($fristError !== null) return $fristError; // the Lehreinheit decides the course of the exam; accept only one of this student in this course $lehreinheiten = $this->lehreinheitenFuerStudent($lva_id, $student_uid, $stsem); if(!$lehreinheiten) return $this->p->t('benotungstool', 'c4pruefungNichtGespeichert', [$student_uid]); $index = array_search($lehreinheit_id, $lehreinheiten); $lehreinheit_id = $lehreinheiten[$index === false ? 0 : $index]; // the addon runs before the transaction: a failed addon query would abort it $entschuldigt = $this->entschuldigungsNote($student_uid, $datum); $this->sperreStudent($student_uid, $lva_id, $stsem); // The context of the rules, read once inside the lock. It travels with the call, so no method // below reads the same row again. // The course grade comes without the filter: a grade that is not released still exists. You // must update it, because a new insert breaks the primary key. $zeugnisNote = $this->getZeugnisnote($lva_id, $student_uid, $stsem); $bestehendeLvNote = $this->getLvGesamtnoteRow($lva_id, $student_uid, $stsem); $pruefungen = $this->VerlaufLib->getPruefungen($student_uid, $lva_id, $stsem); // validate before any write $regelError = $this->validatePruefung($student_uid, $note, $datum, $pruefung_id, $pruefungen, $bestehendeLvNote, $zeugnisNote); if($regelError !== null) { $this->db->trans_rollback(); return $regelError; } // A course grade without an exam row is attempt 1: the takeover ran without // CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME, or the grade is older than this tool. The first new exam writes it now. if(($pruefung_id === null || $pruefung_id === '') && count($pruefungen) === 0 && $bestehendeLvNote !== null && $this->VerlaufLib->buildVerlauf($pruefungen, $bestehendeLvNote->note, $zeugnisNote)->impliziterErstantritt) { // attempt 1 must stay before the new exam; an exam entered late can be older than the benotungsdatum $tagLvNote = substr((string) $bestehendeLvNote->benotungsdatum, 0, 10); $tagErstantritt = ($tagLvNote !== '' && $tagLvNote < $datum) ? $tagLvNote : date('Y-m-d', strtotime($datum . ' -1 day')); $this->VerlaufLib->upsertErstantritt( $student_uid, $lva_id, $stsem, $bestehendeLvNote->note, $bestehendeLvNote->punkte, $tagErstantritt, $this->benotenderMitarbeiter($lehreinheit_id), false, $lehreinheit_id ); $pruefungen = $this->VerlaufLib->getPruefungen($student_uid, $lva_id, $stsem); } // an excused date replaces the grade of the dialog, within the occurrence limit if($entschuldigt !== null && !$this->VerlaufLib->ueberschreitetNotenLimit($pruefungen, $entschuldigt, $pruefung_id)) { $note = $entschuldigt; } $jetzt = date("Y-m-d H:i:s"); // this decides if the course grade is the implicit first attempt; read it before the update $origLvNote = $bestehendeLvNote ? $bestehendeLvNote->note : null; $lvgesamtnote = $bestehendeLvNote; // the rules decide the course grade: the last exam that uses an attempt, never 'entschuldigt' list($lvNoteNeu, $lvPunkteNeu) = $this->VerlaufLib->lvNoteNachTermin( $pruefungen, $pruefung_id, $note, $punkte, $origLvNote, $bestehendeLvNote ? $bestehendeLvNote->punkte : null ); if($bestehendeLvNote === null) { $lvgesamtnote = $this->createLvGesamtnote($lva_id, $student_uid, $stsem, $lvNoteNeu, $lvPunkteNeu, $lehreinheit_id, $mitarbeiter_uid); $this->logLib->logInfoDB(array('pruefung: lvnote angelegt', $student_uid, $lva_id, $stsem, $note, $punkte, getAuthUID(), getAuthPersonId())); } else { // a repeat must not worsen the LV-Note; the exam row keeps the real grade if($lvNoteNeu == $note && $this->config->item('CIS_GESAMTNOTE_VERBESSERUNG_BESSERE_GEWINNT') && $this->VerlaufLib->istSchlechter($note, $origLvNote)) { $lvNoteNeu = $origLvNote; } // state hangs on benotungsdatum > freigabedatum, so touching the date revokes the release $hebtAuf = $this->config->item('CIS_GESAMTNOTE_PRUEFUNG_HEBT_FREIGABE_AUF') !== false; $id = $this->LvgesamtnoteModel->update( [$bestehendeLvNote->student_uid, $bestehendeLvNote->studiensemester_kurzbz, $bestehendeLvNote->lehrveranstaltung_id], array( 'note' => $lvNoteNeu, 'punkte' => $lvPunkteNeu, 'benotungsdatum' => $hebtAuf ? $jetzt : $bestehendeLvNote->benotungsdatum, 'updateamum' => $jetzt, 'updatevon' => getAuthUID() ) ); if($id) { $res = $this->LvgesamtnoteModel->load($id->retval); if(hasData($res)) $lvgesamtnote = getData($res)[0]; } $this->logLib->logInfoDB(array('pruefung: lvnote aktualisiert', $student_uid, $lva_id, $stsem, $note, $punkte, getAuthUID(), getAuthPersonId())); } // save pruefung after updating lvnote, since pruefungspunkte get loaded by lv punkte $pruefungenChanged = $this->savePruefungstermin( $pruefung_id, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum, $origLvNote, $mitarbeiter_uid, $lvgesamtnote, $pruefungen, $zeugnisNote ); if(is_string($pruefungenChanged)) { // the course grade alone is not a valid state, therefore it goes back as well $this->db->trans_rollback(); return $pruefungenChanged; } if(!$this->entsperreStudent()) return $this->p->t('benotungstool', 'c4pruefungNichtGespeichert', [$student_uid]); $pruefungenChanged['lvgesamtnote'] = $lvgesamtnote; return $pruefungenChanged; } /** * Creates or updates an exam. The type carries no meaning here - position comes from the * history; pruefungstyp_kurzbz is written but never read back. * * @param int $pruefung_id set = edit that row, null = new attempt * @param stdClass|null $lvgesamtnote the course grade the caller wrote * @param array $pruefungen the exams before this write * @param mixed|null $zeugnisNote the transcript grade the caller read * @return array|string the changed exams or a translated error message */ private function savePruefungstermin($pruefung_id, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum, $origLvNote = null, $mitarbeiter_uid = null, $lvgesamtnote = null, $pruefungen = array(), $zeugnisNote = null) { // no exam without a course grade (a grade that is not released also counts) if($lvgesamtnote === null) { return $this->p->t('benotungstool', 'c4keineLvNoteEingetragen'); } $jetzt = date("Y-m-d H:i:s"); $pruefungenChanged = []; // edit: only the addressed record, no type change and no new record if($pruefung_id !== null && $pruefung_id !== '') { $id = $this->LePruefungModel->update( $pruefung_id, array( 'updateamum' => $jetzt, 'updatevon' => getAuthUID(), 'note' => $note, 'punkte' => $punkte, 'datum' => $datum, 'anmerkung' => "" ) ); $res = null; if($id) { $res = $this->LePruefungModel->load($id->retval); if(hasData($res)) $pruefungenChanged['savedPruefung'] = getData($res); } $this->logLib->logInfoDB(array('pruefung updated', $res, getAuthUID(), getAuthPersonId())); if(!isset($pruefungenChanged['savedPruefung'])) { return $this->p->t('benotungstool', 'c4pruefungNichtGespeichert', [$student_uid]); } $pruefungenChanged['verlauf'] = $this->buildVerlaufSummary($student_uid, $lva_id, $stsem, $lvgesamtnote, $zeugnisNote); return $pruefungenChanged; } $verlauf = $this->VerlaufLib->buildVerlauf($pruefungen, $origLvNote); $rolle = $verlauf->naechsteRolle; // one action makes one exam; the release creates the first attempt (upsertErstantritt) $typ = ($rolle === PruefungsverlaufLib::ROLLE_ERSTANTRITT) ? $this->VerlaufLib->legacyTypFuerAntritt(1) : $this->VerlaufLib->legacyTypFuerWiederholung($verlauf); $id = $this->LePruefungModel->insert( array( 'lehreinheit_id' => $lehreinheit_id, 'student_uid' => $student_uid, 'mitarbeiter_uid' => $this->benotenderMitarbeiter($lehreinheit_id, $mitarbeiter_uid), 'note' => $note, 'punkte' => $punkte, 'pruefungstyp_kurzbz' => $typ, 'datum' => $datum, 'anmerkung' => "", 'insertamum' => $jetzt, 'insertvon' => getAuthUID(), 'updateamum' => null, 'updatevon' => null, 'ext_id' => null ) ); $res = null; if($id) { $res = $this->LePruefungModel->load($id->retval); if(hasData($res)) $pruefungenChanged['savedPruefung'] = getData($res); } $this->logLib->logInfoDB(array('pruefung inserted ('.$rolle.')', $res, getAuthUID(), getAuthPersonId())); // savedPruefung is the proof of success. Without this guard a failed insert (for example // a missing lehreinheit_id) tells the client that the write was successful. if(!isset($pruefungenChanged['savedPruefung'])) { return $this->p->t('benotungstool', 'c4pruefungNichtGespeichert', [$student_uid]); } $pruefungenChanged['verlauf'] = $this->buildVerlaufSummary($student_uid, $lva_id, $stsem, $lvgesamtnote, $zeugnisNote); return $pruefungenChanged; } private function sendFreigabeEmail($lektorFullName, $lvaFullName, $notenCount, $emailAdressen, $studlist, $betreff) { $emailAdressen = $this->freigabeEmpfaenger($emailAdressen); $adressen = implode(";", $emailAdressen); foreach ($emailAdressen as $email) { // Prepare mail content $body_fields = array( 'lektor' => $lektorFullName, 'lvaname' => $lvaFullName, 'studlist' => $studlist, 'neuenotencount' => $notenCount, 'adressen' => $adressen ); // Send mail sendSanchoMail( $this->freigabeVorlage(), $body_fields, $email, $betreff ); } } /** Opens a transaction; a second request for the same student in the same course waits for it. */ private function sperreStudent($student_uid, $lva_id, $stsem) { $this->db->trans_begin(); $this->db->query('SELECT pg_advisory_xact_lock(hashtext(?))', array($student_uid . '|' . $lva_id . '|' . $stsem)); } /** * Creates or updates attempt 1 on release, dated on the benotungsdatum. §8 asks for the date of * the last performance, which the system does not hold - the old tool used the same column. * * Applies only while at most one exam exists. A credited grade changes nothing. */ private function upsertErstantritt($lva_id, $student_uid, $stsem, $note, $punkte, $datum) { // same switch as the Übernahme path if(!$this->config->item('CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME')) return; if($this->VerlaufLib->istAnrechnungsnote($this->getZeugnisnote($lva_id, $student_uid, $stsem))) 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); $geschrieben = $this->VerlaufLib->upsertErstantritt( $student_uid, $lva_id, $stsem, $note, $punkte, $datum, $this->benotenderMitarbeiter($lehreinheit_id), false, $lehreinheit_id ); if($geschrieben !== null) { $this->logLib->logInfoDB(array('erstantritt (freigabe)', $student_uid, getAuthUID(), getAuthPersonId())); } } /** * Guards the benotungsdatum. It becomes the date of attempt 1, so the exam date rules apply. * No date is allowed - the current moment then applies. * * @return string|null translated error or null */ 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(!$this->config->item('CIS_GESAMTNOTE_DATUM_ZUKUNFT') && $tag > date('Y-m-d')) { return $this->p->t('benotungstool', 'benotungsdatumInZukunft', [$student_uid]); } return $this->pruefungsdatumNachFrist($sem_kurzbz, $tag, $student_uid); } /** * Guards a direct API write of the course grade. The cell editor carries the same rules, but * API and CSV import bypass it. Order: grade, exam, transcript. * * @return string|null translated error or null */ private function validateNotenvorschlag($lva_id, $student_uid, $stsem, $note) { $noten = $this->aktiveNoten(); $wert = trim((string)$note); // the course grade is never 'entschuldigt': an excused date uses no attempt if($wert !== '' && $wert == $this->VerlaufLib->getSpecialNotes()['entschuldigt']) { return $this->p->t('benotungstool', 'c4noteNichtInLehre', [$student_uid]); } // the editor offers the lehre grades only; an administrative grade belongs to the transcript if($this->config->item('CIS_GESAMTNOTE_VORSCHLAG_NUR_LEHRENOTEN') !== false && (!isset($noten[$wert]) || !$noten[$wert]->lehre)) { return $this->p->t('benotungstool', 'c4noteNichtInLehre', [$student_uid]); } // 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->config->item('CIS_GESAMTNOTE_VORSCHLAG_NACH_WIEDERHOLUNG') !== true && $this->VerlaufLib->hatWiederholung($this->VerlaufLib->getPruefungen($student_uid, $lva_id, $stsem))) { return $this->p->t('benotungstool', 'c4notenvorschlagGesperrt', [$student_uid]); } // a released grade can be final; then no path may change it any more if($this->config->item('CIS_GESAMTNOTE_FREIGABE_FINAL') && $this->istFreigegeben($this->getLvGesamtnoteRow($lva_id, $student_uid, $stsem))) { return $this->p->t('benotungstool', 'freigabeEndgueltig', [$student_uid]); } // a transcript grade can forbid the teacher to overwrite it, for example 'intern angerechnet'. // An unknown or inactive grade locks nothing, which is what the editor does as well. $zeugnisnote = trim((string)$this->getZeugnisnote($lva_id, $student_uid, $stsem)); if(isset($noten[$zeugnisnote]) && !$noten[$zeugnisnote]->lkt_ueberschreibbar) { return $this->p->t('benotungstool', 'c4zeugnisnoteGesperrt', [$student_uid]); } return null; } /** * Runs the examination rules for one write. The lib holds the rules; this only translates the * answer. The caller reads the context, because the write path needs the same three values. * * Empty $pruefung_id = new attempt, set = edit. * * @param array $pruefungen the exams of the student in this course * @param stdClass|null $lvRow the course grade, read without the release filter * @param mixed|null $zeugnisNote the transcript grade * @return string|null translated error or null */ private function validatePruefung($student_uid, $note, $datum, $pruefung_id, $pruefungen, $lvRow, $zeugnisNote) { $lvNote = $lvRow ? $lvRow->note : null; $neu = ($pruefung_id === null || $pruefung_id === ''); $fehler = $neu ? $this->VerlaufLib->validateAdd($pruefungen, $note, $datum, $lvNote, $zeugnisNote) : $this->VerlaufLib->validateEdit($pruefungen, $pruefung_id, $note, $datum, $lvNote, $zeugnisNote); if($fehler !== null) return $this->regelFehlertext($fehler, $student_uid); // a released grade can be final; the exam would change it if($this->config->item('CIS_GESAMTNOTE_FREIGABE_FINAL') && $this->istFreigegeben($lvRow)) { return $this->p->t('benotungstool', 'freigabeEndgueltig', [$student_uid]); } // the exam also writes the course grade: only a lehre grade, or one that uses no attempt $noten = $this->aktiveNoten(); $wert = trim((string) $note); if($this->config->item('CIS_GESAMTNOTE_VORSCHLAG_NUR_LEHRENOTEN') !== false && !in_array($wert, $this->VerlaufLib->getSpecialNotes()['ohneAntritt']) && (!isset($noten[$wert]) || !$noten[$wert]->lehre)) { return $this->p->t('benotungstool', 'c4noteNichtInLehre', [$student_uid]); } // a locked transcript grade forbids every change, as in validateNotenvorschlag $zeugnis = trim((string) $zeugnisNote); if(isset($noten[$zeugnis]) && !$noten[$zeugnis]->lkt_ueberschreibbar) { return $this->p->t('benotungstool', 'c4zeugnisnoteGesperrt', [$student_uid]); } // the matrix can reserve the kommPruef for one role, on top of the global switch if($neu && !$this->darfAktion('kommpruef')) { $verlauf = $this->VerlaufLib->buildVerlauf($pruefungen, $lvNote, $zeugnisNote); if($verlauf->naechsteRolle === PruefungsverlaufLib::ROLLE_KOMMISSIONELL) { return $this->p->t('benotungstool', 'kommPruefNichtErlaubt', [$student_uid]); } } return null; } /** * @param bool $withPruefungen add the exams (for the write paths) or send the counters only * @return array */ private function verlaufSummary($verlauf, $withPruefungen = false, $hatLvNote = null) { $summary = array( 'antrittCount' => $verlauf->antrittCount, 'maxAntritte' => $verlauf->maxAntritte, 'canAdd' => $verlauf->canAdd, 'terminal' => $verlauf->terminal, // closed by a pass, not by the attempt limit 'bestanden' => $verlauf->bestanden, // the takeover path is locked from the first repeat on 'hatWiederholung' => $verlauf->hatWiederholung, '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 ); if($withPruefungen) $summary['pruefungen'] = $verlauf->pruefungen; return $summary; } }