diff --git a/application/config/noten.php b/application/config/noten.php index 6fdee8b86..81f467a13 100644 --- a/application/config/noten.php +++ b/application/config/noten.php @@ -2,16 +2,63 @@ if (!defined('BASEPATH')) exit('No direct script access allowed'); -// 'entschuldigt' & 'noch nicht eingetragen' -> wirken sich nicht auf Antritte aus +// Noten die keinen Prüfungsantritt verbrauchen. Fallback-PKs; massgeblich sind die per Bezeichnung +// aufgelösten Noten (NOTEN_OHNE_ANTRITT_BEZEICHNUNGEN), da die PKs je Installation abweichen. $config['NOTEN_OHNE_ANTRITT'] = [9, 17]; // tbl_note pk -$config['NOTEN_OCCURANCE_LIMIT_MAP'] = [17 => 1]; // across the 4 fixed antritte only one can be entschuldigt +// 'Nicht beurteilt' ist eine Altlast: formal eine Note, inhaltlich keine Leistungsfeststellung. +// Wird weiterhin häufig vergeben und darf daher keinen Antritt kosten. +$config['NOTEN_OHNE_ANTRITT_BEZEICHNUNGEN'] = ['Noch nicht eingetragen', 'entschuldigt', 'Nicht beurteilt']; + +// Anrechnungsnoten: die Leistung wurde vorab anerkannt, der/die Studierende ist zwar Teil der +// Lehrveranstaltung, tritt aber zu keiner Prüfung an. Solange die ZEUGNISNOTE eine davon ist, sind +// für diese Lehrveranstaltung keine Prüfungen möglich. Auflösung wie oben über die Bezeichnung, +// die PKs sind nur Fallback. +$config['NOTEN_ANRECHNUNG_BEZEICHNUNGEN'] = ['angerechnet', 'intern angerechnet']; +$config['NOTEN_ANRECHNUNG'] = [6, 16]; // tbl_note pk + +$config['NOTEN_OCCURANCE_LIMIT_MAP'] = [17 => 1]; // über alle Antritte hinweg nur ein entschuldigt // tbl_note pk of the 'entschuldigt' note. An entschuldigt Termin is preserved as its own dated // entry when a new pruefung of the same type is created (instead of being overwritten). $config['NOTE_ENTSCHULDIGT'] = 17; +// The maximum number of attempts that count, the first attempt and the kommissionelle attempt +// included. null derives the number from the old flags: +// 1 (the original assessment) + TERMIN2 + TERMIN3 + KOMMPRUEF +// The examination rules permit three attempts. With TERMIN2 and KOMMPRUEF the formula gives 3. +// An installation with TERMIN3 gets 4, which is one more attempt in the same chain. +$config['CIS_GESAMTNOTE_MAX_ANTRITTE'] = null; + +// The exam types that take place before a commission. +$config['PRUEFUNG_KOMMISSIONELL_TYPEN'] = ['kommPruef', 'zusKommPruef']; + +// The type that the tool writes for the last attempt. The last attempt is always kommissionell. +$config['PRUEFUNG_TYP_KOMMISSIONELL'] = 'kommPruef'; + +// Exam types that never use an attempt. A zusKommPruef repeats a kommissionelle Prüfung that had +// a procedural fault. It stands outside the attempt chain, and the student administration enters +// it. The examination rules do not describe it. +$config['PRUEFUNG_TYPEN_OHNE_ANTRITT'] = ['zusKommPruef']; + +// Spaltenaufteilung der Prüfungen in der Notentabelle: +// 'antritt' - eine Spalte je Antrittsnummer, Datum steht in der Zelle (robust bei Einzelterminen) +// 'datum' - eine Spalte je Prüfungsdatum (kompakt, wenn ganze Jahrgänge am selben Tag antreten) +// Nur die Vorgabe; im Tool umschaltbar und pro Benutzer gespeichert. +$config['CIS_GESAMTNOTE_PRUEFUNGSSPALTEN'] = 'antritt'; + +// Bei der Notenübernahme durch die Assistenz den ersten Antritt als eigene Prüfung anlegen. +$config['CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME'] = true; + // 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) + +// 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/config/routes.php b/application/config/routes.php index 975537dc4..1674b45ed 100644 --- a/application/config/routes.php +++ b/application/config/routes.php @@ -69,6 +69,11 @@ $route['Cis/OtherLvPlan/.*'] = 'Cis/OtherLvPlan/index/$1'; $route['Cis/StgOrgLvPlan/.*'] = 'Cis/StgOrgLvPlan/index/$1'; $route['Cis/Benotungstool/.*'] = 'Cis/Benotungstool/index/$1'; +//Route for Zeitsperrenma +$route['Cis/Zeitsperrenma/.*'] = 'Cis/Zeitsperrenma/index/$1'; +//Route für Zeitsperren +$route['Cis/Zeitsperren/.*'] = 'Cis/Zeitsperren/index/$1'; + $route['Abgabetool/Assistenz'] = 'Cis/Abgabetool/Assistenz'; $route['Abgabetool/Assistenz/(:any)'] = 'Cis/Abgabetool/Assistenz/$1'; $route['Abgabetool/Mitarbeiter'] = 'Cis/Abgabetool/Mitarbeiter'; 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/Cis/Zeitsperrenma.php b/application/controllers/Cis/Zeitsperrenma.php new file mode 100644 index 000000000..fd864286c --- /dev/null +++ b/application/controllers/Cis/Zeitsperrenma.php @@ -0,0 +1,31 @@ + ['basis/cis:r'] + ]); + + } + + // ----------------------------------------------------------------------------------------------------------------- + // Public methods + + /** + * @return void + */ + public function index() + { + $this->load->view('CisRouterView/CisRouterView.php', ['route' => 'Zeitsperrenma']); + } +} diff --git a/application/controllers/api/frontend/v1/MaZeitsperren.php b/application/controllers/api/frontend/v1/MaZeitsperren.php new file mode 100644 index 000000000..ad5bf20a2 --- /dev/null +++ b/application/controllers/api/frontend/v1/MaZeitsperren.php @@ -0,0 +1,167 @@ + self::PERM_LOGGED, + 'getAllZeitsperrenFixeMa' => self::PERM_LOGGED, + 'getAllZeitsperrenLector' => self::PERM_LOGGED, + 'getAllZeitsperrenOes' => self::PERM_LOGGED, + 'getZeitsperrenAss' => self::PERM_LOGGED, + 'getStgLectors' => self::PERM_LOGGED, + 'loadZeitsperrenLectorStg' => self::PERM_LOGGED, + 'loadZeitsperrenMa' => self::PERM_LOGGED, + 'getDetailsMa' => self::PERM_LOGGED, + ]); + + // Load Libraries + $this->load->library('VariableLib', ['uid' => getAuthUID()]); + $this->load->library('form_validation'); + + // Load language phrases + $this->loadPhrases([ + 'ui', + 'person', + 'zeitsperren' + ]); + + // Load models + $this->load->model('ressource/Zeitsperre_model', 'ZeitsperreModel'); + } + + //only user with zeitsperren + public function getAllActiveZeitsperren($days) + { + $result = $this->ZeitsperreModel->getZeitsperrenForNextDays($days); + $data = $this->getDataOrTerminateWithError($result); + + $this->terminateWithSuccess($data); + } + + //TODO: check query: more results after join with hr-table + //all fixe Ma + public function getAllZeitsperrenFixeMa($days) + { + $von = date('Y-m-d'); + $bis = date('Y-m-d', strtotime("+{$days} days")); + $result = $this->ZeitsperreModel->getMitarbeiterWithZeitsperren($von, $bis, true, false, null, false); + $data = $this->getDataOrTerminateWithError($result); + + $this->terminateWithSuccess($data); + } + + public function getAllZeitsperrenLector($days) + { + $von = date('Y-m-d'); + $bis = date('Y-m-d', strtotime("+{$days} days")); + $result = $this->ZeitsperreModel->getMitarbeiterWithZeitsperren($von, $bis, false, true, null, false); + + $data = $this->getDataOrTerminateWithError($result); + + $this->terminateWithSuccess($data); + } + + public function getZeitsperrenAss($days) + { + $von = date('Y-m-d'); + $bis = date('Y-m-d', strtotime("+{$days} days")); + $result = $this->ZeitsperreModel->getMitarbeiterWithZeitsperren($von, $bis, false, false, null, true); + + $data = $this->getDataOrTerminateWithError($result); + + $this->terminateWithSuccess($data); + } + + public function getAllZeitsperrenOes($days, $oe) + { + if(!$oe) + { + return $this->terminateWithError($this->p->t('ui', 'error_missingId', ['id'=> 'OE Kurzbezeichnung']), self::ERROR_TYPE_GENERAL); + } + $von = date('Y-m-d'); + $bis = date('Y-m-d', strtotime("+{$days} days")); + $result = $this->ZeitsperreModel->getMitarbeiterWithZeitsperren($von, $bis, false, false, $oe, false); + + $data = $this->getDataOrTerminateWithError($result); + + $this->terminateWithSuccess($data); + } + + public function loadZeitsperrenLectorStg($days, $stg) + { + if(!$stg) + { + return $this->terminateWithError($this->p->t('ui', 'error_missingId', ['id'=> 'Studiengangskennzahl']), self::ERROR_TYPE_GENERAL); + } + if(!is_numeric($stg)) + { + return $this->terminateWithError($this->p->t('ui', 'error_paramNoNumber', ['param'=> 'Studiengangskennzahl']), self::ERROR_TYPE_GENERAL); + } + + $von = date('Y-m-d'); + $bis = date('Y-m-d', strtotime("+{$days} days")); + $result = $this->ZeitsperreModel->getMitarbeiterWithZeitsperren($von, $bis, false, true, false, false, $stg); + + $data = $this->getDataOrTerminateWithError($result); + + $this->terminateWithSuccess($data); + } + + public function getStgLectors() + { + $sql = " + select + studiengang_kz, + kurzbz, + kurzbzlang, + typ, + bezeichnung, + kurzbzlang || ' (' || bezeichnung || ')' AS label, + aktiv + from + public.tbl_studiengang + where + typ in ('b','m') + order by typ asc, kurzbz Asc + "; + + $this->load->model('organisation/Studiengang_model', 'StudiengangModel'); + $result = $this->StudiengangModel->execReadOnlyQuery($sql); + $data = $this->getDataOrTerminateWithError($result); + + $this->terminateWithSuccess($data); + } + + public function loadZeitsperrenMa($days, $uid) + { + if ($uid === null || $uid === '') + { + return $this->terminateWithError($this->p->t('ui', 'error_missingId', ['id'=> 'Mitarbeiter UID']), self::ERROR_TYPE_GENERAL); + } + $result = $this->ZeitsperreModel->getZeitsperrenForNextDays($days, $uid); + + $data = $this->getDataOrTerminateWithError($result); + + $this->terminateWithSuccess($data); + } + + public function getDetailsMa($uid) + { + if ($uid === null || $uid === '') + { + $this->terminateWithError($this->p->t('ui', 'error_missingId', ['id'=> 'Mitarbeiter UID']), self::ERROR_TYPE_GENERAL); + } + $this->load->model('ressource/Person_model', 'PersonModel'); + $result = $this->PersonModel->getFullName($uid); + + $data = $this->getDataOrTerminateWithError($result); + + $this->terminateWithSuccess($data); + } + +} + diff --git a/application/controllers/api/frontend/v1/Noten.php b/application/controllers/api/frontend/v1/Noten.php index 0cabb639f..6a7d0f640 100644 --- a/application/controllers/api/frontend/v1/Noten.php +++ b/application/controllers/api/frontend/v1/Noten.php @@ -22,28 +22,35 @@ use CI3_Events as Events; class Noten extends FHCAPI_Controller { + /** tbl_note by PK, read once per request. @see aktiveNoten() */ + private $aktiveNotenCache = null; /** * Object initialization */ 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, + '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 @@ -65,13 +72,15 @@ class Noten extends FHCAPI_Controller 'person', 'benotungstool', 'lehre', - 'ui' + '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'); @@ -84,13 +93,15 @@ 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->VerlaufLib->getSpecialNotes(); + $NOTEN_OHNE_ANTRITT = $special['ohneAntritt']; + $NOTEN_OCCURANCE_LIMIT_MAP = $special['limitMap']; + $NOTE_ENTSCHULDIGT = $special['entschuldigt']; $this->terminateWithSuccess( array( - // Punkte bei der Noteneingabe anzeigen + // 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 @@ -99,14 +110,13 @@ 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 - '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 - 'CIS_GESAMTNOTE_PRUEFUNG_TERMIN3' => CIS_GESAMTNOTE_PRUEFUNG_TERMIN3, - + // 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(), + + // the default column layout ('antritt' or 'datum'); the user can change it in the tool + 'CIS_GESAMTNOTE_PRUEFUNGSSPALTEN' => $this->config->item('CIS_GESAMTNOTE_PRUEFUNGSSPALTEN'), + // used to toggle availability of kommPruef type pruefungen 'CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF' => CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF, @@ -133,18 +143,148 @@ 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, + + // 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 + * degree programmes they are entitled for, and picks one before a course 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. 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) { + $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 load only the degree programmes of 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' * returns List of all Students of given lehrveranstaltung and semester and fetches their grades. * Loads LvGesamtnote aswell as Teilnoten from externalSources via getExternalGrades Event. * Calculates the Notenvorschlag for every student based on averaging their Teilnoten. - * Finally also fetches all Prüfungen for every student which are linked to lva and semester. + * At the end it also fetches all exams of every student for that course and semester. */ public function getStudentenNoten() { $lv_id = $this->input->get("lv_id",TRUE); @@ -153,13 +293,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) { @@ -191,10 +333,10 @@ class Noten extends FHCAPI_Controller foreach($student_uids as $uid) { $grades[$uid]['grades'] = []; - $result = $this->LvgesamtnoteModel->getLvGesamtNoten($lv_id, $uid, $sem_kurzbz); -// $this->addMeta($uid.'getLvGesamtNoten', $result); - if(!isError($result) && hasData($result)) { - $lvgesamtnote = getData($result)[0]; + // Read without the filter. getLvGesamtNoten() gives released grades only. With that getter + // the course grade and the release state are empty after a reload. + $lvgesamtnote = $this->getLvGesamtnoteRow($lv_id, $uid, $sem_kurzbz); + if($lvgesamtnote !== null) { $grades[$uid]['note_lv'] = $lvgesamtnote->note; $grades[$uid]['freigabedatum'] = $lvgesamtnote->freigabedatum; $grades[$uid]['benotungsdatum'] = $lvgesamtnote->benotungsdatum; @@ -228,10 +370,11 @@ class Noten extends FHCAPI_Controller // assign the anw% to the students in the studentData loop $anwresult = $this->getAnwesenheiten($prestudent_ids, $lv_id, $sem_kurzbz); - // calculate notenvorschläge from teilnoten + // calculate the grade proposals from the partial grades foreach($studentenData as $student) { - $student->anwquote = $anwresult[$student->prestudent_id]; + // 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']; @@ -259,34 +402,55 @@ class Noten extends FHCAPI_Controller } } - if (CIS_GESAMTNOTE_PUNKTE) { - if (defined('CIS_GESAMTNOTE_GEWICHTUNG') && CIS_GESAMTNOTE_GEWICHTUNG) { - // Lehreinheitsgewichtung - $punkte_vorschlag = round($punktesumme_gewichtet / $gewichtsumme, 2); + // 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, 2); $note_vorschlag_result = $this->NotenschluesselaufteilungModel->getNote($punkte_vorschlag, $lv_id, $sem_kurzbz); $note_vorschlag = $this->getDataOrTerminateWithError($note_vorschlag_result); } else { - $punkte_vorschlag = round($punktesumme / $anzahlnoten, 2); - $note_vorschlag_result = $this->NotenschluesselaufteilungModel->getNote($punkte_vorschlag, $lv_id, $sem_kurzbz); - $note_vorschlag = $this->getDataOrTerminateWithError($note_vorschlag_result); - } - } else { - if (defined('CIS_GESAMTNOTE_GEWICHTUNG') && CIS_GESAMTNOTE_GEWICHTUNG) { - $note_vorschlag = round($notensumme_gewichtet / $gewichtsumme); - } else { - $note_vorschlag = round($notensumme / $anzahlnoten); + $note_vorschlag = round(($gewichtet ? $notensumme_gewichtet : $notensumme) / $divisor); } + + $student->note_vorschlag = $note_vorschlag; } - - $student->note_vorschlag = $note_vorschlag; } } - // get all prüfungen with noten held in that semester in that lva + // get all exams with grades of that semester and that course $pruefungen = $this->LePruefungModel->getPruefungenByLvStudiensemester($lv_id, $sem_kurzbz); $pruefungenData = getData($pruefungen); - $this->terminateWithSuccess(array($studentenData, $pruefungenData, DOMAIN, $grades, $anwresult)); + // 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])) { + $grades[$uid]['verlauf'] = $this->verlaufSummary( + $verlauf, false, $this->getLvGesamtnoteRow($lv_id, $uid, $sem_kurzbz) !== null + ); + } + } + + $this->terminateWithSuccess(array($studentenData, $pruefungenAbgeleitet, DOMAIN, $grades, $anwresult)); } /** @@ -320,14 +484,16 @@ class Noten extends FHCAPI_Controller } if(!$this->AuthLib->checkUserAuthByUsernamePassword(getAuthUID(), $result->password)->retval) { - $this->terminateWithError($this->p->t('global', 'wrongPassword'), 'general'); + $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])); @@ -370,7 +536,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 +548,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]; @@ -405,7 +570,22 @@ class Noten extends FHCAPI_Controller $res = $this->LvgesamtnoteModel->load($id->retval); if(hasData($res)) { $lvgesamtnote = getData($res)[0]; - $ret[] = array('uid' => $note->uid, 'freigabedatum' => $lvgesamtnote->freigabedatum, 'benotungsdatum' => $lvgesamtnote->benotungsdatum); + + // 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, + 'verlauf' => $this->buildVerlaufSummary($note->uid, $lv_id, $sem_kurzbz) + ); } } @@ -416,7 +596,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 +673,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); @@ -515,10 +694,9 @@ class Noten extends FHCAPI_Controller * POST METHOD * expects 'datum', 'lva_id', 'student_uid', 'note' * Inserts or updates a pruefung for lva & student_uid at given datum (YYYY-MM-DD). When creating a new - * Pruefung, sets the provided (Prüfungs-) Note. - * Updates the LvGesamtnote of student. - * Can return 1 or 2 Prüfungen, since the original grade before the first prüfung is being saved as "Termin1" when - * a "Termin2" is being created. + * exam, and sets the grade of that exam. + * Updates the course grade of the student. It never writes the transcript grade, the student + * administration does that. */ public function saveStudentPruefung() { // einzelne pruefung speichern $result = $this->getPostJSON(); @@ -538,12 +716,16 @@ class Noten extends FHCAPI_Controller $pruefung_id = property_exists($result, 'pruefung_id') ? $result->pruefung_id : null; $stsem = $result->sem_kurzbz; - $typ = $result->typ; + + $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 && isset($punkte) && $punkte >= 0) { - // Bei Punkteeingabe wird die Note nochmals geprueft und ggf korrigiert + // with a points entry the server checks the grade again and corrects it $resultNote = $this->NotenschluesselaufteilungModel->getNote($punkte, $lva_id, $stsem); if(isError($resultNote)) { $this->terminateWithError(getError($resultNote)); @@ -564,77 +746,67 @@ class Noten extends FHCAPI_Controller $note = getData($result)[0]->note; } - // validate the edit before any write: the date must stay between the neighbouring exams and, - // once a later/higher pruefung exists, the grade may no longer be changed (only the date). - // only applies when editing an existing record ($pruefung_id set) + // 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)); + } + + /** + * The core of one exam entry for ONE student: validate, write the course grade, write the exam. + * The dialog and the bulk endpoints both use this method. The transcript grade stays unchanged. + * + * @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) + { + // §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; + + // validate before any write $editError = $this->validatePruefungEdit($student_uid, $lva_id, $stsem, $note, $datum, $pruefung_id); - if($editError !== null) { - $this->terminateWithError($editError, 'general'); + if($editError !== null) return $editError; + + if($pruefung_id === null || $pruefung_id === '') { + $addError = $this->validatePruefungAdd($student_uid, $lva_id, $stsem, $note, $datum); + if($addError !== null) return $addError; } - - $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); + // the client does not always send it; without it the insert fails on the NOT NULL column + if(!$lehreinheit_id) { + $resLe = $this->LehrveranstaltungModel->getLeByStudent($student_uid, $stsem, $lva_id); + if(!isError($resLe) && hasData($resLe)) $lehreinheit_id = current(getData($resLe))->lehreinheit_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); - } - - $result = $this->LvgesamtnoteModel->getLvGesamtNoten($lva_id, $student_uid, $stsem); + $jetzt = date("Y-m-d H:i:s"); - $origLvNote = null; - $origLvPunkte = null; - $origBenotungsdatum = null; - if(!isError($result) && !hasData($result)) { - - $id = $this->LvgesamtnoteModel->insert( - array( - 'student_uid' => $student_uid, - 'lehrveranstaltung_id' => $lva_id, - 'studiensemester_kurzbz' => $stsem, - 'note' => $note, - 'punkte' => $punkte, - 'mitarbeiter_uid' => getAuthUID(), - 'benotungsdatum' => $jetzt, - 'freigabedatum' => null, - 'freigabevon_uid' => null, - 'bemerkung' => null, - 'updateamum' => null, - 'updatevon' => null, - 'insertamum' => $jetzt, - 'insertvon' => getAuthUID() - ) - ); - if($id) { - $res = $this->LvgesamtnoteModel->load($id->retval); - if(hasData($res)) $lvgesamtnote = getData($res)[0]; - } + // Read without the filter. A grade that is not released still exists. You must update it. + // A new insert breaks the primary key. + $bestehendeLvNote = $this->getLvGesamtnoteRow($lva_id, $student_uid, $stsem); - $this->logLib->logInfoDB(array('saveStudentPruefung insert lvnote', $res, array( - 'insertvon' => getAuthUID(), - 'insertamum' => date('Y-m-d H:i:s') - ), getAuthUID(), getAuthPersonId(), $student_uid, $lva_id, $stsem, $note,$punkte, $jetzt)); + // this decides if the course grade is the implicit first attempt; read it before the update + $origLvNote = $bestehendeLvNote ? $bestehendeLvNote->note : null; + $lvgesamtnote = $bestehendeLvNote; - } - else if(!isError($result) && hasData($result)) - { - $lvgesamtnote = getData($result)[0]; + if($bestehendeLvNote === null) { + $lvgesamtnote = $this->createLvGesamtnote($lva_id, $student_uid, $stsem, $note, $punkte, $lehreinheit_id, $mitarbeiter_uid); - $orig = getData($result)[0]; - $origLvNote = $lvgesamtnote->note; - $origLvPunkte = $lvgesamtnote->punkte; - $origBenotungsdatum = $lvgesamtnote->benotungsdatum; - + $this->logLib->logInfoDB(array('pruefung: lvnote angelegt', $student_uid, $lva_id, $stsem, + $note, $punkte, getAuthUID(), getAuthPersonId())); + } else { $id = $this->LvgesamtnoteModel->update( - [$lvgesamtnote->student_uid, $lvgesamtnote->studiensemester_kurzbz, $lvgesamtnote->lehrveranstaltung_id], + [$bestehendeLvNote->student_uid, $bestehendeLvNote->studiensemester_kurzbz, $bestehendeLvNote->lehrveranstaltung_id], array( 'note' => $note, 'punkte' => $punkte, @@ -648,41 +820,38 @@ class Noten extends FHCAPI_Controller $res = $this->LvgesamtnoteModel->load($id->retval); if(hasData($res)) $lvgesamtnote = getData($res)[0]; } - - $this->logLib->logInfoDB(array('saveStudentPruefung update lvnote', $res, array( - 'updatevon' => getAuthUID(), - 'updateamum' => date('Y-m-d H:i:s') - ), getAuthUID(), getAuthPersonId(), $student_uid, $lva_id, $stsem, $note,$punkte, $jetzt)); + + $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($typ, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum, $origLvNote, $origLvPunkte, $origBenotungsdatum); - $savedPruefung = $pruefungenChanged['savedPruefung'] ?? null; - $extraPruefung = $pruefungenChanged['extraPruefung'] ?? null; + // 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); + if(is_string($pruefungenChanged)) return $pruefungenChanged; - $savedPruefungData = count($savedPruefung) > 0 ? $savedPruefung[0] : null; - $extraPruefungData = count($extraPruefung) > 0 ? $extraPruefung[0] : null; - - $this->terminateWithSuccess(array($savedPruefungData, $lvgesamtnote, $extraPruefungData)); + $pruefungenChanged['lvgesamtnote'] = $lvgesamtnote; + + return $pruefungenChanged; } /** - * private helper method to update/insert pruefungstermine + * Creates an exam or updates an exam. The exam type has no meaning here: the position comes + * from the history. The code writes pruefungstyp_kurzbz, but it never reads it. + * + * @param int $pruefung_id gesetzt = diesen Datensatz bearbeiten, null = neuer Antritt + * @return array|string the changed exams or a translated error message */ - private function savePruefungstermin($typ, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte = '', $datum, $origLvNote = null, $origLvPunkte = null, $origBenotungsdatum = null) + private function savePruefungstermin($pruefung_id, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum, $origLvNote = null, $mitarbeiter_uid = null) { + // no exam without a course grade (a grade that is not released also counts) + if($this->getLvGesamtnoteRow($lva_id, $student_uid, $stsem) === null) { + return $this->p->t('benotungstool', 'c4keineLvNoteEingetragen'); + } - // extra check if the student has lvnote and a zeugnisnote in the relevant lva - $resultLV = $this->LvgesamtnoteModel->getLvGesamtNoten($lva_id, $student_uid, $stsem); - $lvgesamtnoteData = getData($resultLV); -// $this->addMeta('lvgesamtnoteData', $lvgesamtnoteData); - - // allocating pruefungen before lv note is forbidden - if($lvgesamtnoteData == null) return $this->p->t('benotungstool', 'c4keineLvNoteEingetragen'); + $pruefungen = $this->VerlaufLib->getPruefungen($student_uid, $lva_id, $stsem); $status = []; - + // send $grades reference to moodle addon Events::trigger( 'getEntschuldigungsStatusForStudentOnDate', @@ -695,301 +864,175 @@ class Noten extends FHCAPI_Controller 'datum' => $datum ] ); - + 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->VerlaufLib->getSpecialNotes()['entschuldigt']; + + // the limit was checked on the grade BEFORE the override, therefore check it again + if($entschuldigtNote !== null + && !$this->VerlaufLib->ueberschreitetNotenLimit($pruefungen, $entschuldigtNote, $pruefung_id)) { + $note = $entschuldigtNote; + } } - + $jetzt = date("Y-m-d H:i:s"); - + $pruefungenChanged = []; - - if($typ == "Termin2" && defined('CIS_GESAMTNOTE_PRUEFUNG_TERMIN2') && CIS_GESAMTNOTE_PRUEFUNG_TERMIN2) - { - - // Wenn eine Nachprüfung angelegt wird, wird zuerst eine Pruefung mit 1. Termin angelegt welche für die ursprüngliche Note - // vor den Prüfungsantritten zählt - - $result1 = $this->LePruefungModel->getPruefungenByUidTypLvStudiensemester($student_uid, "Termin1", $lva_id, $stsem); - - // if there is a termin 1 entry already do nothing - if(!isError($result1) && hasData($result1)) { - } else if(!isError($result1) && !hasData($result1)) { - // new entry termin1 - - $resultLV = $this->LvgesamtnoteModel->getLvGesamtNoteVorschlag($lva_id, $student_uid, $stsem); - - // update Termin1 note - if ($origLvNote !== null) { - $pr_note = $origLvNote; - $pr_punkte = $origLvPunkte; - $benotungsdatum = $origBenotungsdatum ?? $jetzt; - } - else if (hasData($resultLV)) - { - $lvgesamtnote = getData($resultLV)[0]; - $pr_note = $lvgesamtnote->note; - $pr_punkte = $lvgesamtnote->punkte; - $benotungsdatum = $lvgesamtnote->benotungsdatum; - } - else if(!hasData($resultLV))// set Termin1 note to "noch nicht eingetragen" - { - $this->load->model('education/Note_model', 'NoteModel'); - $result = $this->NoteModel->getNochNichtEingetragenNote(); - $pr_note = getData($result)[0]->note; - $pr_punkte = ''; - $benotungsdatum = $jetzt; - } - - $id = $this->LePruefungModel->insert( - array( - 'lehreinheit_id' => $lehreinheit_id, - 'student_uid' => $student_uid, - 'mitarbeiter_uid' => getAuthUID(), - 'note' => $pr_note, - 'punkte' => $pr_punkte, - 'pruefungstyp_kurzbz' => "Termin1", - 'datum' => $benotungsdatum, - 'anmerkung' => "", - 'insertamum' => $jetzt, - 'insertvon' => getAuthUID(), - 'updateamum' => null, - 'updatevon' => null, - 'ext_id' => null - ) - ); - if($id) { - $res = $this->LePruefungModel->load($id->retval); - if(hasData($res)) $pruefungenChanged['extraPruefung'] = getData($res); - } - - $this->logLib->logInfoDB(array('termin1 created',$res, getAuthUID(), getAuthPersonId())); - + // 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' => "" + ) + ); + if($id) { + $res = $this->LePruefungModel->load($id->retval); + if(hasData($res)) $pruefungenChanged['savedPruefung'] = getData($res); } - + $this->logLib->logInfoDB(array('pruefung updated', $res, getAuthUID(), getAuthPersonId())); - - // Die Pruefung wird als Termin2 eingetragen. - // Ein bestehender "entschuldigt"-Termin2 bleibt erhalten: in diesem Fall wird die neue - // Pruefung als eigener Datensatz angelegt statt den entschuldigten zu ueberschreiben. - $result2 = $this->LePruefungModel->getPruefungenByUidTypLvStudiensemester($student_uid, "Termin2", $lva_id, $stsem); - - $termin2 = null; - if(!isError($result2) && hasData($result2)) { - foreach(getData($result2) as $t2) { - if(!$this->isEntschuldigtNote($t2->note)) { $termin2 = $t2; break; } - } + if(!isset($pruefungenChanged['savedPruefung'])) { + return $this->p->t('benotungstool', 'c4pruefungNichtGespeichert', [$student_uid]); } - if($termin2 !== null) { - // update existing (non-excused) Termin2 - $id = $this->LePruefungModel->update( - $termin2->pruefung_id, - array( - 'updateamum' => $jetzt, - 'updatevon' => getAuthUID(), - 'note' => $note, - 'punkte' => $punkte, - 'datum' => $datum, - 'anmerkung' => "" - ) - ); - if($id) { - $res = $this->LePruefungModel->load($id->retval); - if(hasData($res)) $pruefungenChanged['savedPruefung'] = getData($res); - } - - $this->logLib->logInfoDB(array('termin2 updated',$res, getAuthUID(), getAuthPersonId())); - - - } else if(!isError($result2)) { - // no editable Termin2 (none yet, or only an entschuldigt one) -> insert new, excused stays - - $id = $this->LePruefungModel->insert( - array( - 'lehreinheit_id' => $lehreinheit_id, - 'student_uid' => $student_uid, - 'mitarbeiter_uid' => getAuthUID(), - 'note' => $note, - 'punkte' => $punkte, - 'pruefungstyp_kurzbz' => $typ, - 'datum' => $datum, - 'anmerkung' => "", - 'insertamum' => $jetzt, - 'insertvon' => getAuthUID(), - 'updateamum' => null, - 'updatevon' => null, - 'ext_id' => null - ) - ); - if($id) { - $res = $this->LePruefungModel->load($id->retval); - if(hasData($res)) $pruefungenChanged['savedPruefung'] = getData($res); - } - - $this->logLib->logInfoDB(array('termin2 inserted',$res, getAuthUID(), getAuthPersonId())); - - } - - } else if($typ == "Termin3" && defined('CIS_GESAMTNOTE_PRUEFUNG_TERMIN3') && CIS_GESAMTNOTE_PRUEFUNG_TERMIN3) - { - // same entschuldigt-preservation handling as Termin2 - $result3 = $this->LePruefungModel->getPruefungenByUidTypLvStudiensemester($student_uid, "Termin3", $lva_id, $stsem); - - $termin3 = null; - if(!isError($result3) && hasData($result3)) { - foreach(getData($result3) as $t3) { - if(!$this->isEntschuldigtNote($t3->note)) { $termin3 = $t3; break; } - } - } - - if($termin3 !== null) { - // update existing (non-excused) Termin3 - $id = $this->LePruefungModel->update( - $termin3->pruefung_id, - array( - 'updateamum' => $jetzt, - 'updatevon' => getAuthUID(), - 'note' => $note, - 'punkte' => $punkte, - 'datum' => $datum, - 'anmerkung' => "" - ) - ); - if($id) { - $res = $this->LePruefungModel->load($id->retval); - if(hasData($res)) $pruefungenChanged['savedPruefung'] = getData($res); - } - - $this->logLib->logInfoDB(array('termin3 updated',$res, getAuthUID(), getAuthPersonId())); - - } else if(!isError($result3)) { - // no editable Termin3 (none yet, or only an entschuldigt one) -> insert new, excused stays - - $id = $this->LePruefungModel->insert( - array( - 'lehreinheit_id' => $lehreinheit_id, - 'student_uid' => $student_uid, - 'mitarbeiter_uid' => getAuthUID(), - 'note' => $note, - 'punkte' => $punkte, - 'pruefungstyp_kurzbz' => $typ, - 'datum' => $datum, - 'anmerkung' => "", - 'insertamum' => $jetzt, - 'insertvon' => getAuthUID(), - 'updateamum' => null, - 'updatevon' => null, - 'ext_id' => null - ) - ); - if($id) { - $res = $this->LePruefungModel->load($id->retval); - if(hasData($res)) $pruefungenChanged['savedPruefung'] = getData($res); - } - - $this->logLib->logInfoDB(array('termin3 inserted',$res, getAuthUID(), getAuthPersonId())); - - } - } else { - $this->terminateWithError($this->p->t('benotungstool', 'wrongPruefungType', [$student_uid, $typ]), 'general'); + $pruefungenChanged['verlauf'] = $this->buildVerlaufSummary($student_uid, $lva_id, $stsem); + 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 + ) + ); + 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); return $pruefungenChanged; } - /** - * ranking of the pruefung attempt types, used to detect whether a "höhere" - * (later attempt) pruefung exists for a student - */ - private function pruefungAttemptRank($typ) + /** The history for the client. Each write answer contains it, so the client calculates nothing. */ + private function buildVerlaufSummary($student_uid, $lva_id, $stsem) { - switch($typ) { - case 'Termin1': return 1; - case 'Termin2': return 2; - case 'Termin3': return 3; - case 'kommPruef': return 4; - default: return 0; - } + $lvNote = $this->getLvGesamtnoteRow($lva_id, $student_uid, $stsem); + $verlauf = $this->VerlaufLib->getVerlauf( + $student_uid, $lva_id, $stsem, + $lvNote ? $lvNote->note : null, + $this->getZeugnisnote($lva_id, $student_uid, $stsem) + ); + + return $this->verlaufSummary($verlauf, true, $lvNote !== null); } /** - * whether a note is the configured 'entschuldigt' note. Such Termine are preserved as their - * own dated entry instead of being overwritten when a new pruefung of the same type is created. + * @param bool $withPruefungen add the exams (for the write paths) or send the counters only + * @return array */ - private function isEntschuldigtNote($note) + private function verlaufSummary($verlauf, $withPruefungen = false, $hatLvNote = null) { - $entschuldigt = $this->config->item('NOTE_ENTSCHULDIGT'); - return $entschuldigt !== null && $note == $entschuldigt; + $summary = array( + 'antrittCount' => $verlauf->antrittCount, + 'maxAntritte' => $verlauf->maxAntritte, + 'canAdd' => $verlauf->canAdd, + 'terminal' => $verlauf->terminal, + 'erstantrittMoeglich' => $verlauf->erstantrittMoeglich, + 'naechsteRolle' => $verlauf->naechsteRolle, + // 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; } /** - * Validates an edit to an existing Termin2/Termin3 pruefung. Returns a localized error - * string if the edit is not allowed, or null if it is. Mirrors the frontend guards so a - * disallowed edit cannot be forced through the API. + * Guards an EDIT ($pruefung_id is set; if not, validatePruefungAdd applies). The date must stay + * between the two adjacent exams. An exam with a later date locks the grade. * - * Rules: - * - The new $datum must stay strictly between the dates of the chronologically adjacent - * pruefungen so the attempt order is preserved. - * - Once a later-dated or higher-attempt pruefung already exists the grade may no longer be - * changed (only the datum may be corrected within the bounds above). - * - * Only guards EDITS: $pruefung_id identifies the record being edited; when it is null (a new - * attempt is being added) nothing is restricted here (adds are validated client-side). - * - * @param string $student_uid - * @param int $lva_id - * @param string $stsem - * @param int $newNote the (already resolved) note being saved - * @param string $newDatum the datum being saved (Y-m-d) - * @param int $pruefung_id pk of the record being edited, or null for an add - * @return string|null + * @return string|null lokalisierte Fehlermeldung oder null */ private function validatePruefungEdit($student_uid, $lva_id, $stsem, $newNote, $newDatum, $pruefung_id) { if($pruefung_id === null || $pruefung_id === '') return null; // add, not an edit - $result = $this->LePruefungModel->getPruefungenByUidTypLvStudiensemester($student_uid, null, $lva_id, $stsem); - if(isError($result) || !hasData($result)) return null; + $zeugnisNote = $this->getZeugnisnote($lva_id, $student_uid, $stsem); + if($this->VerlaufLib->istAnrechnungsnote($zeugnisNote)) { + return $this->p->t('benotungstool', 'c4angerechnetKeinePruefung', [$student_uid]); + } - $pruefungen = getData($result); + $pruefungen = $this->VerlaufLib->getPruefungen($student_uid, $lva_id, $stsem); + if(count($pruefungen) === 0) return null; + + $verlauf = $this->VerlaufLib->buildVerlauf($pruefungen, null, $zeugnisNote); // the record being edited $current = null; - foreach($pruefungen as $p) { + foreach($verlauf->pruefungen as $p) { if($p->pruefung_id == $pruefung_id) { $current = $p; break; } } if($current === null) return null; - // only the changeable resit grades are guarded - if($current->pruefungstyp_kurzbz !== 'Termin2' && $current->pruefungstyp_kurzbz !== 'Termin3') return null; + // The student administration owns the exams that use no attempt (zusKommPruef), so this tool + // does not validate them. The kommissionelle Prüfung itself is a normal attempt now. + if(in_array($current->pruefungstyp_kurzbz, $this->VerlaufLib->getTypenOhneAntritt())) return null; - $currentRank = $this->pruefungAttemptRank($current->pruefungstyp_kurzbz); $currentDate = substr((string)$current->datum, 0, 10); $new = substr((string)$newDatum, 0, 10); - // chronological bounds from the immediate date-neighbours + detect later/higher pruefung - $lower = null; $upper = null; $hasLaterOrHigher = false; - foreach($pruefungen as $p) { + // the limits come from the two exams before and after this one + $lower = null; $upper = null; + foreach($verlauf->pruefungen as $p) { if($p->pruefung_id == $current->pruefung_id) continue; $d = substr((string)$p->datum, 0, 10); - if($d !== '') { - if($d < $currentDate) { if($lower === null || $d > $lower) $lower = $d; } - elseif($d > $currentDate) { if($upper === null || $d < $upper) $upper = $d; } - } + if($d === '') continue; - if($d > $currentDate || $this->pruefungAttemptRank($p->pruefungstyp_kurzbz) > $currentRank) { - $hasLaterOrHigher = true; - } + if($d < $currentDate) { if($lower === null || $d > $lower) $lower = $d; } + elseif($d > $currentDate) { if($upper === null || $d < $upper) $upper = $d; } } - // grade is locked once a later/higher pruefung exists - if($hasLaterOrHigher && $newNote != $current->note) { + // grade is locked once a later attempt exists + if($this->VerlaufLib->hatSpaeterenTermin($verlauf, $current->pruefung_id) && $newNote != $current->note) { return $this->p->t('benotungstool', 'pruefungNoteLocked', [$student_uid]); } @@ -998,9 +1041,173 @@ 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->VerlaufLib->ueberschreitetNotenLimit($verlauf->pruefungen, $newNote, $current->pruefung_id)) { + return $this->p->t('benotungstool', 'noteOccuranceLimitReached', [$student_uid]); + } + return null; } + /** + * Guards a NEW attempt. Rule A is the attempt limit, and nothing follows a final exam. Rule B + * is the chronological order. Rule C is the occurrence limit of a grade. + * + * @return string|null lokalisierte Fehlermeldung oder null + */ + private function validatePruefungAdd($student_uid, $lva_id, $stsem, $note, $datum) + { + $zeugnisNote = $this->getZeugnisnote($lva_id, $student_uid, $stsem); + if($this->VerlaufLib->istAnrechnungsnote($zeugnisNote)) { + return $this->p->t('benotungstool', 'c4angerechnetKeinePruefung', [$student_uid]); + } + + $pruefungen = $this->VerlaufLib->getPruefungen($student_uid, $lva_id, $stsem); + + // the implicit first attempt; read it before the caller changes it + $lvRow = $this->getLvGesamtnoteRow($lva_id, $student_uid, $stsem); + $lvNote = $lvRow ? $lvRow->note : null; + + $verlauf = $this->VerlaufLib->buildVerlauf($pruefungen, $lvNote, $zeugnisNote); + + // A: the first attempt only materialises the course grade, therefore it adds no attempt + if($verlauf->naechsteRolle !== PruefungsverlaufLib::ROLLE_ERSTANTRITT && !$verlauf->canAdd) { + return $this->p->t('benotungstool', 'maxAntritteReached', [$student_uid, $verlauf->maxAntritte]); + } + + // B: no attempt on the same day as an existing exam, and no attempt before it + $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]); + } + } + + // C: the occurrence limit, for example one 'entschuldigt' only + if($this->VerlaufLib->ueberschreitetNotenLimit($pruefungen, $note, null)) { + return $this->p->t('benotungstool', 'noteOccuranceLimitReached', [$student_uid]); + } + + return null; + } + + /** + * The active grades, keyed by their PK. getNoten() sends the client the same set, so both sides + * judge a grade by the same flags. + * + * @return array note (string) => zeile aus lehre.tbl_note + */ + private function aktiveNoten() + { + if($this->aktiveNotenCache !== null) return $this->aktiveNotenCache; + + $this->aktiveNotenCache = array(); + + $result = $this->NoteModel->getAllActive(); + if(!isError($result) && hasData($result)) { + foreach(getData($result) as $n) $this->aktiveNotenCache[(string)$n->note] = $n; + } + + return $this->aktiveNotenCache; + } + + /** + * Guards a DIRECT api write of the course grade (saveNotenvorschlag and its bulk variant). All + * three rules are also in the cell editor, but the API and the CSV import reach the server + * without it. The order follows the editor: grade, exam, transcript. + * + * @return string|null lokalisierte Fehlermeldung oder null + */ + private function validateNotenvorschlag($lva_id, $student_uid, $stsem, $note) + { + $noten = $this->aktiveNoten(); + $wert = trim((string)$note); + + // the editor offers the lehre grades only; an administrative grade belongs to the transcript + if(!isset($noten[$wert]) || !$noten[$wert]->lehre) { + return $this->p->t('benotungstool', 'c4noteNichtInLehre', [$student_uid]); + } + + // as soon as an exam exists the grade belongs to the attempt history and follows its rules + if(count($this->VerlaufLib->getPruefungen($student_uid, $lva_id, $stsem)) > 0) { + return $this->p->t('benotungstool', 'c4notenvorschlagGesperrt', [$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; + } + + /** + * Checks the DATE of the exam against the deadline. enforceNoteneintragungsfrist compares the + * current time, which is a different question: a user can enter an exam today and give it a + * date after the deadline. + * + * @return string|null a translated error message, or null if the date is permitted + */ + private function pruefungsdatumNachFrist($sem_kurzbz, $datum, $student_uid) + { + if(!$this->config->item('CIS_GESAMTNOTE_NOTENEINTRAGUNGSFRIST')) 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')]); + } + + /** Stops the request if the grade entry deadline of the semester has passed. */ + 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' + ); + } + } + + /** 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') { + $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 +1227,24 @@ class Noten extends FHCAPI_Controller $sem_kurzbz = $result->sem_kurzbz; $note = $result->note; $punkte = $result->punkte; - + + $this->assertLvAccess($lv_id, $sem_kurzbz); + + // examination rules: no entry and no change after the grade entry deadline + $this->enforceNoteneintragungsfrist($sem_kurzbz); + + // 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'); + $result = $this->LvgesamtnoteModel->getLvGesamtNoteVorschlag($lv_id, $student_uid, $sem_kurzbz); // $this->addMeta('LvgesamtnoteModelresult', $result); @@ -1054,7 +1278,7 @@ class Noten extends FHCAPI_Controller 'studiensemester_kurzbz' => $sem_kurzbz, 'note' => $note, 'punkte' => $punkte, - 'mitarbeiter_uid' => getAuthUID(), + 'mitarbeiter_uid' => $this->benotenderMitarbeiterFuerStudent($lv_id, $student_uid, $sem_kurzbz), 'benotungsdatum' => date("Y-m-d H:i:s"), 'freigabedatum' => null, 'freigabevon_uid' => null, @@ -1080,6 +1304,7 @@ class Noten extends FHCAPI_Controller * POST METHOD * expects 'sem_kurzbz', 'lv_id', 'noten' * Bulk variant of saveNotenvorschlag, used when importing grades from csv. + * The answer uses the uid as the key. Each row holds the course grade or an error message. */ public function saveNotenvorschlagBulk() { $result = $this->getPostJSON(); @@ -1092,7 +1317,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); + + // examination rules: no entry and no change after the grade entry deadline + $this->enforceNoteneintragungsfrist($sem_kurzbz); + $retLvNoten = []; foreach($noten as $note) @@ -1102,11 +1332,16 @@ class Noten extends FHCAPI_Controller // $this->addMeta($note->uid.'$result', $result); if(CIS_GESAMTNOTE_PUNKTE) { - $resultNote = $this->NotenschluesselaufteilungModel->getNote($note->punkte, $lv_id, $sem_kurzbz); - $note->note = $this->getDataOrTerminateWithError($resultNote); -// $this->addMeta($note->uid.'note', $note); + $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; 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; continue; } + if(!isError($result) && hasData($result)) { $lvgesamtnote = getData($result)[0]; @@ -1136,7 +1371,7 @@ class Noten extends FHCAPI_Controller 'studiensemester_kurzbz' => $sem_kurzbz, 'note' => trim($note->note), 'punkte' => $note->punkte, - 'mitarbeiter_uid' => getAuthUID(), + 'mitarbeiter_uid' => $this->benotenderMitarbeiterFuerStudent($lv_id, $note->uid, $sem_kurzbz), 'benotungsdatum' => date("Y-m-d H:i:s"), 'freigabedatum' => null, 'freigabevon_uid' => null, @@ -1155,7 +1390,7 @@ class Noten extends FHCAPI_Controller $this->logLib->logInfoDB(array('saveNotenvorschlagBulk insert lv gesamtnote',$res, getAuthUID(), getAuthPersonId())); } - $retLvNoten[] = $lvgesamtnote; + $retLvNoten[$note->uid] = $lvgesamtnote; } $this->terminateWithSuccess($retLvNoten); @@ -1163,36 +1398,55 @@ class Noten extends FHCAPI_Controller /** * POST METHOD - * expects 'uids', 'datum' - * Bulk variant of saveStudentPruefung, used when creating a new Prüfung for several students. Always sets note to - * "noch nicht eingetragen" for the created Prüfung. + * expects 'uids', 'datum', optional 'note'/'punkte' + * The bulk variant of saveStudentPruefung. It creates one exam for several students. + * Without a selected grade it writes "noch nicht eingetragen". */ public function createPruefungen() { - $result = $this->getPostJSON(); + $payload = $this->getPostJSON(); - if(!property_exists($result, 'uids') || !property_exists($result, 'datum')) { + if(!property_exists($payload, 'uids') || !property_exists($payload, 'datum')) { $this->terminateWithError($this->p->t('global', 'missingParameters'), 'general'); } - $uids = $result->uids; - $datum = $result->datum; - $lva_id = $result->lva_id; - - $stsem = $result->sem_kurzbz; - + $uids = $payload->uids; + $datum = $payload->datum; + $lva_id = $payload->lva_id; + + $stsem = $payload->sem_kurzbz; + + $this->assertLvAccess($lva_id, $stsem); + + // examination rules: no entry after the grade entry deadline + $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, + $note = property_exists($payload, 'note') ? $payload->note : null; + $punkte = property_exists($payload, 'punkte') ? $payload->punkte : null; - $lehreinheit_id = $student->lehreinheit_id; - $ret[$student->uid] = $this->savePruefungstermin($typ, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum); + // the points win: the grade then comes from the grading scale + if(CIS_GESAMTNOTE_PUNKTE && $punkte !== null && $punkte !== '' && $punkte >= 0) { + $resNote = $this->NotenschluesselaufteilungModel->getNote($punkte, $lva_id, $stsem); + $note = $this->getDataOrTerminateWithError($resNote); + } + + // without a selection the exam has no grade + if($note === null || $note === '') { + $resNote = $this->NoteModel->getNochNichtEingetragenNote(); + $note = getData($resNote)[0]->note; + $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) { + $ret[$student->uid] = $this->savePruefungFuerStudent( + null, $student->uid, $lva_id, $stsem, $student->lehreinheit_id, $note, $punkte, $datum, + $mitarbeiter_uid + ); } $this->logLib->logInfoDB(array('createPruefungen',$ret, getAuthUID(), getAuthPersonId())); @@ -1216,26 +1470,29 @@ class Noten extends FHCAPI_Controller $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) { - $result = $this->NotenschluesselaufteilungModel->getNote($pruefung->punkte, $lv_id, $sem_kurzbz); -// $this->addMeta($pruefung->uid."result", $result); - $pruefung->note = $this->getDataOrTerminateWithError($result); -// $this->addMeta($pruefung->uid."note", $pruefung->note); - } - - $student_uid = $pruefung->uid; - $typ = $pruefung->typ; - $note = $pruefung->note; // TODO: parameterize for import maybe - $datum = $pruefung->datum; - $punkte = $pruefung->punkte; - $lehreinheit_id = $pruefung->lehreinheit_id; - $ret[$student_uid] = $this->savePruefungstermin($typ, $student_uid, $lv_id, $sem_kurzbz, $lehreinheit_id, $note, $punkte, $datum); + 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; 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())); @@ -1243,6 +1500,209 @@ class Noten extends FHCAPI_Controller $this->terminateWithSuccess($ret); } + /** + * Creates or updates the first attempt. The release calls it. + * + * The date is the benotungsdatum of the course grade, which is the moment the teacher recorded + * the assessment. The examination rules ask for the date of the last performance (§8), but the + * system does not hold that date, because many courses have no exam date at all. The old tool + * used the benotungsdatum for the same row, so the value stays comparable with the old data. + * + * It applies only while one exam or no exam exists. After that the course grade holds the last + * grade, and it must not overwrite attempt 1. A credited grade changes nothing. + */ + /** + * The grade from the points for ONE bulk row. It gives the grade, or a translated error message + * if no grade can be derived. The caller then skips the row and continues with the other rows. + * + * @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; + } + + private function upsertErstantritt($lva_id, $student_uid, $stsem, $note, $punkte, $datum) + { + if($this->VerlaufLib->istAnrechnungsnote($this->getZeugnisnote($lva_id, $student_uid, $stsem))) return; + + $pruefungen = $this->VerlaufLib->getPruefungen($student_uid, $lva_id, $stsem); + if(count($pruefungen) > 1) return; + + $jetzt = date("Y-m-d H:i:s"); + + if(count($pruefungen) === 1) { + $this->LePruefungModel->update( + $pruefungen[0]->pruefung_id, + array( + 'note' => $note, + 'punkte' => $punkte, + 'datum' => $datum, + 'updateamum' => $jetzt, + 'updatevon' => getAuthUID() + ) + ); + + $this->logLib->logInfoDB(array('erstantritt aktualisiert (freigabe)', $student_uid, getAuthUID(), getAuthPersonId())); + return; + } + + // the server finds the lehreinheit_id; it does not use the value from the client + $resLe = $this->LehrveranstaltungModel->getLeByStudent($student_uid, $stsem, $lva_id); + if(isError($resLe) || !hasData($resLe)) return; + $le = current(getData($resLe)); + + $this->LePruefungModel->insert( + array( + 'lehreinheit_id' => $le->lehreinheit_id, + 'student_uid' => $student_uid, + 'mitarbeiter_uid' => $this->benotenderMitarbeiter($le->lehreinheit_id), + 'note' => $note, + 'punkte' => $punkte, + 'pruefungstyp_kurzbz' => $this->VerlaufLib->legacyTypFuerAntritt(1), + 'datum' => $datum, + 'anmerkung' => "", + 'insertamum' => $jetzt, + 'insertvon' => getAuthUID(), + 'updateamum' => null, + 'updatevon' => null, + 'ext_id' => null + ) + ); + + $this->logLib->logInfoDB(array('erstantritt angelegt (freigabe)', $student_uid, getAuthUID(), getAuthPersonId())); + } + + /** + * The teachers of one Lehreinheit, from lehre.tbl_lehreinheitmitarbeiter. + * + * @return array + */ + private function lehrendeDerLehreinheit($lehreinheit_id) + { + if(!$lehreinheit_id) return 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); + }); + + return $lehrende; + } + + /** + * The uid of the person who gave the grade. It goes into lehre.tbl_pruefung and into + * campus.tbl_lvgesamtnote. + * + * Historically only teachers used this tool, so the caller was always right. An assistant may + * do the data entry too, but the grade still comes from the teacher. The row must therefore + * carry the teacher of the Lehreinheit, not the person who types. insertvon and updatevon keep + * the caller, because they answer a different question. + * + * Order: a valid selection wins, then the caller if the caller teaches this Lehreinheit, then + * the only teacher. With several teachers and no selection the first one wins, because an + * assistant is never the correct answer. Without any teacher the caller stays. + * + * @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]; + } + + /** + * The same answer for a course grade, which has no Lehreinheit of its own. The Lehreinheit of + * the student 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); + } + + /** 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) + { + $this->load->model('education/Zeugnisnote_model', 'ZeugnisnoteModel'); + + $res = $this->ZeugnisnoteModel->load([ + 'studiensemester_kurzbz' => $stsem, + 'student_uid' => $student_uid, + 'lehrveranstaltung_id' => $lva_id + ]); + + return (!isError($res) && hasData($res)) ? getData($res)[0]->note : null; + } + + /** + * 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; + } + + /** 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; + } + private function getAnwesenheiten($prestudent_ids, $lv_id, $sem_kurzbz) { $anwesenheiten = []; @@ -1269,6 +1729,35 @@ class Noten extends FHCAPI_Controller } + /** + * GET METHOD + * expects 'lehreinheit_id', 'lv_id', 'sem_kurzbz' + * The teachers of one Lehreinheit. The dialog needs them when the Lehreinheit has more than one, + * because the exam must carry the teacher and not the person who types. + */ + 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(!$lehreinheit_id || !$lv_id || !$sem_kurzbz) { + $this->terminateWithError($this->p->t('global', 'missingParameters'), 'general'); + } + + $this->assertLvAccess($lv_id, $sem_kurzbz); + + $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); + } + public function getNoteByPunkte() { $result = $this->getPostJSON(); diff --git a/application/controllers/api/frontend/v1/Ort.php b/application/controllers/api/frontend/v1/Ort.php index 0d8074fa9..a208e4c0d 100644 --- a/application/controllers/api/frontend/v1/Ort.php +++ b/application/controllers/api/frontend/v1/Ort.php @@ -36,7 +36,8 @@ class Ort extends FHCAPI_Controller 'ContentID' => self::PERM_LOGGED, 'getOrtKurzbzContent' => self::PERM_LOGGED, 'getRooms' => self::PERM_LOGGED, - 'getTypes' => self::PERM_LOGGED + 'getTypes' => self::PERM_LOGGED, + 'getRoomInfo' => self::PERM_LOGGED ]); $this->load->model('ressource/Ort_model', 'OrtModel'); @@ -74,6 +75,8 @@ class Ort extends FHCAPI_Controller $vonStunde = getData($this->StundeModel->getStundeForTime($von))[0]->stunde; $bisStunde = getData($this->StundeModel->getStundeForTime($bis))[0]->stunde; + $this->load->model('ressource/Ort_model', 'OrtModel'); + $params = array(); $qry = "SELECT DISTINCT tbl_ort.* FROM public.tbl_ort JOIN public.tbl_ortraumtyp USING(ort_kurzbz) @@ -174,5 +177,16 @@ class Ort extends FHCAPI_Controller $this->terminateWithSuccess($content); } + + public function getRoomInfo() + { + $ortKurzbz = $this->input->get("ortKurzbz"); + + $roomResult = $this->OrtModel->getRoomInfo($ortKurzbz); + $roomData = $this->getDataOrTerminateWithError($roomResult); + $room = count($roomData) ? $roomData[0] : null; + + $this->terminateWithSuccess($room); + } } diff --git a/application/controllers/api/frontend/v1/OtherLvPlan.php b/application/controllers/api/frontend/v1/OtherLvPlan.php index af075fbc3..1807f0a4a 100644 --- a/application/controllers/api/frontend/v1/OtherLvPlan.php +++ b/application/controllers/api/frontend/v1/OtherLvPlan.php @@ -62,6 +62,8 @@ class OtherLvPlan extends FHCAPI_Controller "foto" => $profileData->data->foto, "vorname" => $profileData->data->vorname, "nachname" => $profileData->data->nachname, + "titel" => $profileData->data->titel, + "postnomen" => $profileData->data->postnomen, ], ]; diff --git a/application/controllers/api/frontend/v1/StudstatusAntraege.php b/application/controllers/api/frontend/v1/StudstatusAntraege.php new file mode 100644 index 000000000..44dc4134d --- /dev/null +++ b/application/controllers/api/frontend/v1/StudstatusAntraege.php @@ -0,0 +1,145 @@ +. + */ + +if (! defined('BASEPATH')) exit('No direct script access allowed'); + +class StudstatusAntraege extends FHCAPI_Controller +{ + + /** + * Object initialization + */ + public function __construct() + { + parent::__construct([ + 'getTodos' => self::PERM_LOGGED + ]); + + //load models + $this->load->model('education/Studierendenantrag_model', 'StudierendenantragModel'); + $this->load->model('person/Benutzerfunktion_model', 'BenutzerfunktionModel'); + + } + + //------------------------------------------------------------------------------------------------------------------ + // Public methods + + /** + * gets all todos of antraegen of the user with stg leitungsfunction + * or list of open antraege for user with assistance function + * @access public + * @return array || [] + */ + public function getTodos() + { + $uid = getAuthUID(); + + //at first get studiengang with leitungsfunktion + $result = $this->BenutzerfunktionModel->getSTGLByUID($uid); + + if(hasData($result)) + { + $funktionen = getData($result); + + $studiengaenge = []; + + foreach ($funktionen as $funktion) { + $studiengaenge[] = $funktion->studiengang_kz; + } + + $dataAntrage = []; + + $result = $this->StudierendenantragModel->getOpenAntraegeForStgl($studiengaenge); + + if (isError($result)) { + $this->terminateWithError(getError($result), self::ERROR_TYPE_GENERAL); + } + $dataAntrage[] = getData($result) ?: []; + $statusRole = 1; + + $this->terminateWithSuccess([count($dataAntrage) > 0 ? $dataAntrage[0] : $dataAntrage, $statusRole]); + + } + + //TODO delete after check if needed or not + //get studiengaenge of assistance (regarding benutzerfunktion) + //$result = $this->BenutzerfunktionModel->getSTGAssByUID($uid); + /* if(hasData($result)) { + $statusRole = 2; + $funktionen = getData($result); + + $studiengaenge = []; + + foreach ($funktionen as $funktion) { + $studiengaenge[] = $funktion->studiengang_kz; + } + + $ci->addMeta('stgAss', $studiengaenge); + $dataAntrage = []; + + foreach ($studiengaenge as $studiengang) + { + $result = $this->StudierendenantragModel->getOpenAntraegeForAss($studiengang); + + if (isError($result)) { + $this->terminateWithError(getError($result), self::ERROR_TYPE_GENERAL); + } + if(hasData($result)) { + $dataAntrage[] = getData($result); + $statusRole = 2; + } + } + } + else + { + $statusRole = 0; + $dataAntrage = []; + //$this->terminateWithSuccess([[], $statusRole]); + }*/ + + //get studiengaenge of assistance (regarding rights) + $stgAss = $this->permissionlib->getSTG_isEntitledFor('student/studierendenantrag'); + if(!is_array($stgAss)) + { + $statusRole = 0; + $this->terminateWithSuccess([[], $statusRole]); + } + else { + $statusRole = 2; + $studiengaenge = []; + + foreach ($stgAss as $stg) { + $studiengaenge[] = (int) $stg; + } + + $dataAntrage = []; + $result = $this->StudierendenantragModel->getOpenAntraegeForAss($studiengaenge); + + if (isError($result)) { + $this->terminateWithError(getError($result), self::ERROR_TYPE_GENERAL); + } + if(hasData($result)) { + $dataAntrage[] = getData($result); + $statusRole = 2; + } + + $this->terminateWithSuccess([count($dataAntrage) > 0 ? $dataAntrage[0] : $dataAntrage, $statusRole]); + + } + } +} \ No newline at end of file diff --git a/application/controllers/api/frontend/v1/Zeitsperren.php b/application/controllers/api/frontend/v1/Zeitsperren.php index 66efa81a7..d2698d205 100644 --- a/application/controllers/api/frontend/v1/Zeitsperren.php +++ b/application/controllers/api/frontend/v1/Zeitsperren.php @@ -58,8 +58,8 @@ class Zeitsperren extends FHCAPI_Controller public function getTypenZeitsperren() { - $this->ZeitsperretypModel->addOrder('beschreibung', 'ASC'); - $result = $this->ZeitsperretypModel->load(); + $excludedTypes = array('ZVerfueg'); + $result = $this->ZeitsperretypModel->getTypenZeitsperren($excludedTypes); if (isError($result)) { $this->terminateWithError(getError($result), self::ERROR_TYPE_GENERAL); } diff --git a/application/controllers/api/frontend/v1/calendar/RoomPlan.php b/application/controllers/api/frontend/v1/calendar/RoomPlan.php index 489d83b53..522478f35 100644 --- a/application/controllers/api/frontend/v1/calendar/RoomPlan.php +++ b/application/controllers/api/frontend/v1/calendar/RoomPlan.php @@ -16,7 +16,8 @@ * along with this program. If not, see . */ -if (! defined('BASEPATH')) exit('No direct script access allowed'); +if (!defined('BASEPATH')) + exit('No direct script access allowed'); class RoomPlan extends FHCAPI_Controller { @@ -33,6 +34,7 @@ class RoomPlan extends FHCAPI_Controller 'getGruppen' => self::PERM_LOGGED, 'getLektor' => self::PERM_LOGGED, 'getReservableMap' => self::PERM_LOGGED, + 'getGroupOptions' => self::PERM_LOGGED, ]); $this->load->library('LogLib'); @@ -149,16 +151,14 @@ class RoomPlan extends FHCAPI_Controller $this->GruppeModel->addOrder('gruppe_kurzbz'); $this->GruppeModel->db->group_start(); - foreach ($query_words as $word) - { + foreach ($query_words as $word) { $this->GruppeModel->db->group_start(); $this->GruppeModel->db->where('gruppe_kurzbz ILIKE', "%" . $word . "%"); $this->GruppeModel->db->or_where('bezeichnung ILIKE', "%" . $word . "%"); $this->GruppeModel->db->or_where('beschreibung ILIKE', "%" . $word . "%"); $this->GruppeModel->db->or_where('orgform_kurzbz ILIKE', "%" . $word . "%"); - if (is_numeric($word)) - { + if (is_numeric($word)) { $this->GruppeModel->db->or_where('studiengang_kz', $word); } $this->GruppeModel->db->group_end(); @@ -193,8 +193,7 @@ class RoomPlan extends FHCAPI_Controller $this->MitarbeiterModel->addJoin('public.tbl_person', 'person_id'); $this->MitarbeiterModel->db->where('public.tbl_benutzer.aktiv', true); $this->MitarbeiterModel->db->group_start(); - foreach ($query_words as $word) - { + foreach ($query_words as $word) { $this->MitarbeiterModel->db->group_start(); $this->MitarbeiterModel->db->where('tbl_person.vorname ILIKE', "%" . $word . "%"); $this->MitarbeiterModel->db->or_where('tbl_person.nachname ILIKE', "%" . $word . "%"); @@ -229,4 +228,49 @@ class RoomPlan extends FHCAPI_Controller $this->terminateWithSuccess(array('reservierbarMap' => hasData($result) ? getData($result) : [])); } + public function getGroupOptions() + { + $studiengangKz = $this->input->get('studiengangKz'); + + $this->load->model('organisation/Lehrverband_model', 'LehrverbandModel'); + $this->LehrverbandModel->addSelect('semester, verband, gruppe'); + $this->LehrverbandModel->db->where('aktiv', true); + $this->LehrverbandModel->db->where('studiengang_kz', $studiengangKz); + $lehrverbandResult = $this->LehrverbandModel->load(); + + if (isError($lehrverbandResult)) + $this->terminateWithError($lehrverbandResult); + + $lehrverbanden = hasData($lehrverbandResult) ? getData($lehrverbandResult) : []; + + $grouppedValues = []; + foreach ($lehrverbanden as $lehrverband) { + if (isset($grouppedValues[$lehrverband->semester]) && $lehrverband->verband !== ' ') { + $grouppedValues[$lehrverband->semester][] = $lehrverband; + } else if (!isset($grouppedValues[$lehrverband->semester]) && $lehrverband->verband !== ' ') { + $grouppedValues[$lehrverband->semester] = [$lehrverband]; + } else if (!isset($grouppedValues[$lehrverband->semester])) { + $grouppedValues[$lehrverband->semester] = []; + } + } + $grouppedValues = array_map( + function ($semester) { + $verbandGruppen = []; + foreach ($semester as $lehrverband) { + if (isset($verbandGruppen[$lehrverband->verband]) && $lehrverband->gruppe !== ' ') { + $verbandGruppen[$lehrverband->verband][] = $lehrverband->gruppe; + } else if (!isset($verbandGruppen[$lehrverband->verband]) && $lehrverband->gruppe !== ' ') { + $verbandGruppen[$lehrverband->verband] = [$lehrverband->gruppe]; + } else if (!isset($verbandGruppen[$lehrverband->verband])) { + $verbandGruppen[$lehrverband->verband] = []; + } + } + return $verbandGruppen; + }, + $grouppedValues + ); + + $this->terminateWithSuccess($grouppedValues); + } + } diff --git a/application/controllers/api/frontend/v1/stv/Grades.php b/application/controllers/api/frontend/v1/stv/Grades.php index 128316d2b..abc417bca 100644 --- a/application/controllers/api/frontend/v1/stv/Grades.php +++ b/application/controllers/api/frontend/v1/stv/Grades.php @@ -41,6 +41,9 @@ class Grades extends FHCAPI_Controller // Load Libraries $this->load->library('VariableLib', ['uid' => getAuthUID()]); + $this->load->library('PruefungsverlaufLib', null, 'VerlaufLib'); + + $this->load->config('noten'); // Load Phrases $this->loadPhrases([ @@ -355,23 +358,24 @@ class Grades extends FHCAPI_Controller $data['lehrveranstaltung_id'] = $lehrveranstaltung_id; $data['student_uid'] = $student_uid; $data['studiensemester_kurzbz'] = $studiensemester_kurzbz; - - $this->ZeugnisnoteModel->insert($data); - if (defined('FAS_PRUEFUNG_BEI_NOTENEINGABE_ANLEGEN') - && FAS_PRUEFUNG_BEI_NOTENEINGABE_ANLEGEN) { - $result = $this->addTestsForGrade( - $studiensemester_kurzbz, - $student_uid, - $lehrveranstaltung_id, - $teacherGrade->note, - $teacherGrade->punkte - ); - $this->getDataOrTerminateWithError($result); - } + $this->ZeugnisnoteModel->insert($data); + } + + // Der erste Antritt wird bereits bei der Übernahme als eigene Prüfung angelegt, damit der + // Prüfungsverlauf von Anfang an vollständig ist und das Benotungstool ihn nicht erst beim + // Anlegen einer Wiederholung nachtragen muss. Läuft bewusst in BEIDEN Zweigen (auch beim + // Überschreiben einer bestehenden Zeugnisnote) und ist idempotent. + if ($this->config->item('CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME')) { + $result = $this->addErstantrittForGrade( + $studiensemester_kurzbz, + $student_uid, + $lehrveranstaltung_id, + $teacherGrade + ); + $this->getDataOrTerminateWithError($result); } - $this->terminateWithSuccess(true); } @@ -491,20 +495,32 @@ class Grades extends FHCAPI_Controller } /** - * Helper function that adds tests for a student - * (Entries in lehre.tbl_pruefung) + * Legt bei der Notenübernahme den ersten Prüfungsantritt als eigenen Datensatz an + * (Eintrag in lehre.tbl_pruefung). * - * @param string $studiensemester_kurzbz - * @param string $student_uid - * @param integer $lehrveranstaltung_id - * @param integer $note - * @param numeric $punkte + * Idempotent: existiert für den Studenten in dieser LV bereits ein Termin, passiert nichts - + * die Übernahme darf beliebig oft laufen, ohne den Verlauf zu verfälschen. + * + * Datiert auf das Freigabedatum der LV-Note: das ist der Zeitpunkt, zu dem die + * Leistungsfeststellung verbindlich wurde. Das Benotungsdatum ändert sich bei jeder Korrektur, + * der Übernahmezeitpunkt ist reine Verwaltungsarbeit - beide taugen nicht als Termindatum. + * + * @param string $studiensemester_kurzbz + * @param string $student_uid + * @param integer $lehrveranstaltung_id + * @param stdClass $teacherGrade die übernommene LV-Gesamtnote * * @return stdClass */ - protected function addTestsForGrade($studiensemester_kurzbz, $student_uid, $lehrveranstaltung_id, $note, $punkte) + protected function addErstantrittForGrade($studiensemester_kurzbz, $student_uid, $lehrveranstaltung_id, $teacherGrade) { $this->load->model('education/Lehrveranstaltung_model', 'LehrveranstaltungModel'); + $this->load->model('education/LePruefung_model', 'LePruefungModel'); + + // bereits ein Termin vorhanden -> der Verlauf beginnt schon, nichts nachzutragen + $vorhanden = $this->VerlaufLib->getPruefungen($student_uid, $lehrveranstaltung_id, $studiensemester_kurzbz); + if (count($vorhanden) > 0) + return success(null); // Get Lehreinheit $result = $this->LehrveranstaltungModel->getLeByStudent($student_uid, $studiensemester_kurzbz, $lehrveranstaltung_id); @@ -515,19 +531,24 @@ class Grades extends FHCAPI_Controller return error($this->p->t("stv", "grades_error_lehreinheit_id")); $le = current(getData($result)); + $datum = $teacherGrade->freigabedatum ?: ($teacherGrade->benotungsdatum ?: date('Y-m-d')); + $now = date('Y-m-d H:i:s'); + // Prepare - $this->load->model('education/LePruefung_model', 'LePruefungModel'); $data = [ "student_uid" => $student_uid, "lehreinheit_id" => $le->lehreinheit_id, - "datum" => date('Y-m-d'), - "pruefungstyp_kurzbz" => "Termin1", - "note" => $note + "mitarbeiter_uid" => $teacherGrade->mitarbeiter_uid, + "datum" => $datum, + "pruefungstyp_kurzbz" => $this->VerlaufLib->legacyTypFuerAntritt(1), + "note" => $teacherGrade->note, + "insertamum" => $now, + "insertvon" => getAuthUID() ]; if (defined('CIS_GESAMTNOTE_PUNKTE') && CIS_GESAMTNOTE_PUNKTE) - $data["punkte"] = $punkte; - + $data["punkte"] = $teacherGrade->punkte; + // Get Anwesenheit $this->load->model('education/Anwesenheit_model', 'AnwesenheitModel'); $result = $this->AnwesenheitModel->loadAnwesenheitStudiensemester($studiensemester_kurzbz, $student_uid, $lehrveranstaltung_id); @@ -535,7 +556,8 @@ class Grades extends FHCAPI_Controller return $result; $anwesenheit = getData($result); - if ($anwesenheit && (float)current($anwesenheit)->prozent < FAS_ANWESENHEIT_ROT) { + if (defined('FAS_PRUEFUNG_BEI_NOTENEINGABE_ANLEGEN') && FAS_PRUEFUNG_BEI_NOTENEINGABE_ANLEGEN + && $anwesenheit && (float)current($anwesenheit)->prozent < FAS_ANWESENHEIT_ROT) { // Get Anwesenheitsbefreiung $this->load->model('person/Benutzerfunktion_model', 'BenutzerfunktionModel'); $result = $this->BenutzerfunktionModel->getBenutzerFunktionByUidInStdsem($student_uid, $studiensemester_kurzbz, 'awbefreit'); @@ -545,7 +567,10 @@ class Grades extends FHCAPI_Controller $anwesenheitsbefreit = hasData($result); - // Wenn nicht Anwesenheitsbefreit und Anwesenheit unter einem bestimmten Prozentsatz fällt dann wird ein Pruefungsantritt abgezogen + // Bei zu geringer Anwesenheit ohne Befreiung wird ein zusätzlicher Termin + // 'Nicht beurteilt' vor dem eigentlichen Antritt festgehalten. ACHTUNG: diese Note + // zählt nicht mehr als Antritt (NOTEN_OHNE_ANTRITT_BEZEICHNUNGEN) - der frühere + // "Antritt abziehen"-Effekt entfällt damit, der Eintrag ist reine Dokumentation. if (!$anwesenheitsbefreit) { $data2 = $data; $data2["note"] = 7; @@ -556,8 +581,6 @@ class Grades extends FHCAPI_Controller if (isError($result)) return $result; - - $data["pruefungstyp_kurzbz"] = "Termin2"; } } diff --git a/application/libraries/PruefungsverlaufLib.php b/application/libraries/PruefungsverlaufLib.php new file mode 100644 index 000000000..e0087e527 --- /dev/null +++ b/application/libraries/PruefungsverlaufLib.php @@ -0,0 +1,410 @@ +. + */ + +if (! defined('BASEPATH')) exit('No direct script access allowed'); + +/** + * The exam history of one student in one course: the order of the attempts, the attempt count + * and the limits. This class is the only source of the examination rules. The controller and the + * client only read the result. + */ +class PruefungsverlaufLib +{ + /** The first attempt: the original assessment, which is the transcript grade itself. */ + const ROLLE_ERSTANTRITT = 'erstantritt'; + + /** Each attempt after the first one. */ + const ROLLE_PRUEFUNG = 'pruefung'; + + /** The last attempt. The examination rules require a commission for it. */ + const ROLLE_KOMMISSIONELL = 'kommissionell'; + + /** Maps an attempt number to the old exam type. Kept for backward compatibility. */ + private static $_legacyTypen = [1 => 'Termin1', 2 => 'Termin2', 3 => 'Termin3']; + + private $_ci; + private $_noteCache = []; + private $_specialNotes = null; + private $_typen = null; + + public function __construct() + { + $this->_ci =& get_instance(); + $this->_ci->load->model('education/LePruefung_model', 'LePruefungModel'); + $this->_ci->load->model('education/Note_model', 'NoteModel'); + $this->_ci->load->model('education/Pruefungstyp_model', 'PruefungstypModel'); + $this->_ci->load->config('noten'); + } + + /** + * @param mixed $lvNote counts as the implicit first attempt while no exam counts + * @param mixed $zeugnisNote a credited grade makes all exams impossible + * @return stdClass + */ + public function getVerlauf($student_uid, $lv_id, $sem_kurzbz, $lvNote = null, $zeugnisNote = null) + { + return $this->buildVerlauf($this->getPruefungen($student_uid, $lv_id, $sem_kurzbz), $lvNote, $zeugnisNote); + } + + /** All exams of one student in one course, in chronological order. @return array */ + public function getPruefungen($student_uid, $lv_id, $sem_kurzbz) + { + $result = $this->_ci->LePruefungModel->getPruefungenByUidTypLvStudiensemester($student_uid, null, $lv_id, $sem_kurzbz); + if (isError($result) || !hasData($result)) return []; + + return $this->sortPruefungen(getData($result)); + } + + /** Sort keys: datum, then tbl_pruefungstyp.sort (old rows without a date), then pruefung_id. */ + public function sortPruefungen($pruefungen) + { + $pruefungen = array_values($pruefungen); + $typen = $this->getTypen(); + + usort($pruefungen, function ($a, $b) use ($typen) { + $da = substr((string) $a->datum, 0, 10); + $db = substr((string) $b->datum, 0, 10); + + if ($da !== $db) { + if ($da === '') return -1; + if ($db === '') return 1; + return ($da < $db) ? -1 : 1; + } + + $sa = isset($typen[$a->pruefungstyp_kurzbz]) ? (int) $typen[$a->pruefungstyp_kurzbz]->sort : 0; + $sb = isset($typen[$b->pruefungstyp_kurzbz]) ? (int) $typen[$b->pruefungstyp_kurzbz]->sort : 0; + if ($sa !== $sb) return ($sa < $sb) ? -1 : 1; + + return ((int) $a->pruefung_id < (int) $b->pruefung_id) ? -1 : 1; + }); + + return $pruefungen; + } + + /** + * Builds the history from a list of exams. Each exam gets: position, zaehlt, antritt_nr (null + * if the exam does not count) and terminal. + * + * @return stdClass + */ + public function buildVerlauf($pruefungen, $lvNote = null, $zeugnisNote = null) + { + $pruefungen = $this->sortPruefungen($pruefungen); + + $antrittCount = 0; + $terminal = false; + $eintraege = []; + + foreach ($pruefungen as $i => $p) { + $zaehlt = $this->zaehltAlsAntritt($p->note, $p->pruefungstyp_kurzbz); + if ($zaehlt) $antrittCount++; + + // A kommissionelle exam closes the chain, also if its grade uses no attempt. A student + // with a kommissionelle Prüfung gets no further exam from this tool. + $kommissionell = $this->istKommissionell($p->pruefungstyp_kurzbz); + if ($kommissionell) $terminal = true; + + $eintrag = clone $p; + $eintrag->position = $i + 1; + $eintrag->zaehlt = $zaehlt; + $eintrag->antritt_nr = $zaehlt ? $antrittCount : null; + $eintrag->kommissionell = $kommissionell; + $eintraege[] = $eintrag; + } + + // if no exam counts, the course grade itself is the first attempt + $implizit = ($antrittCount === 0 && $lvNote !== null && $this->zaehltAlsAntritt($lvNote)); + if ($implizit) $antrittCount = 1; + + $maxAntritte = $this->getMaxAntritte(); + + // credited: the row stays visible, but you cannot enter anything + $angerechnet = $this->istAnrechnungsnote($zeugnisNote); + + $verlauf = new stdClass(); + $verlauf->pruefungen = $eintraege; + $verlauf->antrittCount = $antrittCount; + $verlauf->maxAntritte = $maxAntritte; + $verlauf->terminal = $terminal; + $verlauf->angerechnet = $angerechnet; + $verlauf->impliziterErstantritt = $implizit; + $verlauf->canAdd = !$terminal && !$angerechnet && $antrittCount < $maxAntritte; + + // after the first exam each new entry is a repeat attempt + $verlauf->erstantrittMoeglich = !$angerechnet && (count($eintraege) === 0); + + // The last attempt of the chain is kommissionell (§17 Abs 1). The role therefore follows + // from the position, and the user interface offers no type to select. + if (count($eintraege) === 0 && !$implizit) { + $verlauf->naechsteRolle = self::ROLLE_ERSTANTRITT; + } elseif (($antrittCount + 1) >= $maxAntritte) { + $verlauf->naechsteRolle = self::ROLLE_KOMMISSIONELL; + } else { + $verlauf->naechsteRolle = self::ROLLE_PRUEFUNG; + } + + return $verlauf; + } + + /** 'angerechnet' or 'intern angerechnet'. The TRANSCRIPT grade decides. @return bool */ + public function istAnrechnungsnote($note) + { + if ($note === null || $note === '') return false; + return in_array($note, $this->getSpecialNotes()['anrechnung']); + } + + /** + * All attempts that count, the kommissionelle attempt included. The explicit configuration + * wins. Without it the number comes from the old flags: the original assessment, one attempt + * for each active repeat, and the kommissionelle attempt. + * + * @return int + */ + public function getMaxAntritte() + { + $max = $this->_ci->config->item('CIS_GESAMTNOTE_MAX_ANTRITTE'); + if (is_numeric($max) && (int) $max > 0) return (int) $max; + + $max = 1; + if (defined('CIS_GESAMTNOTE_PRUEFUNG_TERMIN2') && CIS_GESAMTNOTE_PRUEFUNG_TERMIN2) $max++; + if (defined('CIS_GESAMTNOTE_PRUEFUNG_TERMIN3') && CIS_GESAMTNOTE_PRUEFUNG_TERMIN3) $max++; + if (defined('CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF') && CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) $max++; + return $max; + } + + /** + * The NOTE decides, therefore the same type can occur more than one time. The type decides in + * one case only: a type in PRUEFUNG_TYPEN_OHNE_ANTRITT never uses an attempt. + * + * @return bool + */ + public function zaehltAlsAntritt($note, $typ = null) + { + if ($typ !== null && in_array($typ, $this->getTypenOhneAntritt())) return false; + if ($note === null || $note === '') return false; + if (in_array($note, $this->getSpecialNotes()['ohneAntritt'])) return false; + + // a credited grade is not an assessment, therefore it uses no attempt + if ($this->istAnrechnungsnote($note)) return false; + + $noteRow = $this->getNote($note); + return $noteRow ? (bool) $noteRow->lehre : false; + } + + /** Tells you if an exam takes place before a commission. @return bool */ + public function istKommissionell($typ) + { + $typen = $this->_ci->config->item('PRUEFUNG_KOMMISSIONELL_TYPEN'); + if (!is_array($typen)) $typen = ['kommPruef', 'zusKommPruef']; + return in_array($typ, $typen); + } + + /** The exam types that never use an attempt. @return array */ + public function getTypenOhneAntritt() + { + $typen = $this->_ci->config->item('PRUEFUNG_TYPEN_OHNE_ANTRITT'); + return is_array($typen) ? $typen : ['zusKommPruef']; + } + + /** + * The type for an attempt number. The student administration still reads this column, so the + * value must be correct. The last attempt gets the kommissionelle type, the attempts before it + * get the old Termin types. + * + * @return string + */ + public function legacyTypFuerAntritt($antrittNr) + { + $antrittNr = max(1, (int) $antrittNr); + $typen = $this->getTypen(); + + $maxAntritte = $this->getMaxAntritte(); + $kommissionell = $this->_ci->config->item('PRUEFUNG_TYP_KOMMISSIONELL'); + if (!is_string($kommissionell) || $kommissionell === '') $kommissionell = 'kommPruef'; + + if ($maxAntritte > 1 && $antrittNr >= $maxAntritte && isset($typen[$kommissionell])) { + return $kommissionell; + } + + for ($n = $antrittNr; $n >= 1; $n--) { + if (isset(self::$_legacyTypen[$n]) && isset($typen[self::$_legacyTypen[$n]])) { + return self::$_legacyTypen[$n]; + } + } + + return self::$_legacyTypen[1]; + } + + /** + * The old type for a new repeat attempt. It is never lower than Termin2, also if the first + * exam does not use an attempt. + * + * @return string + */ + public function legacyTypFuerWiederholung($verlauf) + { + return $this->legacyTypFuerAntritt(max(2, $verlauf->antrittCount + 1)); + } + + /** @return array{entschuldigt: mixed, ohneAntritt: array, anrechnung: array, limitMap: array} */ + public function getSpecialNotes() + { + if ($this->_specialNotes !== null) return $this->_specialNotes; + + $cfgEntschuldigt = $this->_ci->config->item('NOTE_ENTSCHULDIGT'); + $cfgOhneAntritt = $this->_ci->config->item('NOTEN_OHNE_ANTRITT'); + $cfgLimitMap = $this->_ci->config->item('NOTEN_OCCURANCE_LIMIT_MAP'); + $bezeichnungen = $this->_ci->config->item('NOTEN_OHNE_ANTRITT_BEZEICHNUNGEN'); + if (!is_array($cfgOhneAntritt)) $cfgOhneAntritt = []; + if (!is_array($cfgLimitMap)) $cfgLimitMap = []; + if (!is_array($bezeichnungen)) $bezeichnungen = []; + + $resEnt = $this->_ci->NoteModel->getEntschuldigtNote(); + $entschuldigt = (!isError($resEnt) && hasData($resEnt)) ? getData($resEnt)[0]->note : $cfgEntschuldigt; + + // The name wins against the configured key. Do NOT merge the two sets: the same key has a + // different meaning in each installation (17 is 'entschuldigt' here, but 'nicht zugelassen' + // in the standard data). + $ohneAntritt = []; + foreach ($bezeichnungen as $bezeichnung) { + $note = $this->getNoteByBezeichnung($bezeichnung); + if ($note !== null && !in_array($note, $ohneAntritt)) $ohneAntritt[] = $note; + } + if (count($ohneAntritt) === 0) $ohneAntritt = $cfgOhneAntritt; + if ($entschuldigt !== null && !in_array($entschuldigt, $ohneAntritt)) $ohneAntritt[] = $entschuldigt; + + // move the configured limit for 'entschuldigt' to the key that was found + $limitMap = []; + foreach ($cfgLimitMap as $k => $v) { + $limitMap[($k == $cfgEntschuldigt) ? $entschuldigt : $k] = $v; + } + + $this->_specialNotes = [ + 'entschuldigt' => $entschuldigt, + 'ohneAntritt' => array_values($ohneAntritt), + 'anrechnung' => $this->resolveNoten('NOTEN_ANRECHNUNG_BEZEICHNUNGEN', 'NOTEN_ANRECHNUNG'), + 'limitMap' => $limitMap + ]; + return $this->_specialNotes; + } + + /** + * The occurrence limit from NOTEN_OCCURANCE_LIMIT_MAP. $excludePruefungId skips the record + * that the user edits at this moment. + * + * @return bool + */ + public function ueberschreitetNotenLimit($pruefungen, $note, $excludePruefungId = null) + { + $limitMap = $this->getSpecialNotes()['limitMap']; + + $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; + } + + /** An exam with a later date locks the grade. You can still correct the date. @return bool */ + public function hatSpaeterenTermin($verlauf, $pruefung_id) + { + $current = null; + foreach ($verlauf->pruefungen as $p) { + if ($p->pruefung_id == $pruefung_id) { $current = $p; break; } + } + if ($current === null) return false; + + foreach ($verlauf->pruefungen as $p) { + if ($p->pruefung_id == $current->pruefung_id) continue; + if ($p->position > $current->position) return true; + } + + return false; + } + + /** + * tbl_pruefungstyp as a map. It sorts old rows, and it limits the legacy type to the types + * that exist. A type that does not exist breaks the foreign key. Termin3 is absent from the + * standard data. + * + * @return array + */ + private function getTypen() + { + if ($this->_typen !== null) return $this->_typen; + + $this->_typen = []; + $result = $this->_ci->PruefungstypModel->load(); + if (!isError($result) && hasData($result)) { + foreach (getData($result) as $typ) { + $this->_typen[$typ->pruefungstyp_kurzbz] = $typ; + } + } + + return $this->_typen; + } + + /** Finds the grades by their name. The configured keys are the fallback only. @return array */ + private function resolveNoten($bezeichnungKey, $pkKey) + { + $bezeichnungen = $this->_ci->config->item($bezeichnungKey); + if (!is_array($bezeichnungen)) $bezeichnungen = []; + + $noten = []; + foreach ($bezeichnungen as $bezeichnung) { + $note = $this->getNoteByBezeichnung($bezeichnung); + if ($note !== null && !in_array($note, $noten)) $noten[] = $note; + } + + if (count($noten) === 0) { + $fallback = $this->_ci->config->item($pkKey); + if (is_array($fallback)) $noten = $fallback; + } + + return array_values($noten); + } + + /** + * @return stdClass|null + */ + private function getNote($note) + { + if (array_key_exists($note, $this->_noteCache)) return $this->_noteCache[$note]; + + $result = $this->_ci->NoteModel->load($note); + $this->_noteCache[$note] = (!isError($result) && hasData($result)) ? getData($result)[0] : null; + return $this->_noteCache[$note]; + } + + /** + * @return mixed|null + */ + private function getNoteByBezeichnung($bezeichnung) + { + $result = $this->_ci->NoteModel->loadWhere(['bezeichnung' => $bezeichnung]); + return (!isError($result) && hasData($result)) ? getData($result)[0]->note : null; + } +} diff --git a/application/libraries/tags/CoreMissingZgvTagLib.php b/application/libraries/tags/CoreMissingZgvTagLib.php index e25aed4cc..2d5df0ffc 100644 --- a/application/libraries/tags/CoreMissingZgvTagLib.php +++ b/application/libraries/tags/CoreMissingZgvTagLib.php @@ -28,10 +28,21 @@ class CoreMissingZgvTagLib $this->ci->PrestudentModel->addJoin('public.tbl_prestudentstatus', 'prestudent_id'); $this->ci->PrestudentModel->addJoin('public.tbl_benutzer bn', 'person_id'); $this->ci->PrestudentModel->addJoin('public.tbl_studiengang', 'studiengang_kz'); + + $this->ci->PrestudentModel->db->group_start(); + + $this->ci->PrestudentModel->db->where('zgvdatum', null); + $this->ci->PrestudentModel->db->where('typ', 'b'); + + $this->ci->PrestudentModel->db->or_group_start(); + $this->ci->PrestudentModel->db->where('zgvmadatum', null); + $this->ci->PrestudentModel->db->where('typ', 'm'); + $this->ci->PrestudentModel->db->group_end(); + + $this->ci->PrestudentModel->db->group_end(); + $result = $this->ci->PrestudentModel-> loadWhere(array( - 'bn.aktiv' => true, //check if necessary - 'zgvdatum' => null, - 'typ' => 'b', + 'bn.aktiv' => true, 'studiensemester_kurzbz' => $semester )); @@ -60,16 +71,36 @@ class CoreMissingZgvTagLib $semester = $params['studiensemester_kurzbz']; $prestudent_id = $params['id']; + $this->ci->PrestudentModel->addSelect('prestudent_id'); + $this->ci->PrestudentModel->addSelect('start as von'); + $this->ci->PrestudentModel->addSelect('ende as bis'); $this->ci->PrestudentModel->addJoin('public.tbl_prestudentstatus', 'prestudent_id'); + + //add studiensemester also here for manual rebuild + $this->ci->PrestudentModel->addJoin('public.tbl_studiensemester', 'studiensemester_kurzbz'); + $this->ci->PrestudentModel->addJoin('public.tbl_benutzer bn', 'person_id'); $this->ci->PrestudentModel->addJoin('public.tbl_studiengang', 'studiengang_kz'); - $result = $this->ci->PrestudentModel->loadWhere(array( - 'bn.aktiv' => true, //check if necessary - 'zgvdatum' => null, - 'typ' => 'b', + + $this->ci->PrestudentModel->db->group_start(); + + $this->ci->PrestudentModel->db->where('zgvdatum', null); + $this->ci->PrestudentModel->db->where('typ', 'b'); + + $this->ci->PrestudentModel->db->or_group_start(); + $this->ci->PrestudentModel->db->where('zgvmadatum', null); + $this->ci->PrestudentModel->db->where('typ', 'm'); + $this->ci->PrestudentModel->db->group_end(); + + $this->ci->PrestudentModel->db->group_end(); + $this->ci->PrestudentModel->db->limit(1); + + $result = $this->ci->PrestudentModel-> loadWhere(array( + 'bn.aktiv' => true, 'studiensemester_kurzbz' => $semester, 'prestudent_id' => $prestudent_id )); + if(hasData($result)) { return $result; diff --git a/application/libraries/vertragsbestandteil/Gehaltsbestandteil.php b/application/libraries/vertragsbestandteil/Gehaltsbestandteil.php index 95bbdd908..869ed1c2b 100644 --- a/application/libraries/vertragsbestandteil/Gehaltsbestandteil.php +++ b/application/libraries/vertragsbestandteil/Gehaltsbestandteil.php @@ -13,6 +13,7 @@ class Gehaltsbestandteil extends AbstractBestandteil implements \JsonSerializabl protected $dienstverhaeltnis_id; protected $vertragsbestandteil_id; protected $gehaltstyp_kurzbz; + protected $gehaltsanpassungtyp_kurzbz = ''; protected $von; protected $bis; protected $anmerkung; @@ -21,6 +22,7 @@ class Gehaltsbestandteil extends AbstractBestandteil implements \JsonSerializabl protected $valorisierungssperre; protected $valorisierung; protected $auszahlungen; + protected $gehaltstyp_bezeichnung; protected $insertamum; protected $insertvon; @@ -39,6 +41,10 @@ class Gehaltsbestandteil extends AbstractBestandteil implements \JsonSerializabl isset($data->dienstverhaeltnis_id) && $this->setDienstverhaeltnis_id($data->dienstverhaeltnis_id); isset($data->vertragsbestandteil_id) && $this->setVertragsbestandteil_id($data->vertragsbestandteil_id); isset($data->gehaltstyp_kurzbz) && $this->setGehaltstyp_kurzbz($data->gehaltstyp_kurzbz); + //isset($data->gehaltsanpassungtyp_kurzbz) && $this->setGehaltsanpassungtyp_kurzbz($data->gehaltsanpassungtyp_kurzbz); + if (!empty($data->gehaltsanpassungtyp_kurzbz)) { + $this->setGehaltsanpassungtyp_kurzbz($data->gehaltsanpassungtyp_kurzbz); + } isset($data->von) && $this->setVon($data->von); isset($data->bis) && $this->setBis($data->bis); isset($data->anmerkung) && $this->setAnmerkung($data->anmerkung); @@ -47,7 +53,8 @@ class Gehaltsbestandteil extends AbstractBestandteil implements \JsonSerializabl isset($data->valorisierungssperre) && $this->setValorisierungssperre($data->valorisierungssperre); isset($data->valorisierung) && $this->setValorisierung($data->valorisierung); isset($data->auszahlungen) && $this->setAuszahlungen($data->auszahlungen); - + isset($data->gehaltstyp_bezeichnung) && $this->setGehaltstyp_bezeichnung($data->gehaltstyp_bezeichnung); + isset($data->insertamum) && $this->setInsertamum($data->insertamum); isset($data->insertvon) && $this->setInsertvon($data->insertvon); isset($data->updateamum) && $this->setUpdateamum($data->updateamum); @@ -75,6 +82,11 @@ class Gehaltsbestandteil extends AbstractBestandteil implements \JsonSerializabl return $this->gehaltstyp_kurzbz; } + public function getGehaltsanpassungtyp_kurzbz() + { + return $this->gehaltsanpassungtyp_kurzbz; + } + public function getVon() { return $this->von; @@ -178,6 +190,13 @@ class Gehaltsbestandteil extends AbstractBestandteil implements \JsonSerializabl return $this; } + public function setGehaltsanpassungtyp_kurzbz($gehaltsanpassungtyp_kurzbz) + { + $this->markDirty('gehaltsanpassungtyp_kurzbz', $this->gehaltsanpassungtyp_kurzbz, $gehaltsanpassungtyp_kurzbz); + $this->gehaltsanpassungtyp_kurzbz = $gehaltsanpassungtyp_kurzbz; + return $this; + } + public function setVon($von) { $this->markDirty('von', $this->von, $von); @@ -206,6 +225,10 @@ class Gehaltsbestandteil extends AbstractBestandteil implements \JsonSerializabl $grundbetrag = number_format($grundbetrag, 2, '.', ''); } $this->markDirty('grundbetrag', $this->grundbetrag, $grundbetrag); + if(in_array('grundbetrag', $this->modifiedcolumns)) + { + $this->setBetrag_valorisiert($grundbetrag); + } $this->grundbetrag = $grundbetrag; return $this; } @@ -242,6 +265,13 @@ class Gehaltsbestandteil extends AbstractBestandteil implements \JsonSerializabl return $this; } + public function setGehaltstyp_bezeichnung($gehaltstyp_bezeichnung) + { + $this->markDirty('gehaltstyp_bezeichnung', $this->gehaltstyp_bezeichnung, $gehaltstyp_bezeichnung); + $this->gehaltstyp_bezeichnung = $gehaltstyp_bezeichnung; + return $this; + } + public function setInsertamum($insertamum) { $this->markDirty('insertamum', $this->insertamum, $insertamum); @@ -284,6 +314,7 @@ class Gehaltsbestandteil extends AbstractBestandteil implements \JsonSerializabl 'dienstverhaeltnis_id' => $this->getDienstverhaeltnis_id(), 'vertragsbestandteil_id' => $this->getVertragsbestandteil_id(), 'gehaltstyp_kurzbz' => $this->getGehaltstyp_kurzbz(), + 'gehaltsanpassungtyp_kurzbz' => $this->getGehaltsanpassungtyp_kurzbz(), 'von' => $this->getVon(), 'bis' => $this->getBis(), 'anmerkung' => $this->getAnmerkung(), @@ -312,6 +343,7 @@ class Gehaltsbestandteil extends AbstractBestandteil implements \JsonSerializabl dienstverhaeltnis_id: {$this->getDienstverhaeltnis_id()} vertragsbestandteil_id: {$this->getVertragsbestandteil_id()} gehaltstyp_kurzbz: {$this->getGehaltstyp_kurzbz()} + gehaltsanpassungtyp_kurzbz: {$this->getGehaltsanpassungtyp_kurzbz()} von: {$this->getVon()} bis: {$this->getBis()} anmerkung: {$this->getAnmerkung()} diff --git a/application/models/education/Lehrveranstaltung_model.php b/application/models/education/Lehrveranstaltung_model.php index 3b94ab3bd..f19e64469 100644 --- a/application/models/education/Lehrveranstaltung_model.php +++ b/application/models/education/Lehrveranstaltung_model.php @@ -1410,4 +1410,50 @@ class Lehrveranstaltung_model extends DB_Model return $this->execReadOnlyQuery($qry, [$sem_kurzbz, $mitarbeiter_uid, $sem_kurzbz, $mitarbeiter_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/application/models/education/Lvgesamtnote_model.php b/application/models/education/Lvgesamtnote_model.php index c7d9ea3df..00112d39c 100644 --- a/application/models/education/Lvgesamtnote_model.php +++ b/application/models/education/Lvgesamtnote_model.php @@ -59,7 +59,7 @@ class Lvgesamtnote_model extends DB_Model $params = [$student_uid, $studiensemester_kurzbz]; if ($lehrveranstaltung_id) { - $qry .= "AND campus.tbl_lvgesamtnote.lehrveranstaltung_id = ?"; + $qry .= " AND campus.tbl_lvgesamtnote.lehrveranstaltung_id = ?"; $params[] = $lehrveranstaltung_id; } diff --git a/application/models/education/Notenschluesselaufteilung_model.php b/application/models/education/Notenschluesselaufteilung_model.php index f9031de47..33c8de26c 100644 --- a/application/models/education/Notenschluesselaufteilung_model.php +++ b/application/models/education/Notenschluesselaufteilung_model.php @@ -13,7 +13,7 @@ class Notenschluesselaufteilung_model extends DB_Model } /** - * Liefert die Note zu Punkten einer Lehrveranstaltung + * Gives the grade for the points of a course. * * @param number $points * @param integer $lehrveranstaltung_id @@ -23,6 +23,12 @@ class Notenschluesselaufteilung_model extends DB_Model */ public function getNote($points, $lehrveranstaltung_id, $studiensemester_kurzbz) { + // Without a valid points value you cannot derive a grade. Without this guard the query + // builder makes a bad statement from "punkte <=" => null ('punkte' < 'IS' 'NULL'). The + // request then fails with HTTP 500 instead of an answer that says "no grade". + if (!is_numeric($points)) + return success(null); + $this->load->model('education/Notenschluesselzuordnung_model', 'NotenschluesselzuordnungModel'); $notenschluessel_kurzbz = $this->NotenschluesselzuordnungModel->getKurzbzForLv($lehrveranstaltung_id, $studiensemester_kurzbz); diff --git a/application/models/education/Studierendenantrag_model.php b/application/models/education/Studierendenantrag_model.php index c18d18053..1fa5b89ef 100644 --- a/application/models/education/Studierendenantrag_model.php +++ b/application/models/education/Studierendenantrag_model.php @@ -515,4 +515,75 @@ class Studierendenantrag_model extends DB_Model return hasData($this->load()); } + + /** + * Loads all todos for a certain studiengangsleiter + * + * @param integer $stg_kz + * + * @return stdClass + */ + public function getOpenAntraegeForStgl($stg_kz) + { + $qry = " + SELECT + studierendenantrag_id, + prestudent_id, + typ, + person.nachname, + person.vorname, + ps.studiengang_kz + FROM campus.tbl_studierendenantrag a + JOIN public.tbl_prestudent ps USING (prestudent_id) + JOIN public.tbl_person person USING (person_id) + WHERE studiengang_kz in ? + AND ( + (typ IN ('Abmeldung', 'AbmeldungStgl', 'Unterbrechung') + AND campus.get_status_studierendenantrag(a.studierendenantrag_id) = 'Erstellt') + OR + (typ = 'Wiederholung' + AND campus.get_status_studierendenantrag(a.studierendenantrag_id) = 'Lvszugewiesen') + ) + ORDER BY typ; + "; + + $params = array($stg_kz); + return $this->execQuery($qry, $params); + } + + /** + * Lists all open antraege for a certain stg + * + * @param Array $stg_kz + * + * @return stdClass + */ + public function getOpenAntraegeForAss($stg_kz) + { + $qry = " + SELECT + UPPER(stg.typ) || UPPER(stg.kurzbz) || ' ' || stg.bezeichnung AS studiengang, + COUNT(*) AS anzahl + FROM campus.tbl_studierendenantrag + JOIN public.tbl_prestudent p USING (prestudent_id) + JOIN public.tbl_studiengang stg ON p.studiengang_kz=stg.studiengang_kz + JOIN campus.tbl_studierendenantrag_status as s ON campus.get_status_id_studierendenantrag(campus.tbl_studierendenantrag.studierendenantrag_id) = studierendenantrag_status_id + JOIN campus.tbl_studierendenantrag_statustyp as st USING (studierendenantrag_statustyp_kurzbz) + WHERE ( + s.studierendenantrag_statustyp_kurzbz NOT IN('Zurueckgezogen', 'Genehmigt', 'Abgelehnt', 'EinspruchAbgelehnt', 'Abgemeldet', 'Pause', 'EmailVersandt') + OR ( + s.studierendenantrag_statustyp_kurzbz = 'Genehmigt' + AND tbl_studierendenantrag.typ = 'AbmeldungStgl' + ) + ) + AND p.studiengang_kz IN ? + GROUP BY studiengang + ORDER BY studiengang + " + ; + + $params = array($stg_kz); + return $this->execQuery($qry, $params); + } + } diff --git a/application/models/organisation/Studiengang_model.php b/application/models/organisation/Studiengang_model.php index 1db658596..db0996319 100644 --- a/application/models/organisation/Studiengang_model.php +++ b/application/models/organisation/Studiengang_model.php @@ -457,6 +457,7 @@ class Studiengang_model extends DB_Model */ public function getLeitung($studiengang_kz = null) { + $this->addDistinct(); $this->addSelect('uid, studiengang_kz, oe_kurzbz, vorname, nachname, email, titelpre, titelpost, alias'); $this->addJoin('public.tbl_benutzerfunktion', 'oe_kurzbz'); $this->addJoin('public.tbl_benutzer', 'uid'); diff --git a/application/models/person/Benutzerfunktion_model.php b/application/models/person/Benutzerfunktion_model.php index 52236ba7c..83d106210 100644 --- a/application/models/person/Benutzerfunktion_model.php +++ b/application/models/person/Benutzerfunktion_model.php @@ -319,6 +319,40 @@ class Benutzerfunktion_model extends DB_Model return $record; } + /** + * Get active Assistenz(en) of the user by UID. + * @param $uid + */ + public function getSTGAssByUID($uid) + { + $query = ' + SELECT + uid, + oe_kurzbz, + studiengang_kz, + typ, + tbl_studiengang.bezeichnung + FROM + public.tbl_benutzerfunktion + JOIN public.tbl_studiengang USING (oe_kurzbz) + WHERE + funktion_kurzbz = \'ass\' + AND (datum_von IS NULL OR datum_von <= now()) + AND (datum_bis IS NULL OR datum_bis >= now()) + AND uid = ? + ORDER BY + oe_kurzbz + '; + + $parameters_array = array(); + if (is_string($uid)) + { + $parameters_array[] = $uid; + } + + return $this->execQuery($query, $parameters_array); + } + function updateBenutzerfunktion($funktionJson) { $funktionJson['updatevon'] = getAuthUID(); diff --git a/application/models/ressource/Ort_model.php b/application/models/ressource/Ort_model.php index de0c8e331..9e2f286d0 100644 --- a/application/models/ressource/Ort_model.php +++ b/application/models/ressource/Ort_model.php @@ -12,6 +12,12 @@ class Ort_model extends DB_Model $this->pk = "ort_kurzbz"; } + public function getRoomInfo($ortKurzbz) + { + $this->addSelect("ort_kurzbz, ausstattung"); + return $this->OrtModel->loadWhere(array("ort_kurzbz" => $ortKurzbz)); + } + public function getAll($raumtyp_kurzbz) { $this->addOrder("ort_kurzbz"); diff --git a/application/models/ressource/Reservierung_model.php b/application/models/ressource/Reservierung_model.php index d1c4e683b..bedeefed8 100644 --- a/application/models/ressource/Reservierung_model.php +++ b/application/models/ressource/Reservierung_model.php @@ -24,7 +24,7 @@ class Reservierung_model extends DB_Model $lvplan_reservierungen_query = "SELECT r.* , stund.beginn, stund.ende, CASE WHEN r.gruppe_kurzbz IS NOT NULL THEN r.gruppe_kurzbz - ELSE CONCAT(UPPER(studg.typ),UPPER(studg.kurzbz),'-',COALESCE(CAST(r.semester AS varchar),'/'),COALESCE(CAST(r.verband AS varchar),'/')) + ELSE CONCAT(UPPER(studg.typ),UPPER(studg.kurzbz),'-',COALESCE(CAST(r.semester AS varchar),'/'),COALESCE(CAST(r.verband AS varchar),'/'),COALESCE(CAST(res.gruppe AS varchar),'/')) END as gruppen_kuerzel FROM campus.vw_reservierung r JOIN public.tbl_studiengang studg ON studg.studiengang_kz=r.studiengang_kz @@ -39,7 +39,7 @@ class Reservierung_model extends DB_Model $raum_reservierungen_query = "SELECT res.*, beginn, ende, CASE WHEN res.gruppe_kurzbz IS NOT NULL THEN res.gruppe_kurzbz - ELSE CONCAT(UPPER(studg.typ),UPPER(studg.kurzbz),'-',COALESCE(CAST(res.semester AS varchar),'/'),COALESCE(CAST(res.verband AS varchar),'/')) + ELSE CONCAT(UPPER(studg.typ),UPPER(studg.kurzbz),'-',COALESCE(CAST(res.semester AS varchar),'/'),COALESCE(CAST(res.verband AS varchar),'/'),COALESCE(CAST(res.gruppe AS varchar),'/')) END as gruppen_kuerzel FROM campus.vw_reservierung res JOIN public.tbl_studiengang studg ON studg.studiengang_kz=res.studiengang_kz @@ -83,7 +83,7 @@ class Reservierung_model extends DB_Model $raum_reservierungen_query = "SELECT res.*, beginn, ende, CASE WHEN res.gruppe_kurzbz IS NOT NULL THEN res.gruppe_kurzbz - ELSE CONCAT(UPPER(studg.typ),UPPER(studg.kurzbz),'-',COALESCE(CAST(res.semester AS varchar),'/'),COALESCE(CAST(res.verband AS varchar),'/')) + ELSE CONCAT(UPPER(studg.typ),UPPER(studg.kurzbz),'-',COALESCE(CAST(res.semester AS varchar),'/'),COALESCE(CAST(res.verband AS varchar),'/'),COALESCE(CAST(res.gruppe AS varchar),'/')) END as gruppen_kuerzel FROM campus.vw_reservierung res JOIN public.tbl_studiengang studg ON studg.studiengang_kz=res.studiengang_kz @@ -96,7 +96,7 @@ class Reservierung_model extends DB_Model $query_result = $this->execReadOnlyQuery(" SELECT DISTINCT(insertvon), - 'reservierung' as type, beginn, ende, datum, + 'reservierung' as type, beginn, ende, datum, array_agg(DISTINCT reservierung_id) AS reservierung_id, COALESCE(titel, beschreibung) as topic, array_agg(DISTINCT mitarbeiter_kurzbz) as lektor, array_agg(DISTINCT (gruppe,verband,semester,studiengang_kz,gruppen_kuerzel)) as gruppe, diff --git a/application/models/ressource/Zeitsperre_model.php b/application/models/ressource/Zeitsperre_model.php index d67ab361c..55fdb5b9d 100644 --- a/application/models/ressource/Zeitsperre_model.php +++ b/application/models/ressource/Zeitsperre_model.php @@ -188,6 +188,146 @@ class Zeitsperre_model extends DB_Model return $result; } + /** + * getZeitsperren of Mitarbeiter for the next days + * @ days: count of the intervall of the next days + * @return array + */ + public function getZeitsperrenForNextDays($days, $uid=null) + { + $von = date('Y-m-d'); + $bis = date('Y-m-d', strtotime("+{$days} days")); + + $paramsArray = [$von, $bis, $bis, $von]; + + if($uid) + $paramsArray[] = $uid; + + $qry = "select + nachname, + vorname, + uid, + vondatum, + bisdatum, + vertretung_uid, + erreichbarkeit_kurzbz, + mv.kurzbz + from + campus.vw_mitarbeiter + join campus.tbl_zeitsperre on + (uid = mitarbeiter_uid) + left join public.tbl_mitarbeiter mv on campus.tbl_zeitsperre.vertretung_uid = mv.mitarbeiter_uid + where + ((? <= bisdatum + and ? >= bisdatum) + or (?>= vondatum + and ? <= vondatum))"; + + if($uid) + $qry .= " and uid = ? "; + + $qry .= " + order by nachname + "; + + $result = $this->execQuery($qry, $paramsArray); + + return $result; + } + + public function getMitarbeiterWithZeitsperren($von, $bis, $fix=false, $lector=false, $oe=null, $ass=false, $stg=false) + { + $paramsArray = [$von, $bis, $bis, $von]; + + if($oe) + { + $paramsArray[] = $oe; + } + + if($stg) + { + $paramsArray[] = $stg; + } + + $qry = " + SELECT + m.nachname, + m.vorname, + m.uid, + z.vondatum, + z.bisdatum, + z.vertretung_uid, + z.erreichbarkeit_kurzbz, + m.lektor, + m.fixangestellt, + mv.kurzbz + FROM campus.vw_mitarbeiter m + join public.tbl_benutzer bn on bn.uid = m.uid"; + + if($oe || $ass || $stg) + { + $qry .= " JOIN public.tbl_benutzerfunktion bf on bf.uid = bn.uid"; + } + + if($stg) + { + $qry .= " JOIN public.tbl_studiengang stg using (oe_kurzbz)"; + } + + $qry.= " + LEFT JOIN campus.tbl_zeitsperre z + ON m.uid = z.mitarbeiter_uid + AND ( + (? <= z.bisdatum AND ? >= z.bisdatum) + OR + (? >= z.vondatum AND ? <= z.vondatum) + ) + left join public.tbl_mitarbeiter mv on z.vertretung_uid = mv.mitarbeiter_uid + where bn.aktiv = true + "; + + + if($fix) { + $qry.= " AND m.fixangestellt = true"; + } + if($lector) { + $qry.= " AND m.lektor = true"; + } + + if ($oe) + { + $qry.= " + AND bf.funktion_kurzbz ='oezuordnung' + AND bf.oe_kurzbz= ? + AND (bf.datum_von is null or bf.datum_von <=now()) + AND (bf.datum_bis is null or bf.datum_bis >= now()) + "; + } + + if ($ass) + { + $qry.= " + AND bf.funktion_kurzbz ='ass' + AND (bf.datum_von is null or bf.datum_von <=now()) + AND (bf.datum_bis is null or bf.datum_bis >= now()) + "; + } + + if ($stg) + { + $qry.= " + AND stg.studiengang_kz = ? + "; + } + + $qry .= " order by nachname, vorname, uid"; + + + $result = $this->execQuery($qry, $paramsArray); + + return $result; + } + public function checkIfZeitsperreExists($uid, $datum, $stunde) { $this->db->select("*"); @@ -197,27 +337,27 @@ class Zeitsperre_model extends DB_Model $this->db->group_start(); - $this->db->where('vondatum <', $datum); - $this->db->or_group_start(); - $this->db->where('vondatum', $datum); - $this->db->group_start(); - $this->db->where('vonstunde <=', $stunde); - $this->db->or_where('vonstunde IS NULL', null, false); - $this->db->group_end(); - $this->db->group_end(); + $this->db->where('vondatum <', $datum); + $this->db->or_group_start(); + $this->db->where('vondatum', $datum); + $this->db->group_start(); + $this->db->where('vonstunde <=', $stunde); + $this->db->or_where('vonstunde IS NULL', null, false); + $this->db->group_end(); + $this->db->group_end(); $this->db->group_end(); $this->db->group_start(); - $this->db->where('bisdatum >', $datum); - $this->db->or_group_start(); - $this->db->where('bisdatum', $datum); - $this->db->group_start(); - $this->db->where('bisstunde >=', $stunde); - $this->db->or_where('bisstunde IS NULL', null, false); - $this->db->group_end(); + $this->db->where('bisdatum >', $datum); + $this->db->or_group_start(); + $this->db->where('bisdatum', $datum); + $this->db->group_start(); + $this->db->where('bisstunde >=', $stunde); + $this->db->or_where('bisstunde IS NULL', null, false); + $this->db->group_end(); - $this->db->group_end(); + $this->db->group_end(); $this->db->group_end(); return $this->load(); diff --git a/application/models/ressource/Zeitsperretyp_model.php b/application/models/ressource/Zeitsperretyp_model.php index ef54571e8..b693d189b 100644 --- a/application/models/ressource/Zeitsperretyp_model.php +++ b/application/models/ressource/Zeitsperretyp_model.php @@ -11,4 +11,20 @@ class Zeitsperretyp_model extends DB_Model $this->dbTable = 'campus.tbl_zeitsperretyp'; $this->pk = 'zeitsperretyp_kurzbz'; } + + public function getTypenZeitsperren($excludedTypes = []) + { + $whereNotClause = ""; + if (count($excludedTypes)) { + $excludedTypes = array_map( + function ($type) { + return "'$type'"; + }, + $excludedTypes + ); + $excludedTypes = implode(", ", $excludedTypes); + $whereNotClause = "WHERE NOT zeitsperretyp_kurzbz IN ($excludedTypes)"; + } + return $this->execQuery("SELECT * FROM $this->dbTable $whereNotClause ORDER BY beschreibung ASC"); + } } diff --git a/application/models/vertragsbestandteil/Gehaltsbestandteil_model.php b/application/models/vertragsbestandteil/Gehaltsbestandteil_model.php index 2669105a1..3918e98e1 100644 --- a/application/models/vertragsbestandteil/Gehaltsbestandteil_model.php +++ b/application/models/vertragsbestandteil/Gehaltsbestandteil_model.php @@ -41,6 +41,7 @@ with gbt as ( gb.anmerkung, gb.dienstverhaeltnis_id, gb.gehaltstyp_kurzbz, + gb.gehaltsanpassungtyp_kurzbz, gb.valorisierungssperre, gb.valorisierung, gb.grundbetrag as grund_betrag_decrypted, @@ -76,11 +77,13 @@ select gbt.anmerkung, gbt.dienstverhaeltnis_id, gbt.gehaltstyp_kurzbz, + gbt.gehaltsanpassungtyp_kurzbz, gbt.valorisierungssperre, gbt.valorisierung, gbt.grund_betrag_decrypted, gbt.betrag_val_decrypted, gt.bezeichnung as gehaltstyp_bezeichnung, + ga.bezeichnung as gehaltsanpassungtyp_bezeichnung, vb.vertragsbestandteiltyp_kurzbz, bf.funktion_kurzbz, bf.oe_kurzbz, @@ -93,6 +96,8 @@ from gbt LEFT JOIN hr.tbl_gehaltstyp gt using(gehaltstyp_kurzbz) +LEFT JOIN + hr.tbl_gehaltsanpassungtyp ga using(gehaltsanpassungtyp_kurzbz) LEFT JOIN hr.tbl_vertragsbestandteil vb using(vertragsbestandteil_id) LEFT JOIN @@ -177,7 +182,8 @@ LEFT JOIN $stichtagclause .= ')'; } - $this->addSelect('*'); + $this->addSelect('tbl_gehaltsbestandteil.*, tbl_gehaltstyp.bezeichnung AS gehaltstyp_bezeichnung'); + $this->addJoin('hr.tbl_gehaltstyp', 'gehaltstyp_kurzbz'); $where = <<escape($dienstverhaeltnis_id)} {$stichtagclause} @@ -202,6 +208,7 @@ SELECT g.dienstverhaeltnis_id, g.vertragsbestandteil_id, g.gehaltstyp_kurzbz, + g.gehaltsanpassungtyp_kurzbz, g.von, g.bis, g.anmerkung, @@ -213,9 +220,11 @@ SELECT g.updateamum, g.updatevon, g.valorisierung, - g.auszahlungen + g.auszahlungen, + gt.bezeichnung AS gehaltstyp_bezeichnung FROM - hr.tbl_gehaltsbestandteil g + hr.tbl_gehaltsbestandteil g + JOIN hr.tbl_gehaltstyp gt USING (gehaltstyp_kurzbz) LEFT JOIN hr.tbl_valorisierung_historie vh ON vh.gehaltsbestandteil_id = g.gehaltsbestandteil_id AND vh.valorisierungsdatum = ( SELECT @@ -263,7 +272,7 @@ EOSQL; // Note: replaced gb.betrag_valorisiert with vh.betrag_valorisiert! $qry = " SELECT - gb.gehaltsbestandteil_id,gb.dienstverhaeltnis_id,gb.vertragsbestandteil_id,gb.gehaltstyp_kurzbz, + gb.gehaltsbestandteil_id,gb.dienstverhaeltnis_id,gb.vertragsbestandteil_id,gb.gehaltstyp_kurzbz,gb.gehaltsanpassungtyp_kurzbz, gb.von,gb.bis,gb.anmerkung,gb.grundbetrag as grundbetrag,gb.valorisierungssperre,gb.insertamum, gb.insertvon,gb.updateamum,gb.updatevon,gb.valorisierung,gb.auszahlungen, vh.valorisierungsdatum, vh.betrag_valorisiert as betrag_valorisiert diff --git a/application/views/CisRouterView/CisRouterView.php b/application/views/CisRouterView/CisRouterView.php index 1aa8c398e..530379308 100644 --- a/application/views/CisRouterView/CisRouterView.php +++ b/application/views/CisRouterView/CisRouterView.php @@ -11,7 +11,6 @@ $includesArray = array( 'skipID' => '#fhccontent', 'vuedatepicker11' => true, 'customCSSs' => array( - 'vendor/vuejs/vuedatepicker_css/main.css', 'public/css/components/verticalsplit.css', 'public/css/components/searchbar/searchbar.css', 'public/css/Fhc.css', @@ -27,6 +26,8 @@ $includesArray = array( 'public/css/Cis4/Studium.css', 'public/css/Cis4/Benotungstool.css', 'public/css/Cis4/Zeitsperren.css', + 'public/css/components/Zeitsperren.css', //70747 + 'public/css/Cis4/Zeitsperren.css', //77941 ), 'customJSs' => array( 'vendor/npm-asset/primevue/accordion/accordion.min.js', diff --git a/application/views/Vertragsverwaltung.php b/application/views/Vertragsverwaltung.php index 8fa6dff27..4a73be945 100644 --- a/application/views/Vertragsverwaltung.php +++ b/application/views/Vertragsverwaltung.php @@ -12,13 +12,14 @@ $includesArray = array( 'tinymce5' => true, 'phrases' => array( 'global', - 'ui', + 'ui' ), 'customCSSs' => [ 'public/css/components/vue-datepicker.css', 'public/css/components/primevue.css', 'public/css/Vertragsverwaltung.css', - 'public/css/components/Detailheader.css' + 'public/css/components/Detailheader.css', + 'public/css/components/Zeitsperren.css' ], 'customJSs' => [ #'vendor/npm-asset/primevue/tree/tree.min.js', diff --git a/cis/private/lehre/benotungstool/lvgesamtnoteverwalten.php b/cis/private/lehre/benotungstool/lvgesamtnoteverwalten.php index 553e3f03c..3b3689144 100644 --- a/cis/private/lehre/benotungstool/lvgesamtnoteverwalten.php +++ b/cis/private/lehre/benotungstool/lvgesamtnoteverwalten.php @@ -1824,7 +1824,7 @@ $htmlstring .= " "; -if ($anzahlChanged > 0) +if (isset($anzahlChanged) && $anzahlChanged > 0) echo '
' . $p->t('benotungstool/geaenderteNotenVorhanden') . '
'; echo $htmlstring; diff --git a/cypress.config.js b/cypress.config.js new file mode 100644 index 000000000..4e250bae4 --- /dev/null +++ b/cypress.config.js @@ -0,0 +1,47 @@ +const { defineConfig } = require("cypress"); + +require("dotenv").config({ path: "tests/cypress/.env" }); + +// The suite being run: its cy.task handlers and its Cypress.env() values. +const suite = require("./tests/cypress/suites/noten"); + +module.exports = defineConfig({ + // Cypress 15 deprecates Cypress.env() inside cy.request options, which the API helpers use. + allowCypressEnv: true, + + // The suite lives under tests/cypress, so every default folder has to be pointed there. + fixturesFolder: "tests/cypress/fixtures", + screenshotsFolder: "tests/cypress/screenshots", + videosFolder: "tests/cypress/videos", + downloadsFolder: "tests/cypress/downloads", + + e2e: { + baseUrl: process.env.BASE_URL, + specPattern: "tests/cypress/e2e/**/*.cy.{js,jsx,ts,tsx}", + supportFile: "tests/cypress/support/e2e.js", + defaultCommandTimeout: 20000, + pageLoadTimeout: 20000, + retries: { runMode: 0, openMode: 0 }, + + setupNodeEvents(on, config) { + // No defaults anywhere: an empty .env fails the run instead of hitting someone else's instance. + const missing = ["BASE_URL", "USER_NAME", "USER_PASSWORD"].filter((key) => !process.env[key]); + if (missing.length) throw new Error(`Missing in tests/cypress/.env: ${missing.join(", ")}`); + + suite.registerTasks(on); + + // otherwise the SSH forward outlives Cypress + on("after:run", () => require("./tests/cypress/tasks/sshTunnel").closeTunnel()); + + return config; + }, + }, + + env: { + // HTTP Basic + LDAP user the API calls authenticate as. + adminusername: process.env.USER_NAME, + adminpassword: process.env.USER_PASSWORD, + + ...suite.env, + }, +}); diff --git a/package.json b/package.json new file mode 100644 index 000000000..66b7246c1 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "scripts": { + "cy:open": "cypress open", + "cy:run": "cypress run", + "cy:noten": "cypress run --spec \"tests/cypress/e2e/specs/**/*.cy.js\"", + "cy:noten:api": "cypress run --spec \"tests/cypress/e2e/specs/{unit,api}/**/*.cy.js\"", + "cy:noten:ui": "cypress run --browser chrome --spec \"tests/cypress/e2e/specs/ui/**/*.cy.js\"", + "cy:noten:ui:watch": "cypress run --browser chrome --headed --no-exit --spec \"tests/cypress/e2e/specs/ui/**/*.cy.js\"", + "cy:noten:punkte-on": "cypress run --browser chrome --env NOTEN_PUNKTE_MODUS=on --spec \"tests/cypress/e2e/specs/**/*.cy.js\"", + "cy:noten:punkte-off": "cypress run --browser chrome --env NOTEN_PUNKTE_MODUS=off --spec \"tests/cypress/e2e/specs/**/*.cy.js\"", + "cy:noten:p0": "cypress run --spec \"tests/cypress/e2e/specs/unit/notenRules.cy.js,tests/cypress/e2e/specs/api/noten/noten.pruefungsantritte.cy.js,tests/cypress/e2e/specs/api/noten/noten.pruefungstermin.cy.js,tests/cypress/e2e/specs/api/noten/noten.ueberschreiben.cy.js\"", + "noten:check": "node tests/cypress/tools/dbCheck.js noten" + }, + "dependencies": { + "cypress": "^15.11.0", + "dotenv": "^17.4.2", + "pg": "^8.13.1", + "ssh2": "^1.17.0" + } +} diff --git a/public/css/Cis4/Benotungstool.css b/public/css/Cis4/Benotungstool.css index 83a026d0e..225cfadf8 100644 --- a/public/css/Cis4/Benotungstool.css +++ b/public/css/Cis4/Benotungstool.css @@ -62,9 +62,63 @@ transform: translateY(-50%) rotate(180deg); } -.pruefung-badge { +/* + * Prueufungszelle: feste Spuren fuer Note | Datum | Aktion. + * Vorher war das eine zentrierte Flex-Zeile, dadurch verschoben laengere Notenbezeichnungen + * ("Nicht Genuegend" vs "Genuegend") Datum und Button - die Zellen standen von Zeile zu Zeile + * nicht mehr untereinander. Mit festen Spurbreiten fluchten sie immer. + */ +.pruefung-cell { position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr) 6.5rem; /* Note | Aktion */ + align-items: center; + column-gap: 0.5rem; + height: 100%; padding-left: 32px; /* badge width (20) + its left offset (6) + gap (6) */ + padding-right: 6px; + text-align: left; +} + +/* Spaltenmodus 'antritt': das Datum steht in der Zelle, nicht im Spaltenkopf */ +.pruefung-cell.mit-datum { + grid-template-columns: minmax(0, 1fr) 4.75rem 6.5rem; /* Note | Datum | Aktion */ +} + +/* + * Zellen ohne Aktion (abschliessender Termin, wird in einem anderen Tool gepflegt): die Spur fuer + * den Button entfaellt, der Platz gehoert der Notenbezeichnung. + */ +.pruefung-cell.ohne-aktion { + grid-template-columns: minmax(0, 1fr); /* Note */ +} + +.pruefung-cell.ohne-aktion.mit-datum { + grid-template-columns: minmax(0, 1fr) 4.75rem; /* Note | Datum */ +} + +.pruefung-cell .pruefung-note { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pruefung-cell .pruefung-datum { + font-size: 0.75rem; + white-space: nowrap; + text-align: right; + opacity: 0.7; +} + +/* immer in der letzten Spur, auch wenn Note und Datum fehlen (leere Zelle mit "Hinzufuegen") */ +.pruefung-cell .pruefung-action { + grid-column: -2 / -1; + display: flex; + justify-content: flex-end; +} + +.pruefung-cell .pruefung-action .btn { + white-space: nowrap; } .pruefung-badge::before { @@ -87,4 +141,6 @@ .pruefung-badge.attempt-t1::before { background: #0072B2; } .pruefung-badge.attempt-t2::before { background: #E69F00; } .pruefung-badge.attempt-t3::before { background: #CC79A7; } -.pruefung-badge.attempt-k::before { background: #D55E00; } \ No newline at end of file +.pruefung-badge.attempt-k::before { background: #D55E00; } +/* Termin ohne verbrauchten Antritt (entschuldigt, nicht beurteilt) - traegt keine Nummer */ +.pruefung-badge.attempt-none::before { background: #767676; } \ No newline at end of file diff --git a/public/css/components/Detailheader.css b/public/css/components/Detailheader.css index dcfcf6806..e9ebddfc1 100644 --- a/public/css/components/Detailheader.css +++ b/public/css/components/Detailheader.css @@ -62,3 +62,16 @@ top:13%;" z-index: 104; font-size: 1rem; width: 2.5rem; height: 2.5rem; opacity:0; transition: opacity 0.2s; top:13%;" }*/ + +.tag-hover:hover { + background-color: #e9ecef; +} + +.green { + background-color: #e9ecef; +} + +.btn-hover { + --bs-btn-hover-bg: #e9ecef; /* Neue Hintergrundfarbe beim Hover */ + --bs-btn-hover-border-color: #e9ecef; /* Neue Rahmenfarbe beim Hover */ +} diff --git a/public/css/components/Zeitsperren.css b/public/css/components/Zeitsperren.css new file mode 100644 index 000000000..3e5e4a632 --- /dev/null +++ b/public/css/components/Zeitsperren.css @@ -0,0 +1,3 @@ +.item-inactive { + text-decoration: line-through; +} \ No newline at end of file diff --git a/public/css/tags.css b/public/css/tags.css index 0634c5ce3..0230cfbda 100644 --- a/public/css/tags.css +++ b/public/css/tags.css @@ -145,3 +145,8 @@ float: right; margin-top: 3px; } + +.btn-hover { + --bs-btn-hover-bg: #e9ecef; + --bs-btn-hover-border-color: #e9ecef; +} diff --git a/public/js/api/factory/calendar/roomPlan.js b/public/js/api/factory/calendar/roomPlan.js index d27843b37..dc8aee207 100644 --- a/public/js/api/factory/calendar/roomPlan.js +++ b/public/js/api/factory/calendar/roomPlan.js @@ -40,5 +40,14 @@ export default { reservierung_id: reservierung_id } }; + }, + getGroupOptions(studiengangKz) { + return { + method: 'get', + url: '/api/frontend/v1/calendar/RoomPlan/getGroupOptions', + params: { + studiengangKz + } + }; } } \ No newline at end of file diff --git a/public/js/api/factory/noten.js b/public/js/api/factory/noten.js index d0b8940ff..bf78a2d3b 100644 --- a/public/js/api/factory/noten.js +++ b/public/js/api/factory/noten.js @@ -49,18 +49,30 @@ export default { params: { lv_id, sem_kurzbz, student_uid, note, punkte } }; }, - saveStudentPruefung(student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, typ, pruefung_id = null){ + // kein Termintyp mehr: welchen Antritt die Prüfung darstellt, leitet der Server aus dem + // bestehenden Prüfungsverlauf des Studenten in dieser LV und diesem Semester ab + // mitarbeiter_uid: der benotende Lektor. Nur nötig, wenn die Lehreinheit mehrere hat - sonst + // löst der Server ihn selbst auf. + saveStudentPruefung(student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, pruefung_id = null, mitarbeiter_uid = null){ return { method: 'post', url: '/api/frontend/v1/Noten/saveStudentPruefung', - params: { student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, typ, pruefung_id } + params: { student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, pruefung_id, mitarbeiter_uid } }; }, - createPruefungen(uids, datum, lva_id, sem_kurzbz){ + // note/punkte optional: ohne Auswahl wird der Termin als "noch nicht eingetragen" angelegt + createPruefungen(uids, datum, lva_id, sem_kurzbz, note = null, punkte = null, mitarbeiter_uid = null){ return { method: 'post', url: '/api/frontend/v1/Noten/createPruefungen', - params: { uids, datum, lva_id, sem_kurzbz } + params: { uids, datum, lva_id, sem_kurzbz, note, punkte, mitarbeiter_uid } + }; + }, + getLehrendeFuerLehreinheit(lehreinheit_id, lv_id, sem_kurzbz) { + return { + method: 'get', + url: '/api/frontend/v1/Noten/getLehrendeFuerLehreinheit', + params: { lehreinheit_id, lv_id, sem_kurzbz } }; }, saveNotenvorschlagBulk(lv_id, sem_kurzbz, noten) { @@ -83,5 +95,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/api/factory/ort.js b/public/js/api/factory/ort.js index 2e06d6b71..dce61279d 100644 --- a/public/js/api/factory/ort.js +++ b/public/js/api/factory/ort.js @@ -18,23 +18,32 @@ export default { getContentID(ort_kurbz) { return { - method: 'get', - url: '/api/frontend/v1/Ort/ContentID', - params: { ort_kurzbz: ort_kurbz } + method: "get", + url: "/api/frontend/v1/Ort/ContentID", + params: { ort_kurzbz: ort_kurbz }, }; }, getRooms(datum, von, bis, typ, personenanzahl = 0) { return { - method: 'get', - url: '/api/frontend/v1/Ort/getRooms', - params: { datum, von, bis, typ, personenanzahl } + method: "get", + url: "/api/frontend/v1/Ort/getRooms", + params: { datum, von, bis, typ, personenanzahl }, }; }, getRoomTypes() { return { - method: 'get', - url: '/api/frontend/v1/Ort/getTypes', - params: { } + method: "get", + url: "/api/frontend/v1/Ort/getTypes", + params: {}, }; - } -}; \ No newline at end of file + }, + getRoomInfo(ortKurzbz) { + return { + method: "get", + url: "/api/frontend/v1/Ort/getRoomInfo", + params: { + ortKurzbz, + }, + }; + }, +}; diff --git a/public/js/api/factory/widget/studstatus.js b/public/js/api/factory/widget/studstatus.js new file mode 100644 index 000000000..96f55652a --- /dev/null +++ b/public/js/api/factory/widget/studstatus.js @@ -0,0 +1,25 @@ +/** + * Copyright (C) 2026 fhcomplete.org + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +export default { + getTodos() { + return { + method: 'get', + url: '/api/frontend/v1/StudstatusAntraege/getTodos' + }; + }, +}; diff --git a/public/js/api/factory/zeitsperren.js b/public/js/api/factory/zeitsperren.js new file mode 100644 index 000000000..325bcc0c5 --- /dev/null +++ b/public/js/api/factory/zeitsperren.js @@ -0,0 +1,78 @@ +/** + * Copyright (C) 2026 fhcomplete.org + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +export default { + loadAllActiveZeitsperren(days) { + return { + method: 'get', + url:'api/frontend/v1/MaZeitsperren/getAllActiveZeitsperren/' + days + }; + }, + loadAllZeitsperrenFixeMa(days) { + return { + method: 'get', + url:'api/frontend/v1/MaZeitsperren/getAllZeitsperrenFixeMa/' + days + }; + }, + loadAllZeitsperrenLector(days) { + return { + method: 'get', + url:'api/frontend/v1/MaZeitsperren/getAllZeitsperrenLector/' + days + }; + }, + getAllOes(){ + return { + method: 'get', + url:'api/frontend/v1/funktionen/Funktionen/getAllOrgUnits' + }; + }, + loadAllZeitsperrenOE(days, oe) { + return { + method: 'get', + url:'api/frontend/v1/MaZeitsperren/getAllZeitsperrenOes/' + days + '/' + oe, + }; + }, + loadZeitsperrenAss(days) { + return { + method: 'get', + url:'api/frontend/v1/MaZeitsperren/getZeitsperrenAss/' + days + }; + }, + getAllStg(){ + return { + method: 'get', + url:'api/frontend/v1/MaZeitsperren/getStgLectors' + }; + }, + loadZeitsperrenLectorStg(days,stg) { + return { + method: 'get', + url:'api/frontend/v1/MaZeitsperren/loadZeitsperrenLectorStg/' + days + '/' + stg, + }; + }, + loadZeitsperrenMa(days, uid) { + return { + method: 'get', + url:'api/frontend/v1/MaZeitsperren/loadZeitsperrenMa/' + days + '/' + uid, + }; + }, + getDetailsMa(uid){ + return { + method: 'get', + url:'api/frontend/v1/MaZeitsperren/getDetailsMa/' + uid, + }; + } +} \ No newline at end of file diff --git a/public/js/components/Calendar/LvPlan.js b/public/js/components/Calendar/LvPlan.js index 4f240cd73..1d2efb9ab 100644 --- a/public/js/components/Calendar/LvPlan.js +++ b/public/js/components/Calendar/LvPlan.js @@ -208,6 +208,9 @@ export default { > {{ $p.t('lehre/noLvFound') }} +
+ +
{{ $p.t('lehre/noLvFound') }}
+
+ +
@@ -98,6 +122,7 @@ export default { :is="renderers[event.type]?.calendarEvent" :event="event" :timeSlotDisplayBehavior="'always'" + @delete-event="(event) => deleteEvent(event)" >
diff --git a/public/js/components/Cis/Abgabetool/AbgabetoolAssistenz.js b/public/js/components/Cis/Abgabetool/AbgabetoolAssistenz.js index a2fdb6551..3d8fc1d79 100644 --- a/public/js/components/Cis/Abgabetool/AbgabetoolAssistenz.js +++ b/public/js/components/Cis/Abgabetool/AbgabetoolAssistenz.js @@ -1436,20 +1436,41 @@ export const AbgabetoolAssistenz = { const table = this.$refs.abgabeTable.tabulator this.tableBuiltResolve() + + table.on("columnMoved", () => { + this.saveState(table); + }); + + table.on("columnResized", () => { + this.saveState(table); + }); + + table.on("columnVisibilityChanged", () => { + this.saveState(table); + }); + + table.on("filterChanged", () => { + this.saveState(table); + }); + + table.on("headerFilterChanged", () => { + this.saveState(table); + }); + + table.on("dataSorted", () => { + this.saveState(table); + }); + + table.on("columnSorted", () => { + this.saveState(table); + }); + + table.on("sortersChanged", () => { + this.saveState(table); + }); const saved = this.loadState(); - // setup change eventlisteners - const events = [ - "columnMoved", "columnResized", "columnVisibilityChanged", - "filterChanged", "headerFilterChanged", "dataSorted", - "columnSorted", "sortersChanged" - ]; - - events.forEach(eventName => { - table.on(eventName, () => this.saveState(table)); - }); - table.on("renderComplete", () => { if(!this.stateRestored) { diff --git a/public/js/components/Cis/Benotungstool/Benotungstool.js b/public/js/components/Cis/Benotungstool/Benotungstool.js index cb2af33e7..a4272a597 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'; @@ -12,6 +10,7 @@ import NotenlisteLinks from "./NotenlisteLinks.js"; import FhcOverlay from "../../Overlay/FhcOverlay.js"; import {debounce} from "../../../helpers/debounce.js"; import {centeredTextFormatter} from "../../../tabulator/formatter/centered.js"; +import * as NotenRules from "./notenRules.js"; export const Benotungstool = { name: "Benotungstool", @@ -42,7 +41,6 @@ export const Benotungstool = { }, data() { return { - uid: null, headerFiltersRestored: false, filtersRestored: false, filteredRows: null, @@ -66,12 +64,22 @@ export const Benotungstool = { selectedPruefungNote: null, selectedPruefungDate: new Date(), // v-model for pruefung edit datepicker selectedPruefungPunkte: null, - pruefungNoteLocked: false, // grade read-only when a later/higher pruefung exists (date stays editable) + pruefungNoteLocked: false, // grade read-only when a later pruefung exists (date stays editable) pruefungDateMin: null, pruefungDateMax: null, distinctPruefungsDates: null, + // The column layout of the exams: 'antritt' (one column for each attempt number) or 'datum' + // (one column for each exam date). null uses the default from the configuration. The browser + // keeps the choice of the user. + pruefungsspalten: localStorage.getItem('notenToolPruefungsspalten'), + // The students of the current entry that have no course grade. The new exam creates the + // grade, and the dialog shows a hint before that happens. pruefungStudent: null, pruefung: null, + // Lehrende der betroffenen Lehreinheit. Die Prüfung trägt den Lektor, nicht den, der sie + // einträgt - bei mehreren Lehrenden wählt der Benutzer aus. + lehrendeDerLehreinheit: [], + selectedLehrender: null, password: '', changedNotenCounter: 0, tableVersion: 0, // incremented on table sort/filter/data changes so getStudentenOptions mirrors the table @@ -85,6 +93,9 @@ export const Benotungstool = { pruefungen: null, studiensemester: null, selectedSemester: null, + isAssistenz: false, + assistenzStudiengaenge: null, + selectedStudiengang: null, lehrveranstaltungen: null, selectedLehrveranstaltung: null, tableBuiltResolve: null, @@ -206,10 +217,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"); }, @@ -248,9 +255,8 @@ export const Benotungstool = { localStorage.setItem(this.persistenceID, JSON.stringify(state)); }, stickyClass(field) { - // the freezable identity columns always carry the sticky-col class; whether they are - // actually sticky is toggled per column via container classes (see applyStickyColumnState), - // which avoids re-running updateDefinition on columns that have reactive (Vue.computed) titles + // Klasse liegt immer an; ob wirklich sticky, schaltet applyStickyColumnState per + // container class, because updateDefinition breaks the Vue.computed titles return this.freezableColumnFields.includes(field) ? 'sticky-col' : undefined }, recomputeStickyOffsets() { @@ -312,7 +318,7 @@ export const Benotungstool = { table.on(eventName, () => this.recomputeStickyOffsets()); }); - // keep the "neue Prüfung" dropdown order in sync with the table's current sort + // the "neue Prüfung" dropdown uses the same order as the table table.on("dataSorted", () => this.tableVersion++); // renderComplete restore state logic @@ -456,25 +462,14 @@ export const Benotungstool = { } }, validatePruefungBulk(pruefungen) { - // need to check pruefungen for validity in respect to the students nr of antritte - // pruefungsdatum will be validated aswell so we dont get a termin 3 chronologically before - // a termin 2 which is totally possible in the old tool + // Do not send a row that cannot work. A missing course grade is no problem, because the + // imported grade creates it. const validatedPruefungen = [] pruefungen.forEach( p => { const student = this.studenten.find(s => s.uid === p.uid) - // if a student doesnt have a lv_note entered definitely dont allow to create pruefung - if(!student.lv_note) { - this.$fhcAlert.alertWarning('Student ' + student.uid + ' hat noch keine LV-Note eingetragen! Es wird keine Prüfung angelegt.') - return - } - - // note: a missing Zeugnisnote is intentionally allowed here so that a dated import can - // create the first attempt for not-yet-graded students (Termin1 falls back to - // "noch nicht eingetragen" server-side). Only the LV-note is mandatory. - // check if student antrittCount is too high already - if(student.hoechsterAntritt >= this.maxAntrittCount) { + if(!this.canAddPruefung(student)) { this.$fhcAlert.alertWarning('Student ' + student.uid + ' hat bereits ' + student.hoechsterAntritt + ' Prüfungsantritte abgelegt. Die Zeile wurde übersprungen.') return } @@ -574,41 +569,34 @@ export const Benotungstool = { } } - const typ = this.getPruefungstypForStudentByAntritt(student) - - pruefungbulk.push({uid: student.uid, datum: dateStr, note, punkte, typ, lehreinheit_id: student.lehreinheit_id, dateObj}) + pruefungbulk.push({uid: student.uid, datum: dateStr, note, punkte, lehreinheit_id: student.lehreinheit_id, dateObj}) }, saveNotenBulk(notenbulk) { this.loading = true this.$api.call(ApiNoten.saveNotenvorschlagBulk(this.lv_id, this.sem_kurzbz, notenbulk)).then(res => { if(res.meta.status === 'success') { + // the answer uses the uid as the key: a course grade or an error message for each row + let errorList = '' + + Object.keys(res.data ?? {}).forEach(uid => { + const lvn = res.data[uid] + const s = this.studenten.find(s => s.uid === uid) + if(!s) return + + if(typeof lvn === 'string') { errorList += lvn + '\n'; return } + + s.note_vorschlag = lvn.note + this.applyLvGesamtnote(s, lvn) + }) + + if(errorList !== '') this.$fhcAlert.alertError(errorList) + this.$fhcAlert.alertDefault( 'success', 'Info', this.$capitalize(this.$p.t('benotungstool/notenImportSuccessAlert')), true ) - const lvNoten = res.data - - - lvNoten.forEach(lvn => { - // 1.) get relevant student row by uid - const s = this.studenten.find(s => s.uid === lvn.student_uid) - s.note_vorschlag = lvn.note // TODO: check if note_vorschlag should be changed by import - - s.lv_note = lvn.note - if(this.config?.CIS_GESAMTNOTE_PUNKTE) { - s.punkte = lvn.punkte - } - - this.teilnoten[s.uid].note_lv = lvn.note - // recalculate freigabestatus - s.freigabedatum = this.parseDate(lvn['freigabedatum']) - s.benotungsdatum = this.parseDate(lvn['benotungsdatum']) - - s.freigegeben = this.checkFreigabe(s.freigabedatum, s.benotungsdatum, s.uid); - }) - } this.$refs.notenTable.tabulator.redraw(true) @@ -621,6 +609,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) { + errorList += entry + '\n' + } + }) + if(errorList !== '') { + this.$fhcAlert.alertError(errorList) + } + this.$fhcAlert.alertDefault( 'success', 'Info', @@ -634,176 +634,275 @@ export const Benotungstool = { handleAddNewPruefungenResponse(res, uids) { // in case we reload when changing lva_id or stsem to always consider local storage layout this.colLayoutRestored = false; - + 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 student = this.studenten.find(s => s.uid == entry.uid) if(!student) return - // check for extra pruefung (termin1) to add before - if(extra) { - student["Termin1"] = extra - student.pruefungen.push(extra) // push to pruefungen array for antritt evaluation - } + // savedPruefung is the proof of success, not verlauf. The server sends verlauf also + // after a failed insert. The caller shows the error messages. + if(!rowResult?.savedPruefung || !rowResult?.verlauf) return - this.correctOldTerminTypenForStudent(student, saved) - - if(!this.distinctPruefungsDates.includes(saved.datum)) { - this.insertSortedDate(this.distinctPruefungsDates, saved.datum) - } - - // add pruefung to pruefungen array - student.pruefungen.push(saved) - - // add pruefung to student via its datum as a field - student[saved.datum] = saved - - // usually should be in order naturally, just to be save - student.pruefungen.sort((p1, p2) => { - if(p1.datum > p2.datum) { - return 1 - } else if (p1.datum < p2.datum) { - return -1 - } else { - return 0 - } - }) - - // recalculate student antritte - student.hoechsterAntritt = this.getAntrittCountStudent(student) - this.recalculateSelectable(student) - this.reformatStudentRow(student) + this.applyLvGesamtnote(student, rowResult.lvgesamtnote) + this.applyVerlauf(student, rowResult.verlauf) }) - // add col to table - const cols = [...this.notenTableOptions.columns.slice(0, -1)]; - let kommCol = null - if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) kommCol = this.notenTableOptions.columns[this.notenTableOptions.columns.length - 1]; - - if(this.distinctPruefungsDates.length) { - cols.push({ - title: this.$capitalize(this.$p.t('benotungstool/c4originalZnote')), - field: "Termin1", - formatter: this.pruefungFormatter, - titleFormatter: this.pruefungTitleFormatter, - topCalc: this.terminCalcFunc, - topCalcFormatter: this.terminCalcFormatter, - hozAlign:"center", - widthGrow: 1, - minWidth: 200, - width: 250, - visible: true, - tooltip: false - }) - } - - this.distinctPruefungsDates.forEach((date)=>{ - const dateparts = date.split('-') - const titledate = `${dateparts[2]}.${dateparts[1]}.${dateparts[0]}` - - cols.push({ - title: titledate,//this.$p.t('benotungstool/pruefungNr', [index+1]), - field: date, - formatter: this.pruefungFormatter, - titleFormatter: this.pruefungTitleFormatter, - topCalc: this.terminCalcFunc, - topCalcFormatter: this.terminCalcFormatter, - hozAlign:"center", - widthGrow: 1, - minWidth: 200, - width: 250, - visible: true, - tooltip: false - }) - }) - - if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) cols.push(kommCol) // keep kommPruef Col as last - // redraw table - - - // preemptively do the persistence part of columns already here so we get the merged definition before - // tabulator internal go to war with vue reactives - let colsUsed = null - const saved = this.loadState(); - if (saved && saved.columns) { - // new columns map for lookup - const colMap = new Map(cols.map(c => [c.field, c])); - - const restoredCols = []; - - // add columns in the SAVED order, applying saved width/visibility - saved.columns.forEach(savedCol => { - const originalDef = colMap.get(savedCol.field); - if (originalDef) { - restoredCols.push({ - ...originalDef, // Keep formatters, calcs, etc. - width: savedCol.width, - visible: savedCol.visible - }); - colMap.delete(savedCol.field); // Remove so we don't double-add - } - }); - - // append any NEW columns aka pruefungs cols - colMap.forEach((def) => { - restoredCols.push(def); - }); - - colsUsed = restoredCols; - this.colLayoutRestored = true; - } - this.loading = false - - const colsFinal = colsUsed ?? cols - this.$refs.notenTable.tabulator.setColumns(colsFinal) - this.$refs.notenTable.tabulator.setData(this.studenten); - this.$refs.notenTable.tabulator.redraw(true); + + // keep the scroll position, so that the user still sees the same rows after a bulk entry + this.preserveScroll(() => { + this.applyPruefungColumns() + const loaded = this.$refs.notenTable.tabulator.setData(this.studenten); + this.$refs.notenTable.tabulator.redraw(true); + return loaded + }) + }, + /** Takes the course grade from the server answer. All entry paths use this method. */ + applyLvGesamtnote(student, lvgesamtnote) { + if(!student || !lvgesamtnote) return + + student.lv_note = lvgesamtnote.note + student.freigabedatum = this.parseDate(lvgesamtnote.freigabedatum) + student.benotungsdatum = this.parseDate(lvgesamtnote.benotungsdatum) + student.freigegeben = this.checkFreigabe(student.freigabedatum, student.benotungsdatum, student.uid) + + if(this.config?.CIS_GESAMTNOTE_PUNKTE) student.punkte = lvgesamtnote.punkte + + // teilnoten is the source of changedNoten (the counter and the list of the release) + if(this.teilnoten && this.teilnoten[student.uid]) { + this.teilnoten[student.uid].note_lv = lvgesamtnote.note + this.teilnoten[student.uid].punkte_lv = lvgesamtnote.punkte + this.teilnoten[student.uid]['freigabedatum'] = lvgesamtnote.freigabedatum + this.teilnoten[student.uid]['benotungsdatum'] = lvgesamtnote.benotungsdatum + } + + this.changedNotenCounter++ // computed changedNoten neu auswerten + }, + /** Builds the row again from the server history. The client counts no attempts itself. */ + applyVerlauf(student, verlauf) { + if(!verlauf) return + + // Take the full object. If you copy single fields, the other fields are absent (for + // example angerechnet and hatLvNote) and the user interface is wrong until the next load. + student.verlauf = {...verlauf} + delete student.verlauf.pruefungen + + student.pruefungen = [] + delete student['kommPruef'] + + ;(verlauf.pruefungen ?? []).forEach(p => { + p.dateObj = this.parseISODate(p.datum) + // the final exam has its own column, and that column is always the last one + if(p.kommissionell) student['kommPruef'] = p + else student.pruefungen.push(p) + }) + + // Update the flat list too. It decides if the user can edit the grade proposal and the + // points (see getColumnsDefinition). + this.pruefungen = (this.pruefungen ?? []).filter(p => p.student_uid !== student.uid) + ;(verlauf.pruefungen ?? []).forEach(p => this.pruefungen.push(p)) + + this.syncDistinctPruefungsDates() + this.indexPruefungen(student) + + student.hoechsterAntritt = this.getAntrittCountStudent(student) + this.recalculateSelectable(student) + this.reformatStudentRow(student) + }, + /** Derives the date columns from all rows. They are visible in the 'datum' mode only. */ + syncDistinctPruefungsDates() { + const dates = new Set() + this.studenten?.forEach(s => (s.pruefungen ?? []).forEach(p => dates.add(p.datum))) + this.distinctPruefungsDates = [...dates].sort() + }, + /** The field name of the column that shows one exam. */ + pruefungField(pruefung, index) { + if(pruefung.kommissionell) return 'kommPruef' + return this.pruefungsspaltenModus === 'antritt' ? ('antritt_' + (index + 1)) : pruefung.datum + }, + /** Puts the exams into the column fields. Remove the old fields first, or they stay behind. */ + indexPruefungen(student) { + (student._pruefungFields ?? []).forEach(f => { delete student[f] }) + + const fields = [] + student.pruefungen.forEach((p, i) => { + const field = this.pruefungField(p, i) + student[field] = p + fields.push(field) + }) + + student._pruefungFields = fields + }, + /** One definition for an exam column. It is the same in both column modes. */ + pruefungColumnDef(field, title) { + // must hold the fixed tracks of .pruefung-cell and a readable grade text + const minWidth = this.pruefungsspaltenModus === 'antritt' ? 320 : 250 + + return { + title, + field, + formatter: this.pruefungFormatter, + titleFormatter: this.pruefungTitleFormatter, + sorter: this.pruefungSorter, + topCalc: this.terminCalcFunc, + topCalcFormatter: this.terminCalcFormatter, + hozAlign: "center", + widthGrow: 1, + minWidth, + width: minWidth, + visible: true, + tooltip: false + } + }, + /** + * 'antritt' makes one column for each attempt number and puts the date into the cell. Use it + * if each student has an own exam date. 'datum' makes one column for each exam date. Use it + * if the students share the exam dates. + */ + buildPruefungColumns() { + if(this.pruefungsspaltenModus === 'antritt') { + let count = 0 + this.studenten?.forEach(s => { + // Add one more column while this row can get one more attempt. The kommissionelle + // attempt has its own column, so it needs no attempt column. + const kommissionellFaellig = s.verlauf?.naechsteRolle === 'kommissionell' + const frei = this.canAddPruefung(s) && !kommissionellFaellig ? 1 : 0 + const needed = (s.pruefungen?.length ?? 0) + frei + if(needed > count) count = needed + }) + + return Array.from({length: count}, (_, i) => + this.pruefungColumnDef('antritt_' + (i + 1), this.$capitalize(this.$p.t('benotungstool/c4antrittNr', [i + 1]))) + ) + } + + return (this.distinctPruefungsDates ?? []).map(date => + this.pruefungColumnDef(date, this.formatDatumDMY(date)) + ) + }, + /** + * The only place that builds the columns. setColumns builds the full table again, therefore + * call it only if the set of columns is different. + */ + applyPruefungColumns(force = false) { + const table = this.$refs.notenTable?.tabulator + if(!table || !this.notenTableOptions) return + + const pruefungCols = this.buildPruefungColumns() + const key = pruefungCols.map(c => c.field).join('|') + + if(!force && key === this._pruefungColumnKey) return + this._pruefungColumnKey = key + + const cols = [...this.notenTableOptions.columns.slice(0, -1)] + const kommCol = this.config?.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF + ? this.notenTableOptions.columns[this.notenTableOptions.columns.length - 1] + : null + + pruefungCols.forEach(c => cols.push(c)) + if(kommCol) cols.push(kommCol) // abschliessender Termin bleibt die letzte Spalte + + table.setColumns(this.restoreColumnLayout(cols)) + this.applyKommPruefSpalte() + }, + /** Blendet die kommissionelle Spalte ein oder aus. Nach jedem setColumns nötig. */ + applyKommPruefSpalte() { + if(!this.config?.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) return + + const col = this.$refs.notenTable?.tabulator?.getColumn("kommPruef") + if(!col) return + + if(this.kommPruefSpalteNoetig) col.show() + else col.hide() + }, + /** + * Keeps the scroll position while a table operation renders the table again. + * + * There are two traps. Read and write .tabulator-tableholder directly, because Tabulator does + * not update rowManager.scrollLeft horizontally. Set the position again in the next two + * frames, because Tabulator renders later and resets the position. Vue.nextTick is too early. + */ + preserveScroll(operation) { + const holder = this.$refs.notenTable?.tabulator?.element?.querySelector('.tabulator-tableholder') + + const left = holder?.scrollLeft ?? 0 + const top = holder?.scrollTop ?? 0 + + const result = operation() + + // nothing to restore, and do not work against a scroll to the top that the user wants + if(!holder || (!left && !top)) return result + + const restore = () => { + holder.scrollLeft = left + holder.scrollTop = top + } + + restore() + requestAnimationFrame(() => { restore(); requestAnimationFrame(restore) }) + + // setData gives a promise in Tabulator, therefore set the position again after the load + if(result && typeof result.then === 'function') result.then(() => requestAnimationFrame(restore)) + + return result + }, + /** + * Apply the layout that the browser kept BEFORE Tabulator takes the columns. After that the + * internal definitions and the Vue reactives are in conflict. + */ + restoreColumnLayout(cols) { + const saved = this.loadState() + if(!saved?.columns) return cols + + const colMap = new Map(cols.map(c => [c.field, c])) + const restored = [] + + // the columns in the SAVED order, with the saved width and visibility + saved.columns.forEach(savedCol => { + const originalDef = colMap.get(savedCol.field) + if(originalDef) { + restored.push({...originalDef, width: savedCol.width, visible: savedCol.visible}) + colMap.delete(savedCol.field) + } + }) + + colMap.forEach(def => restored.push(def)) // neue Spalten anhängen + + this.colLayoutRestored = true + return restored + }, + /** Changes the column mode. The browser keeps the choice. */ + setPruefungsspalten(modus) { + if(modus !== 'antritt' && modus !== 'datum') return + + this.pruefungsspalten = modus + localStorage.setItem('notenToolPruefungsspalten', modus) + + this.studenten?.forEach(s => this.indexPruefungen(s)) + + // keep the vertical position: all columns change, but the row stays the same + this.preserveScroll(() => { + this.applyPruefungColumns(true) + const loaded = this.$refs.notenTable?.tabulator?.setData(this.studenten) + this.$refs.notenTable?.tabulator?.redraw(true) + return loaded + }) }, reformatStudentRow(student) { const table = this.$refs.notenTable.tabulator if(!table) return - + const row = table.rowManager.getRowFromDataObject(student) + if(!row) return // Zeile noch nicht gerendert (zB direkt nach einem Datenwechsel) const rowComponent = row.getComponent() rowComponent.reformat() }, - correctOldTerminTypenForStudent(student, saved) { - // check if student has a preceding pruefung from same type and remove it since in this case - // the new pruefung will have overwritten the old one. - // EXCEPTION: an "entschuldigt" Termin must remain as its own dated entry (it was not an - // attempt, so the new pruefung is an additional dated record, not an overwrite of it). - const oldP = student.pruefungen.find(p => - p.pruefungstyp_kurzbz == saved.pruefungstyp_kurzbz - && p.pruefung_id != saved.pruefung_id - && !(this.config?.NOTE_ENTSCHULDIGT != null && p.note == this.config.NOTE_ENTSCHULDIGT) - ) - if(oldP) { - delete student[oldP.datum] // delete the variable col value - - const pIndex = student.pruefungen.indexOf(oldP) - if (pIndex > -1) { - student.pruefungen.splice(pIndex, 1) - } - // student.pruefungen.splice(student.pruefungen.indexOf(oldP), 1) // delete from pruefungen array - - // check if on that date any other student has a pruefung, if not delete it from distinctPruefungsDates - const other = this.studenten.find(s => s[oldP.datum] !== undefined) - if(!other) { - const dateIndex = this.distinctPruefungsDates.indexOf(oldP.datum); - if (dateIndex > -1) { - this.distinctPruefungsDates.splice(dateIndex, 1); - } - // this.distinctPruefungsDates.splice(this.distinctPruefungsDates.findIndex(oldP.datum), 1) - } - } - }, importPruefungen() { - // Prüfungsimport: every row must carry a date: "UID/Matrikelnr Datum Note". + // Exam import: each row must have a date: "UID/Matrikelnr Datum Note". // Each row creates a dated exam attempt. const rows = this.importString.split('\n') const bulk = [] @@ -888,13 +987,8 @@ export const Benotungstool = { }, selectableCheck(row, e) { - const data = row.getData(); - - if(data['kommPruef']) return false - else if(data.hoechsterAntritt >= this.maxAntrittCount) return false // 2 or 3 pruefungen counted - - return true; // student can be selected to add pruefung - + // a student is selectable if the student can get one more attempt + return this.canAddPruefung(row.getData()); }, getColumnsDefinition() { const columns = [] @@ -953,22 +1047,26 @@ export const Benotungstool = { handleClick: this.selectAllHandler }, width: 50, + minWidth: 50, cssClass: this.stickyClass('selectCol'), field: 'selectCol', title: '' }) - columns.push({title: 'UID', field: 'uid', tooltip: false, topCalc: this.sumCalcFunc, formatter: centeredTextFormatter, cssClass: this.stickyClass('uid')}) - columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4mail'))), field: 'email', formatter: this.mailFormatter, tooltip: false, visible: false, variableHeight: true}) - columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4antrittCountv2'))), field: 'hoechsterAntritt', formatter: centeredTextFormatter, tooltip: false}) - columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4vorname'))), field: 'vorname', formatter: centeredTextFormatter, headerFilter: true, tooltip: false, cssClass: this.stickyClass('vorname')}) - columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4nachname'))), field: 'nachname', formatter: centeredTextFormatter, headerFilter: true, cssClass: this.stickyClass('nachname')}) - columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4anwesenheitsquote'))), field: 'anwquote', formatter: this.percentFormatter}) - columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4mobility'))), field: 'mobility_zusatz', formatter: centeredTextFormatter, headerFilter: true, visible: false}) + // Minimum widths: a column with a header filter or an editor needs more space than its + // title. If not, the column starts too narrow and cuts the filter field and the values. + columns.push({title: 'UID', field: 'uid', tooltip: false, topCalc: this.sumCalcFunc, formatter: centeredTextFormatter, minWidth: 110, cssClass: this.stickyClass('uid')}) + columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4mail'))), field: 'email', formatter: this.mailFormatter, tooltip: false, visible: false, minWidth: 170, variableHeight: true}) + columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4antrittCountv2'))), field: 'hoechsterAntritt', formatter: centeredTextFormatter, tooltip: false, minWidth: 100}) + columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4vorname'))), field: 'vorname', formatter: centeredTextFormatter, headerFilter: true, tooltip: false, minWidth: 140, cssClass: this.stickyClass('vorname')}) + columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4nachname'))), field: 'nachname', formatter: centeredTextFormatter, headerFilter: true, minWidth: 140, cssClass: this.stickyClass('nachname')}) + columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4anwesenheitsquote'))), field: 'anwquote', formatter: this.percentFormatter, minWidth: 120}) + columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4mobility'))), field: 'mobility_zusatz', formatter: centeredTextFormatter, headerFilter: true, visible: false, minWidth: 140}) if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_MOODLE_LE_NOTE) { - columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4teilnoten'))), field: 'teilnote', formatter: this.teilnotenFormatter, variableHeight: true}) + columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4teilnoten'))), field: 'teilnote', formatter: this.teilnotenFormatter, minWidth: 160, variableHeight: true}) } if(this.config?.CIS_GESAMTNOTE_PUNKTE) { - columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4punkte'))), field: 'punkte', + columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4punkte'))), field: 'punkte', + minWidth: 110, editor: this.liveNumberEditor, editable: (cell) => { const rowData = cell.getRow().getData(); @@ -980,6 +1078,7 @@ export const Benotungstool = { }) } columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4notenvorschlag'))), field: 'note_vorschlag', + minWidth: 160, editor: 'list', editorParams: (cell) => { // write original cell value into row to it can be retrieved if edit is cancelled without selection @@ -1018,10 +1117,11 @@ export const Benotungstool = { return '
' + val + '
' } }) - columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4notenvorschlagUebernehmen'))), field: 'übernehmen', width: 150, hozAlign: 'center', formatter: this.arrowFormatter, - cellClick: this.saveNote, + columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4notenvorschlagUebernehmen'))), field: 'übernehmen', width: 150, minWidth: 100, hozAlign: 'center', formatter: this.arrowFormatter, + cellClick: this.saveNote, variableHeight: true}) columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4lvnote'))), field: 'lv_note', + minWidth: 160, formatter: this.notenFormatter, headerFilter: 'list', headerFilterParams: () => { @@ -1029,9 +1129,10 @@ export const Benotungstool = { }, headerFilterFunc: this.notenFilterFunc }) - columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4freigabe'))), field: 'freigegeben', formatter: this.freigabeFormatter, variableHeight: true}) + columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4freigabe'))), field: 'freigegeben', formatter: this.freigabeFormatter, minWidth: 130, variableHeight: true}) columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4zeugnisnote'))), field: 'note', + minWidth: 160, formatter: this.notenFormatter, topCalc: this.negativeNotenCalc, topCalcFormatter: this.negativeNotenCalcFormatter, @@ -1041,13 +1142,13 @@ export const Benotungstool = { }, headerFilterFunc: this.notenFilterFunc }) - columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4kommPruef'))), + columns.push({title: Vue.computed(() => this.kommPruefSpaltentitel), field: 'kommPruef', widthGrow: 1, formatter: this.pruefungFormatter, sorter: this.pruefungSorter, topCalc: this.terminCalcFunc, topCalcFormatter: this.terminCalcFormatter, - hozAlign:"center", minWidth: 150, visible: false, + hozAlign:"center", minWidth: 200, visible: false, tooltip: false }) @@ -1093,11 +1194,13 @@ export const Benotungstool = { }, fixTabulatorSelectionFormatter(row) { // if a row is not selectable, remove the checkbox from the dom - + const data = row.getData() - - const notSelectable = data.pruefungen?.find(p => p.pruefungstyp_kurzbz == 'kommPruef') || data.hoechsterAntritt >= this.maxAntrittCount - if(notSelectable) { + + // test hook: you can address a row by its uid (see tests/cypress/support/pages) + row.getElement().setAttribute('data-cy', 'student-row-' + data.uid) + + if(!this.canAddPruefung(data)) { const el = row.getElement() el.children[0]?.children[0]?.remove() @@ -1172,15 +1275,8 @@ export const Benotungstool = { const [hour, minute, second] = timePart.split(":").map(Number); return new Date(year, month - 1, day, hour, minute, second); }, - checkFreigabe(freigabedatum, benotungsdatum, uid) { - if(!freigabedatum) { - // check for change -> set freigabe to 'changed' on change - return 'offen' - } else if(benotungsdatum > freigabedatum) { - return 'changed' - } else { - return 'ok' - } + checkFreigabe(freigabedatum, benotungsdatum) { + return NotenRules.checkFreigabe(freigabedatum, benotungsdatum) }, unselectableFormatter(row) { @@ -1189,8 +1285,8 @@ export const Benotungstool = { const value = cell.getValue() const field = cell.getField() let style = 'display: flex; justify-content: start; align-items: center; height: 100%;'; - // Wenn sich die Zeugnisnote von der von Ihnen freigegebenen Note unterscheidet, - // wird erstere rot umrandet markiert. + // If the transcript grade is different from the released grade, the transcript grade gets + // a red border. const data = cell.getData() @@ -1206,53 +1302,46 @@ export const Benotungstool = { }, freigabeFormatter(cell) { const value = cell.getValue() - + let style = 'display: flex; justify-content: center; align-items: center; height: 100%;' - + + // data-state makes the state testable, and the test does not depend on the icon + const wrap = (icon) => + '
' + icon + '
' + if(value === 'ok') { - return '
' + - '
' + return wrap('') } else if (value === 'offen') { - return '
' + - '
' + return wrap('') } else if (value === 'changed') { - return '
' + - '
' + return wrap('') } - + return value }, - saveNote(e, cell) { // Notenvorschlag freigeben + async saveNote(e, cell) { // Notenvorschlag freigeben const row = cell.getRow() const data = row.getData() - + if(!data.note_vorschlag) return - - // if vorschlag is the same as lv_note do nothing if(data.note_vorschlag == data.lv_note) return - - // if the student already has pruefungen disable this part + + // as soon as exams exist, the grade belongs to the exam history if(data.pruefungen.length) return - + this.loading = true this.$api.call(ApiNoten.saveNotenvorschlag(this.lv_id, this.sem_kurzbz, data.uid, data.note_vorschlag, data.punkte)) .then((res) => { if (res.meta.status === 'success') { const s = this.studenten.find(s => s.uid === data.uid) - this.teilnoten[s.uid].note_lv = data.note_vorschlag - s.freigabedatum = this.parseDate(res.data[0]['freigabedatum']) - s.benotungsdatum = this.parseDate(res.data[0]['benotungsdatum']) - s.freigegeben = this.checkFreigabe(s.freigabedatum, s.benotungsdatum, s.uid); - - row.update({ lv_note: data.note_vorschlag, freigegeben: 'changed' }) - // row.update({ freigegeben: 'changed' }) + // the same path as after an exam, so that the release state is the same + this.applyLvGesamtnote(s, res.data[0]) + + row.update({ lv_note: s.lv_note, freigegeben: s.freigegeben }) row.reformat() // trigger reformat of arrow - this.changedNotenCounter++; } }).finally(()=>this.loading = false) - - }, teilnotenFormatter(cell) { const val = cell.getValue() @@ -1261,176 +1350,143 @@ export const Benotungstool = { return '
'+val+'
' }, - pruefungAttemptRank(typ) { - // ranking of the attempt types so we can compare "höhere" pruefungen - switch(typ) { - case 'Termin1': return 1 - case 'Termin2': return 2 - case 'Termin3': return 3 - case 'kommPruef': return 4 - default: return 0 - } + /** Tells you if this row can get one more attempt. The value comes from the server. */ + canAddPruefung(student) { + return NotenRules.canAddPruefung(student, this.config) }, - hasLaterOrHigherPruefung(student, pruefung) { - // a Termin2/3 grade may no longer be changed once the student already has a - // later-dated exam OR an exam of a higher attempt (e.g. a Termin3/kommPruef - // exists while looking at the Termin2). Mirrors the add-button's youngerPruefung guard. + hasLaterPruefung(student, pruefung) { + // an exam with a later date locks the grade; you can still correct the date if(!pruefung) return false - const currentRank = this.pruefungAttemptRank(pruefung.pruefungstyp_kurzbz) - const currentDate = (pruefung.datum ?? '').slice(0, 10) - return (student.pruefungen ?? []).some(p => { - if(p === pruefung) return false - if(p.pruefung_id != null && pruefung.pruefung_id != null && p.pruefung_id === pruefung.pruefung_id) return false - const laterDate = (p.datum ?? '').slice(0, 10) > currentDate - const higherRank = this.pruefungAttemptRank(p.pruefungstyp_kurzbz) > currentRank - return laterDate || higherRank - }) + const alle = [...(student.pruefungen ?? [])] + if(student.kommPruef) alle.push(student.kommPruef) + + return alle.some(p => p.pruefung_id != pruefung.pruefung_id && p.position > pruefung.position) }, + /** The cell of an exam column. indexPruefungen decides which attempt the cell shows. */ pruefungFormatter(cell) { const data = cell.getData() + // credited: the row is visible, but it gets no exams + if(data.verlauf?.angerechnet) return '' + + // A transcript grade that you cannot overwrite locks the exam columns. Without a transcript + // grade the columns stay open, because the first attempt starts there. const noteDef = data.note ? this.notenOptions.find(n => n.note == data.note) : null - if(!data.note || !noteDef?.lkt_ueberschreibbar) { - return '' - } - - const colDef = cell.getColumn().getDefinition() - - // column is just a date, student can have any of his antritte on this date, so we need to get - // student.pruefungen and look for a pruefung with this cols title as date + if(noteDef && !noteDef.lkt_ueberschreibbar) return '' const field = cell.getColumn().getField() - let studentPruefung = null - if(field === 'kommPruef') { studentPruefung = data['kommPruef'] } - else if(field === "Termin1") { studentPruefung = data['Termin1'] } - else { studentPruefung = data.pruefungen.find(p => p.datum === field) } + const studentPruefung = data[field] + + // in the attempt mode the column has no date, therefore the cell shows it + const antrittModus = this.pruefungsspaltenModus === 'antritt' + + // fixed tracks grade | date | action, so that they align in all rows + const rowDiv = document.createElement('div') + rowDiv.className = antrittModus ? 'pruefung-cell mit-datum' : 'pruefung-cell' + + const addSlot = (className, content) => { + const div = document.createElement('div') + div.className = className + + if(typeof content === 'string') div.textContent = content + else if(content instanceof HTMLElement) div.appendChild(content) + + rowDiv.appendChild(div) + return div + } + + const addButton = (label, title, onClick, cy) => { + const button = document.createElement('button') + button.className = 'btn btn-outline-secondary' + button.textContent = label + button.dataset.cy = cy + if(title) button.title = title + button.addEventListener('click', onClick) + + addSlot('pruefung-action', button) + } - // is this column/cell allowed to have an add pruefung action - const canAdd = field !== 'kommPruef' && field !== "Termin1" && data.hoechsterAntritt < this.maxAntrittCount - - // TODO: check for some time limit maybe? old pruefungen can be changed/created - - // Create root row div - const rowDiv = document.createElement('div'); - rowDiv.className = 'row flex-nowrap'; - rowDiv.style.display = 'flex'; - rowDiv.style.justifyContent = 'center'; - rowDiv.style.alignItems = 'center'; - rowDiv.style.height = '100%'; - - let attemptLabel = '' - let attemptClass = '' if(studentPruefung) { - let color = '' - - - switch(studentPruefung.pruefungstyp_kurzbz) { - case 'Termin1': - color = 'green' - break - case 'Termin2': - color = 'yellow' - break - case 'Termin3': - color = 'orange' - break - case 'kommPruef': - color = 'red' - break - } + // The badge shows the attempt number. A kommissionelle exam shows K, because its number + // follows from the position anyway. An exam that does not count (excused, not assessed) + // has no number and gets a neutral style. + const attemptLabel = studentPruefung.kommissionell + ? 'K' + : (studentPruefung.antritt_nr ? String(studentPruefung.antritt_nr) : '–') + const attemptClass = studentPruefung.kommissionell + ? 'attempt-k' + : (studentPruefung.zaehlt ? ('attempt-t' + Math.min(studentPruefung.antritt_nr, 3)) : 'attempt-none') - // rowDiv.style.borderLeft = `4px solid ${color}`; - rowDiv.style.marginLeft = "6px"; // small indent so text doesn't overlap - rowDiv.style.boxSizing = "border-box"; + rowDiv.classList.add('pruefung-badge', attemptClass) + rowDiv.setAttribute('data-attempt', attemptLabel) + rowDiv.setAttribute('data-cy', 'pruefung-cell') + rowDiv.setAttribute('data-note', studentPruefung.note) - switch(studentPruefung.pruefungstyp_kurzbz) { - case 'Termin1': attemptLabel = '1'; attemptClass = 'attempt-t1'; break - case 'Termin2': attemptLabel = '2'; attemptClass = 'attempt-t2'; break - case 'Termin3': attemptLabel = '3'; attemptClass = 'attempt-t3'; break - case 'kommPruef': attemptLabel = 'K'; attemptClass = 'attempt-k'; break - } + // The student administration owns the escalation (zusKommPruef), therefore that cell has + // no action button and the space belongs to the grade name. + const nurAnzeige = studentPruefung.kommissionell && !studentPruefung.zaehlt + if(nurAnzeige) rowDiv.classList.add('ohne-aktion') + + const noteDefEntry = this.notenOptions.find(n => n.note == studentPruefung.note) + const bezeichnung = noteDefEntry?.bezeichnung || '' + addSlot('pruefung-note', bezeichnung).title = bezeichnung + + if(antrittModus) addSlot('pruefung-datum', this.formatDatumDMY(studentPruefung.datum)) + + if(nurAnzeige) return rowDiv + + // An exam with a later date locks the GRADE, but you can still correct the date. The + // button stays active, and the dialog locks the grade field. + const noteLocked = this.hasLaterPruefung(data, studentPruefung) + + addButton( + this.$capitalize(this.$p.t('benotungstool/changePruefungButtonText')), + noteLocked ? this.$capitalize(this.$p.t('benotungstool/pruefungNoteLockedHint')) : null, + () => this.openPruefungModal(data, studentPruefung, field), + 'btn-pruefung-edit' + ) + + return rowDiv } - - function createCol(content, classParam) { - const colDiv = document.createElement('div'); - colDiv.className = classParam ?? 'col-4'; - colDiv.style.justifyContent = 'center'; - colDiv.style.alignItems = 'center'; - colDiv.style.alignContent = 'center'; - colDiv.style.height = '100%'; - if (typeof content === 'string') { - colDiv.textContent = content; - } else if (content instanceof HTMLElement) { - colDiv.appendChild(content); - } + // An empty cell gets an add button only if one more attempt is possible and if the column + // is after all exams that exist. + if(!this.canAddPruefung(data)) return '' - return colDiv; + // The last attempt is kommissionell, therefore only the kommPruef column may take it. All + // other columns stay empty at that point. + const kommissionellFaellig = data.verlauf?.naechsteRolle === 'kommissionell' + if(field === 'kommPruef' ? !kommissionellFaellig : kommissionellFaellig) return '' + + if(antrittModus && field !== 'kommPruef') { + // only the next free attempt column shows an add button + if(field !== ('antritt_' + ((data.pruefungen?.length ?? 0) + 1))) return '' + } else if(field !== 'kommPruef') { + // no new exam before an exam that exists + if((data.pruefungen ?? []).some(p => p.datum >= field)) return '' } - - if(data[field]) { - const noteDefEntry = data.note ? this.notenOptions.find(n => n.note == data[field].note) : null + addButton( + this.$capitalize(this.$p.t('benotungstool/addPruefungButtonText')), + null, + () => this.openPruefungModal(data, null, field), + 'btn-pruefung-add' + ) - if(attemptClass) rowDiv.classList.add('pruefung-badge', attemptClass); - if(attemptLabel) rowDiv.setAttribute('data-attempt', attemptLabel); - - // Second column (note_bezeichnung) - rowDiv.appendChild(createCol(noteDefEntry.bezeichnung || '', 'col-auto d-flex justify-content-center align-items-center')); - - // no actions on kommPruef allowed - // no actions on termin1 aka pruefung 0 aka ursprüngliche note erlaubt - if(field === 'kommPruef' || field === "Termin1") { - return rowDiv - } - - if(data[field]?.pruefungstyp_kurzbz !== 'Termin1') { - // once a later-dated or higher-attempt pruefung exists (e.g. a Termin3/kommPruef has - // already been entered) the GRADE is locked, but the exam DATE may still be corrected - // (within the neighbouring exam dates) -> keep the button enabled, lock the note in the modal - const noteLocked = this.hasLaterOrHigherPruefung(data, data[field]) - - // Third column (button) - const button = document.createElement('button'); - button.className = 'btn btn-outline-secondary'; - button.textContent = this.$capitalize(this.$p.t('benotungstool/changePruefungButtonText')); - if(noteLocked) { - button.title = this.$capitalize(this.$p.t('benotungstool/pruefungNoteLockedHint')) - } - button.addEventListener('click', () => { - this.openPruefungModal(data, data[field], field); - }); - - rowDiv.appendChild(createCol(button, 'col-4 d-flex justify-content-center align-items-center')); - } - - return rowDiv; - - } else if (canAdd) { // return new btn action - // dont render the add button in cells where a younger pruefung exists for the students - const youngerPruefung = data.pruefungen.find(p => p.datum > field) - if(youngerPruefung) return rowDiv - - const button = document.createElement('button'); - button.className = 'btn btn-outline-secondary'; - button.textContent = this.$capitalize(this.$p.t('benotungstool/addPruefungButtonText')); - button.addEventListener('click', () => { - this.openPruefungModal(data, null, field) - }); - - rowDiv.appendChild(createCol(button), 'col-4 d-flex justify-content-center align-content-center'); - - return rowDiv; - } else { - return '' - } + return rowDiv }, parseISODate(iso) { const parts = (iso ?? '').slice(0, 10).split('-') 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) @@ -1439,7 +1495,10 @@ export const Benotungstool = { getPruefungDateBounds(student, pruefung, fallbackDate) { // the exam date must stay strictly between the dates of the chronologically adjacent // pruefungen so the attempt order is preserved. Returns inclusive datepicker bounds. - const refDate = (pruefung?.datum ?? fallbackDate ?? '').slice(0, 10) + // A column name that is not a date ('antritt_2', 'kommPruef') means a new exam after all + // existing ones. Use a date in the far future, so every existing exam is a lower bound. + const raw = (pruefung?.datum ?? fallbackDate ?? '').slice(0, 10) + const refDate = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? raw : '9999-12-31' let lower = null, upper = null; (student.pruefungen ?? []).forEach(p => { if(pruefung && p.pruefung_id != null && pruefung.pruefung_id != null && p.pruefung_id === pruefung.pruefung_id) return @@ -1453,18 +1512,46 @@ export const Benotungstool = { max: upper ? this.addDays(this.parseISODate(upper), -1) : null } }, + /** + * Lädt die Lehrenden einer Lehreinheit für den Dialog. Bei genau einem Lehrenden bleibt die + * Auswahl leer; der Server setzt ihn dann selbst ein. + */ + ladeLehrende(lehreinheitIds) { + this.lehrendeDerLehreinheit = [] + this.selectedLehrender = null + + const eindeutig = [...new Set((lehreinheitIds ?? []).filter(id => id != null))] + if(eindeutig.length !== 1) return + + this.$api.call(ApiNoten.getLehrendeFuerLehreinheit(eindeutig[0], this.lv_id, this.sem_kurzbz)) + .then(res => { + if(res?.meta?.status !== 'success') return + this.lehrendeDerLehreinheit = (res.data ?? []).map(l => ({ + ...l, + anzeigename: `${l.vorname ?? ''} ${l.nachname ?? ''}`.trim() || l.mitarbeiter_uid + })) + + // nur eine Auswahl anbieten, wenn es etwas zu wählen gibt + if(this.lehrendeDerLehreinheit.length > 1) { + const bestehend = this.pruefung?.mitarbeiter_uid + this.selectedLehrender = this.lehrendeDerLehreinheit + .some(l => l.mitarbeiter_uid === bestehend) + ? bestehend + : this.lehrendeDerLehreinheit[0].mitarbeiter_uid + } + }) + }, openPruefungModal(student, pruefung = null, field) { this.pruefungStudent = student this.pruefung = pruefung - const dateStr = this.pruefung?.datum ?? field + this.ladeLehrende([student?.lehreinheit_id]) - const pruefungDateParts = dateStr.split('-') + // In the attempt mode the column has no date, therefore a new exam starts with today. + const dateStr = this.pruefung?.datum ?? (/^\d{4}-\d{2}-\d{2}$/.test(field) ? field : null) + this.selectedPruefungDate = dateStr ? this.parseISODate(dateStr) : new Date() - const newDate = new Date(Number(pruefungDateParts[0]), Number(pruefungDateParts[1]) - 1, Number(pruefungDateParts[2])) - this.selectedPruefungDate = newDate - - // grade is locked once a later/higher pruefung exists; only the date may be corrected - this.pruefungNoteLocked = !!(pruefung && this.hasLaterOrHigherPruefung(student, pruefung)) + // grade is locked once a later pruefung exists; only the date may be corrected + this.pruefungNoteLocked = !!(pruefung && this.hasLaterPruefung(student, pruefung)) // constrain the date to stay between the neighbouring exam dates const bounds = this.getPruefungDateBounds(student, pruefung, field) @@ -1503,6 +1590,7 @@ export const Benotungstool = { const button = document.createElement('button'); button.className = 'btn btn-outline-secondary'; + button.dataset.cy = 'btn-uebernehmen'; button.textContent = this.$capitalize(this.$p.t('benotungstool/c4notenvorschlagUebernehmen')); return button; @@ -1526,17 +1614,6 @@ export const Benotungstool = { buildMailToLink(student){ return 'mailto:' + student.uid +'@'+ this.domain }, - insertSortedDate(arr, dateStr) { - // Binary search to find insertion index - let left = 0, right = arr.length; - while (left < right) { - const mid = (left + right) >> 1; - if (arr[mid] < dateStr) left = mid + 1; - else right = mid; - } - arr.splice(left, 0, dateStr); // insert at index - return arr; - }, tableResolve(resolve) { this.tableBuiltResolve = resolve }, @@ -1546,7 +1623,7 @@ export const Benotungstool = { async setupData(data){ // in case we reload when changing lva_id or stsem to always consider local storage layout this.colLayoutRestored = false; - + this.studenten = data[0] ?? [] this.studenten.forEach(s => { s.pruefungen = [] @@ -1555,58 +1632,36 @@ export const Benotungstool = { }) this.pruefungen = data[1] ?? [] this.domain = data[2] - - // contains notenvorschläge from moodle, lv_note + + // holds the grade proposals from Moodle, lv_note and the exam history of each student this.teilnoten = data[3] ?? [] - - // let pruefungenRegularColCount = 0; + this.distinctPruefungsDates = [] - const cols = [...this.notenTableOptions.columns.slice(0, -1)]; - let kommCol = null - if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) kommCol = this.notenTableOptions.columns[this.notenTableOptions.columns.length - 1]; - + this.pruefungen?.forEach(p => { - const dateParts = p.datum.split('-') - p.dateObj = new Date(dateParts[0], +(dateParts[1]) - 1, dateParts[2]) - const student = this.studenten.find(s => s.uid === p.student_uid) if(!student) return - - if(p.pruefungstyp_kurzbz !== 'kommPruef' - && p.pruefungstyp_kurzbz !== 'Termin1' - && !this.distinctPruefungsDates.includes(p.datum)) { - this.distinctPruefungsDates.push(p.datum) - } - - // seperate kommPruefungen from previous pruefungen counts since the column count variability always ends with this - if(p.pruefungstyp_kurzbz == 'kommPruef') { + + p.dateObj = this.parseISODate(p.datum) + + // the final exam has its own column, and that column is always the last one + if(p.kommissionell) { student['kommPruef'] = p } else { student.pruefungen.push(p) + if(!this.distinctPruefungsDates.includes(p.datum)) this.distinctPruefungsDates.push(p.datum) } - }) + this.distinctPruefungsDates.sort() + this.studenten?.forEach(s => { - // sort students regular pruefungen by datum - s.pruefungen.sort((p1, p2) => { - if(p1.datum > p2.datum) { - return 1 - } else if (p1.datum < p2.datum) { - return -1 - } else { - return 0 - } - }) - // set the sorted pruefungen to their respective column fields - s.pruefungen.forEach((p, i) => { - // Termin1 aka originale Zeugnisnote should note create variable columns - if(p.pruefungstyp_kurzbz == "Termin1") { - s["Termin1"] = p - } else { - s[p.datum] = p - } - }) + // the server sends the exams in the order of the history; this only makes it sure + s.pruefungen.sort((p1, p2) => (p1.position ?? 0) - (p2.position ?? 0)) + + // the attempt count, the limits and the next possible role come from the server + s.verlauf = this.teilnoten[s.uid]?.verlauf ?? null + this.indexPruefungen(s) s.hoechsterAntritt = this.getAntrittCountStudent(s) // multiselect label, uid first: "uid – Nachname Vorname – Antritte: n" @@ -1617,9 +1672,9 @@ export const Benotungstool = { s.freigabedatum = this.parseDate(this.teilnoten[s.uid]['freigabedatum']) s.benotungsdatum = this.parseDate(this.teilnoten[s.uid]['benotungsdatum']) s.freigegeben = this.checkFreigabe(s.freigabedatum, s.benotungsdatum, s.uid); - + s.punkte = this.teilnoten[s.uid].punkte_lv - + const grades = this.teilnoten[s.uid].grades s.teilnote = '' s.mobility_zusatz = this.teilnoten[s.uid].mobility_zusatz @@ -1629,118 +1684,20 @@ export const Benotungstool = { if(notenOption.positiv) s.teilnote += (''+g.text +''+ '
') else s.teilnote += (''+g.text +''+ '
') }) - - const vueThis = this - Object.defineProperty(s, 'selectable', { - get() { - const kP = s.pruefungen?.find(p => p.pruefungstyp_kurzbz == 'kommPruef') - const maxAntrittReached = s.hoechsterAntritt >= vueThis.maxAntrittCount - return !(kP || maxAntrittReached) - - }, - set() { - // empty setter so tabulator doesnt scream - }, - enumerable: true, - configurable: true - }) - - }) - - // if we have any pruefungen at all someone must have original note/termin1 pruefung - if(this.distinctPruefungsDates.length) { - cols.push({ - title: this.$capitalize(this.$p.t('benotungstool/c4originalZnote')), - field: "Termin1", - formatter: this.pruefungFormatter, - titleFormatter: this.pruefungTitleFormatter, - sorter: this.pruefungSorter, - topCalc: this.terminCalcFunc, - topCalcFormatter: this.terminCalcFormatter, - hozAlign:"center", - widthGrow: 1, - minWidth: 200, - width: 250, - visible: true, - tooltip: false - }) - } - this.distinctPruefungsDates.sort((d1, d2) => { - if(d1 > d2) { - return 1 - } else if (d1 < d2) { - return -1 - } else { - return 0 - } - }) - this.distinctPruefungsDates.forEach((date)=>{ - const dateparts = date.split('-') - const titledate = `${dateparts[2]}.${dateparts[1]}.${dateparts[0]}` - - cols.push({ - title: titledate, - field: date, - formatter: this.pruefungFormatter, - titleFormatter: this.pruefungTitleFormatter, - sorter: this.pruefungSorter, - topCalc: this.terminCalcFunc, - topCalcFormatter: this.terminCalcFormatter, - hozAlign:"center", - widthGrow: 1, - minWidth: 200, - width: 250, - visible: true, - tooltip: false - }) + this.recalculateSelectable(s) }) - if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) cols.push(kommCol) // keep kommPruef Col as last + // a new course: always build the columns again, and reset the scroll position + this.applyPruefungColumns(true) - - // preemptively do the persistence part of columns already here so we get the merged definition before - // tabulator internal go to war with vue reactives - let colsUsed = null - const saved = this.loadState(); - if (saved && saved.columns) { - // new columns map for lookup - const colMap = new Map(cols.map(c => [c.field, c])); - - const restoredCols = []; - - // add columns in the SAVED order, applying saved width/visibility - saved.columns.forEach(savedCol => { - const originalDef = colMap.get(savedCol.field); - if (originalDef) { - restoredCols.push({ - ...originalDef, // Keep formatters, calcs, etc. - width: savedCol.width, - visible: savedCol.visible - }); - colMap.delete(savedCol.field); // Remove so we don't double-add - } - }); - - // append any NEW columns aka pruefungs cols - colMap.forEach((def) => { - restoredCols.push(def); - }); - - colsUsed = restoredCols; - this.colLayoutRestored = true; - } - - const colsFinal = colsUsed ?? cols - - this.$refs.notenTable.tabulator.setColumns(colsFinal) this.$refs.notenTable.tabulator.setData(this.studenten); this.$refs.notenTable.tabulator.redraw(true); // reflect the sticky-column selection on the freshly (re)built columns this.applyStickyColumnState() - // refresh the "neue Prüfung" dropdown options for the freshly loaded data + // build the options of the "neue Prüfung" dropdown again for the new data this.tableVersion++ // keep the loading overlay up until the browser has actually painted the rebuilt table @@ -1775,22 +1732,72 @@ 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 + }, + // An assistant selects a degree programme first, and then sees the courses of that + // entitlement. A teacher sees the own courses directly. + 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 +1825,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 +1871,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,22 +1919,6 @@ 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 "" - } - }, savePruefungEingabe() { // keep the date within the neighbouring exam dates (a typed value can bypass the picker bounds) if((this.pruefungDateMin && this.selectedPruefungDate < this.pruefungDateMin) || @@ -1919,11 +1932,7 @@ export const Benotungstool = { const day = String(this.selectedPruefungDate.getDate()).padStart(2, '0'); const dateStr = `${year}-${month}-${day}`; - // first pruefung is always "Termin2" since normal note counts as Termin1 - // const pOffset = this.pruefung === null && this.pruefungStudent.pruefungen.length === 0 ? 2 : 1 - - const typ = this.pruefung ? this.pruefung.pruefungstyp_kurzbz : this.getPruefungstypForStudentByAntritt(this.pruefungStudent) - // when the grade is locked (later/higher pruefung exists) keep the existing note untouched + // when the grade is locked (later pruefung exists) keep the existing note untouched const note = this.pruefungNoteLocked && this.pruefung ? this.pruefung.note : (this.selectedPruefungNote?.note ?? 9) // noch nicht eingetragen @@ -1940,8 +1949,8 @@ export const Benotungstool = { this.lv_id, this.pruefungStudent.lehreinheit_id, this.sem_kurzbz, - typ, - this.pruefung?.pruefung_id ?? null // null = adding a new pruefung, otherwise edit of this record + this.pruefung?.pruefung_id ?? null, // null = adding a new pruefung, otherwise edit of this record + this.selectedLehrender )).then(res => { if(res.meta.status === 'success') { //'Prüfung für Student ' + this.pruefungStudent.uid + ' bearbeitet oder angelegt' this.$fhcAlert.alertDefault( @@ -1951,182 +1960,30 @@ export const Benotungstool = { true ) const s = this.studenten.find(s => s.uid === res.data[1]?.student_uid) - - s.freigabedatum = this.parseDate(res.data[1]?.['freigabedatum']) - s.benotungsdatum = this.parseDate(res.data[1]?.['benotungsdatum']) - - s.freigegeben = this.checkFreigabe(s.freigabedatum, s.benotungsdatum, s.uid); - - s.lv_note = res.data[1]?.note - const oldScrollLeft = this.$refs.notenTable.tabulator.rowManager.scrollLeft - const oldScrollTop = this.$refs.notenTable.tabulator.rowManager.scrollTop - - // add new pruefung to row - if(!this.pruefung) { - this.handleAddNewTermin(res.data, s) - } else { // update existing - const oldIndex = s.pruefungen.findIndex(p => p.pruefung_id == this.pruefung.pruefung_id) - if(oldIndex !== -1) { - s.pruefungen.splice(oldIndex, 1, res.data[0]) - s[res.data[0].datum] = res.data[0] - } - // antritte might have changed due to different benotung - s.hoechsterAntritt = this.getAntrittCountStudent(s) - this.recalculateSelectable(s) - this.reformatStudentRow(s) - } - - this.$refs.notenTable.tabulator.redraw(true) - Vue.nextTick(()=> { - const table = this.$refs.notenTable.tabulator.element.querySelector('.tabulator-tableholder') - if(table) { - table.scrollLeft = oldScrollLeft; - table.scrollTop = oldScrollTop; - } + this.applyLvGesamtnote(s, res.data[1]) + + // build the full row again from the server history; this covers a new exam and an edit + this.colLayoutRestored = false + this.preserveScroll(() => { + this.applyVerlauf(s, res.data[2]) + this.applyPruefungColumns() + this.$refs.notenTable.tabulator.redraw(true) }) - } }).finally(()=> { this.pruefungStudent = null this.pruefung = null this.loading = false }) - + this.$refs.modalContainerPruefung.hide() }, - handleAddNewTermin(data, student){ - this.colLayoutRestored = false; // always keep persistence options in mind when modifying table cols dynamically - - const savedPruefung = data[0] - const extra = data[2] - - // check for extra pruefung (termin1) to add before - if(extra) { - student["Termin1"] = extra - student.pruefungen.push(extra) // push to pruefungen array for antritt evaluation - } - - this.correctOldTerminTypenForStudent(student, savedPruefung) - - if(!this.distinctPruefungsDates.includes(savedPruefung.datum)) { - this.insertSortedDate(this.distinctPruefungsDates, savedPruefung.datum) - } - - // add pruefung to pruefungen array - student.pruefungen.push(savedPruefung) - - // add pruefung to student via its datum as a field - student[savedPruefung.datum] = savedPruefung - - // usually should be in order naturally, just to be save - student.pruefungen.sort((p1, p2) => { - if(p1.datum > p2.datum) { - return 1 - } else if (p1.datum < p2.datum) { - return -1 - } else { - return 0 - } - }) - - // recalculate student antritte - - student.hoechsterAntritt = this.getAntrittCountStudent(student) - this.recalculateSelectable(student) - this.reformatStudentRow(student) - - // add col to table - const cols = [...this.notenTableOptions.columns.slice(0, -1)]; - let kommCol = null - if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) kommCol = this.notenTableOptions.columns[this.notenTableOptions.columns.length - 1]; - - if(this.distinctPruefungsDates.length) { - cols.push({ - title: this.$capitalize(this.$p.t('benotungstool/c4originalZnote')), - field: "Termin1", - formatter: this.pruefungFormatter, - titleFormatter: this.pruefungTitleFormatter, - sorter: this.pruefungSorter, - topCalc: this.terminCalcFunc, - topCalcFormatter: this.terminCalcFormatter, - hozAlign:"center", - widthGrow: 1, - minWidth: 200, - width: 250, - visible: true, - tooltip: false - }) - } - - this.distinctPruefungsDates.forEach((date)=>{ - const dateparts = date.split('-') - const titledate = `${dateparts[2]}.${dateparts[1]}.${dateparts[0]}` - - cols.push({ - title: titledate,//this.$p.t('benotungstool/pruefungNr', [index+1]), - field: date, - formatter: this.pruefungFormatter, - titleFormatter: this.pruefungTitleFormatter, - sorter: this.pruefungSorter, - topCalc: this.terminCalcFunc, - topCalcFormatter: this.terminCalcFormatter, - hozAlign:"center", - widthGrow: 1, - minWidth: 200, - width: 250, - visible: true, - tooltip: false - }) - }) - - if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) cols.push(kommCol) // keep kommPruef Col as last - - - // preemptively do the persistence part of columns already here so we get the merged definition before - // tabulator internal go to war with vue reactives - let colsUsed = null - const saved = this.loadState(); - if (saved && saved.columns) { - // new columns map for lookup - const colMap = new Map(cols.map(c => [c.field, c])); - - const restoredCols = []; - - // add columns in the SAVED order, applying saved width/visibility - saved.columns.forEach(savedCol => { - const originalDef = colMap.get(savedCol.field); - if (originalDef) { - restoredCols.push({ - ...originalDef, // Keep formatters, calcs, etc. - width: savedCol.width, - visible: savedCol.visible - }); - colMap.delete(savedCol.field); // Remove so we don't double-add - } - }); - - // append any NEW columns aka pruefungs cols - colMap.forEach((def) => { - restoredCols.push(def); - }); - - colsUsed = restoredCols; - this.colLayoutRestored = true; - } - - - const colsFinal = colsUsed ?? cols - this.$refs.notenTable.tabulator.setColumns(colsFinal) - // this.$refs.notenTable.tabulator.redraw(true) - // redraw table outside this function - }, recalculateSelectable(student) { const vueThis = this Object.defineProperty(student, 'selectable', { get() { - const kP = student.pruefungen?.find(p => p.pruefungstyp_kurzbz == 'kommPruef') - return !(kP || student.hoechsterAntritt >= vueThis.maxAntrittCount) + return vueThis.canAddPruefung(student) }, set() { // empty setter so tabulator doesnt scream @@ -2134,7 +1991,7 @@ export const Benotungstool = { enumerable: true, configurable: true }) - // a student's selectability may have changed -> refresh the "neue Prüfung" dropdown options + // a student can be selectable now, therefore build the "neue Prüfung" options again this.tableVersion++ }, saveNoteneingabe() { @@ -2152,13 +2009,25 @@ export const Benotungstool = { res.data.forEach(d => { const s = this.studenten.find(s => s.uid === d.uid) + if(!s) return + s.freigabedatum = this.parseDate(d.freigabedatum) s.benotungsdatum = this.parseDate(d.benotungsdatum) s.freigegeben = this.checkFreigabe(s.freigabedatum, s.benotungsdatum, s.uid); + + // die Freigabe legt den ersten Antritt an; ohne den Verlauf bliebe die Zeile bis + // zum nächsten Laden leer + if(d.verlauf) this.applyVerlauf(s, d.verlauf) }) this.changedNotenCounter++; - this.$refs.notenTable.tabulator.redraw(true) + // die neuen Termine brauchen unter Umständen eine zusätzliche Spalte + this.preserveScroll(() => { + this.applyPruefungColumns() + const loaded = this.$refs.notenTable.tabulator.setData(this.studenten) + this.$refs.notenTable.tabulator.redraw(true) + return loaded + }) }).finally(() => { this.loading = false }) @@ -2169,6 +2038,12 @@ export const Benotungstool = { this.$refs.modalContainerNotenSpeichern.show() }, openNewPruefungsdatumModal() { + // the same fields as the dialog in the table: both start empty and without a grade + this.selectedPruefungNote = null + this.selectedPruefungPunkte = null + this.selectedPruefungDate = new Date() + // nur bei einer gemeinsamen Lehreinheit gibt es eine sinnvolle Lektorenauswahl + this.ladeLehrende((this.selectedUids ?? []).map(s => s.lehreinheit_id)) this.$refs.modalContainerNeuesPruefungsdatum.show() }, openPruefungImportModal() { @@ -2181,34 +2056,18 @@ export const Benotungstool = { return option.bezeichnung }, addPruefung(){ - - this.$refs.modalContainerNeuesPruefungsdatum.hide() - - // filter students that already have a pruefung on datum - const year = this.selectedPruefungDate.getFullYear(); const month = String(this.selectedPruefungDate.getMonth() + 1).padStart(2, '0'); // Months are 0-based const day = String(this.selectedPruefungDate.getDate()).padStart(2, '0'); const dateStrDb = `${year}-${month}-${day}`; const dateStrFront = `${day}.${month}.${year}`; - - const uids = [] - // const uids = this.selectedUids.map(student => { - this.selectedUids.forEach(student => { - - // if a student doesnt have a lv_note entered definitely dont allow to create pruefung - if(!student.lv_note) { - this.$fhcAlert.alertWarning('Student ' + student.uid + ' hat noch keine LV-Note eingetragen! Es wird keine Prüfung angelegt.') - return - } - if(!student.note) { - this.$fhcAlert.alertWarning('Student ' + student.uid + ' hat noch keine Zeugnis Note eingetragen! Es wird keine Prüfung angelegt.') - return - } + const uids = [] + + this.selectedUids.forEach(student => { // check if student antrittCount is too high already - if(student.hoechsterAntritt >= this.maxAntrittCount) { + if(!this.canAddPruefung(student)) { this.$fhcAlert.alertWarning('Student ' + student.uid + ' hat bereits ' + student.hoechsterAntritt + ' Prüfungsantritte abgelegt. Es wird keine Prüfung angelegt.') return } @@ -2224,46 +2083,54 @@ export const Benotungstool = { uids.push({ uid: student.uid, - lehreinheit_id: student.lehreinheit_id, - typ: this.getPruefungstypForStudentByAntritt(student)//student.hoechsterAntritt + lehreinheit_id: student.lehreinheit_id }) }) - + + this.$refs.modalContainerNeuesPruefungsdatum.hide() + + if(!uids.length) return + + // A missing course grade blocks nothing. The server creates it from the grade of this + // exam. The dialog shows a hint, see pruefungOhneLvNote. this.loading = true; this.$api.call(ApiNoten.createPruefungen( uids, - dateStrDb, + dateStrDb, this.lv_id, this.sem_kurzbz, + this.selectedPruefungNote?.note ?? null, + this.selectedPruefungPunkte ?? null, + this.selectedLehrender )).then(res => { if(res.meta.status === "success") { - - // iterate over response data + + // iterate over response data // -> alert successful pruefungen // -> alert denied pruefungen + reason - + let uidListSuccess = '' let uidListError = '' const successData = [] Object.keys(res.data).forEach(student_uid => { const student = res.data[student_uid] // actual pruefung has been allocated - if(student.savedPruefung || student.extraPruefung) { + if(student.savedPruefung) { uidListSuccess += ' ' + student_uid - + // keep res.data format intact for handleResponse method successData[student_uid] = student } else { // there should be an error message why no pruefungen where allocated for this person, many reasons possible uidListError += student_uid + ' - ' + student +'\n'// student variable is the error message here } }) - + if(uidListError != '') { this.$fhcAlert.alertError( this.$capitalize(this.$p.t('benotungstool/c4pruefungAnlageError', [dateStrFront])) + ': ' + uidListError + ' ' ) } - + if(uidListSuccess != '') { this.$fhcAlert.alertDefault( 'success', @@ -2274,47 +2141,22 @@ export const Benotungstool = { this.handleAddNewPruefungenResponse({data: successData}, uids) } - + } }).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 - if(student['kommPruef']) return 4 - - let pruefungsAntrittCount = 0 - const pLen = student.pruefungen.length - for(let i = 0; i < pLen; i++) { - const p = student.pruefungen[i] - - // TODO: filter for limit here but in a perfect world they should never be able to exceed that anyway - const isDefinedAsAntrittsloseNote = this.config.NOTEN_OHNE_ANTRITT.find(n_pk => n_pk == p.note) - if(!isDefinedAsAntrittsloseNote) pruefungsAntrittCount++ - } - - // when student never had to take an exam beyond the original benotung - // aka pruefungsantritt (even though it does not have to have pruefungscharacter) - // it still counts as an antritt, except it is coming from a notenOption like "angerechnet" - // which indicates no participation at all - if(pruefungsAntrittCount === 0 && student.note){ - const noteOption = this.notenOptions.find(note => note.note == student.note) - - if(noteOption.lehre) return 1 - else return 0 - } - - return pruefungsAntrittCount + return NotenRules.antrittCountStudent(student, this.config, this.notenOptions) } }, watch: { selectedUids(newVal, oldVal) { + // im offenen Sammeldialog ändert das Multiselect die Auswahl; die Lektorenliste hängt + // an der Lehreinheit und muss dann neu geladen werden + if(this.neuesPruefungsdatumModalVisible) { + this.ladeLehrende((newVal ?? []).map(s => s.lehreinheit_id)) + } + const table = this.$refs.notenTable?.tabulator if (!table) return; @@ -2354,15 +2196,8 @@ export const Benotungstool = { this.selectedLehreinheit = null } }, - getKommPruefCount(newVal) { - if(!this.config.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) return 0 - if(this.$refs.notenTable?.tabulator && newVal > 0) { - const kommPruefCol = this.$refs.notenTable?.tabulator.getColumn("kommPruef") - kommPruefCol.show() - } else if(this.$refs.notenTable?.tabulator && newVal == 0) { - const kommPruefCol = this.$refs.notenTable?.tabulator.getColumn("kommPruef") - kommPruefCol.hide() - } + kommPruefSpalteNoetig() { + this.applyKommPruefSpalte() }, selectedPruefungNote(newVal, oldVal) { if (!newVal || !this.pruefungStudent) return @@ -2375,7 +2210,7 @@ export const Benotungstool = { const limit = limitMap[note] if (limit == null) return - // all fixed antritte: Termin1/2/3 sit in pruefungen, kommPruef is separate + // all exams of the history; the row holds the final exam separately const allPruefungen = [...this.pruefungStudent.pruefungen] if (this.pruefungStudent.kommPruef) allPruefungen.push(this.pruefungStudent.kommPruef) @@ -2409,13 +2244,17 @@ export const Benotungstool = { + this.$p.t('global/gesamt') + ': ' + (this.studenten?.length || 0) + ''; }, - maxAntrittCount() { - let maxAntritte = 1; - - if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_TERMIN2) maxAntritte++ - if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_TERMIN3) maxAntritte++ - - return maxAntritte; + // a hint for the dialog: for these students the exam creates the course grade + pruefungOhneLvNote() { + return (this.selectedUids ?? []).filter(s => NotenRules.brauchtNeueLvNote(s)) + }, + // the same hint in the dialog of the table: both dialogs show the same text + pruefungStudentOhneLvNote() { + return !this.pruefung && !!this.pruefungStudent && NotenRules.brauchtNeueLvNote(this.pruefungStudent) + }, + // the active column layout: the choice of the user, or the default from the configuration + pruefungsspaltenModus() { + return this.pruefungsspalten ?? this.config?.CIS_GESAMTNOTE_PRUEFUNGSSPALTEN ?? 'antritt' }, getFreigabeCounter() { return this.studenten ? this.studenten.reduce((acc, cur) => { @@ -2435,10 +2274,8 @@ export const Benotungstool = { } }, getStudentenOptions() { - // the "neue Prüfung" multiselect mirrors the table: same row order (current sort + filter) - // and the same selectable set. tableVersion is bumped on sort/filter/data changes so this - // recomputes whenever the table order changes. - const _ = this.tableVersion // reactive dependency + // the multiselect mirrors the table: the same order after the sort and the filter + const _ = this.tableVersion // reactive dependency, wird bei Sort/Filter/Daten erhöht const table = this.$refs.notenTable?.tabulator if(!table) { @@ -2452,9 +2289,37 @@ export const Benotungstool = { }, getKommPruefCount(){ let counter = 0 - this.studenten?.forEach(s => {if(s['kommPruef']){counter++}}) + this.studenten?.forEach(s => {if(s['kommPruef']){counter++}}) return counter }, + /** + * Titel der kommissionellen Spalte. Im Datumsmodus tragen alle anderen Spalten ihr Datum im + * Kopf, daher bekommt diese es auch - aber nur, wenn alle Termine darin auf denselben Tag + * fallen. Bei verschiedenen Daten steht das Datum weiterhin in der Zelle. + */ + kommPruefSpaltentitel() { + const titel = this.$capitalize(this.$p.t('benotungstool/c4kommPruef')) + if(this.pruefungsspaltenModus !== 'datum') return titel + + const daten = new Set( + (this.studenten ?? []) + .map(s => (s.kommPruef?.datum ?? '').slice(0, 10)) + .filter(d => d !== '') + ) + if(daten.size !== 1) return titel + + return `${titel} - ${this.formatDatumDMY([...daten][0])}` + }, + /** + * Die kommissionelle Spalte wird gebraucht, sobald ein Termin darin steht ODER bei einer + * Zeile der letzte Antritt fällig ist - sonst fehlt der Platz zum Anlegen. + */ + kommPruefSpalteNoetig() { + if(!this.config?.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) return false + if(this.getKommPruefCount > 0) return true + + return (this.studenten ?? []).some(s => s.verlauf?.naechsteRolle === 'kommissionell') + }, getSaveBtnClass() { return this.changedNoten?.length ? "btn btn-primary ml-2" : "btn btn-secondary ml-2" }, @@ -2480,6 +2345,21 @@ export const Benotungstool = { }, []) : [] return cs }, + // The preview of the release dialog, over changedNoten. The release writes the course grade + // only, not the transcript grade. Therefore the dialog compares the two. + freigabeSummary() { + return this.changedNoten.map(s => { + const zeugnisOpt = this.notenOptions?.find(opt => opt.note == s.note) + const releaseOpt = this.notenOptions?.find(opt => opt.note == s.lv_note) + return { + uid: s.uid, + name: `${s.vorname} ${s.nachname}`, + currentNote: zeugnisOpt ? zeugnisOpt.bezeichnung : (s.note || '—'), + releasedNote: releaseOpt ? releaseOpt.bezeichnung : (s.lv_note || '—'), + changed: (s.note ?? '') != (s.lv_note ?? '') + } + }) + }, getNotenfreigabeHinweistext() { return this.$capitalize(this.$p.t('benotungstool/notenfreigabeHinweistextv4')) }, @@ -2506,7 +2386,7 @@ export const Benotungstool = { this.setupMounted() }, template: ` - + - + - - + - + @@ -2684,57 +2659,68 @@ export const Benotungstool = { -
-
-

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

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

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

+
{{ selectedLehrveranstaltung?.lv_bezeichnung }}
- -
- -
-
- - - -
- -
- -
-
- - - -
- -
- -
-
- - - + +
+
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
-
+
- + +
+ + - - - diff --git a/public/js/components/Cis/Benotungstool/NotenlisteLinks.js b/public/js/components/Cis/Benotungstool/NotenlisteLinks.js index 58d16c41b..2576118bf 100644 --- a/public/js/components/Cis/Benotungstool/NotenlisteLinks.js +++ b/public/js/components/Cis/Benotungstool/NotenlisteLinks.js @@ -13,9 +13,9 @@ export const NotenlisteLinks = { return LehreinheitenModule; }, lehreinheiten() { - // reuse the already-loaded LE options from the shared module + // use the teaching unit options that the shared module loaded before const all = LehreinheitenModule.options ?? []; - // when a single Lehreinheit is selected, restrict the list to just that one + // if the user selected one teaching unit, show only that one if (this.selectedLehreinheit?.lehreinheit_id != null) { return all.filter(le => le.lehreinheit_id === this.selectedLehreinheit.lehreinheit_id); } diff --git a/public/js/components/Cis/Benotungstool/notenRules.js b/public/js/components/Cis/Benotungstool/notenRules.js new file mode 100644 index 000000000..942f596e1 --- /dev/null +++ b/public/js/components/Cis/Benotungstool/notenRules.js @@ -0,0 +1,57 @@ +/** + * The examination rules as the client applies them. + * + * PruefungsverlaufLib sends one `verlauf` for each student. It contains the attempt count, the + * limit and the next possible role. For each exam it contains the position and a flag that tells + * you if the exam uses an attempt. + * + * The local fallback applies only while the client has no `verlauf` for a row. + */ + +/** The maximum number of attempts that count in this tool. The server derives this value. */ +export const maxAntrittCount = (config) => config?.CIS_GESAMTNOTE_MAX_ANTRITTE ?? 1; + +/** + * The attempts that count. The NOTE decides, not the exam type: the grades in NOTEN_OHNE_ANTRITT + * (excused, not assessed, not entered yet) use no attempt. If no exam counts, the course grade + * itself is the first attempt, but only if that grade counts. + */ +export const antrittCountStudent = (student, config, notenOptions) => { + if (student.verlauf) return student.verlauf.antrittCount; + + const ohneAntritt = config?.NOTEN_OHNE_ANTRITT ?? []; + let count = 0; + for (const p of student.pruefungen ?? []) { + if (!ohneAntritt.find((pk) => pk == p.note)) count++; + } + + if (count === 0 && student.note && !ohneAntritt.find((pk) => pk == student.note)) { + const noteOption = notenOptions?.find((n) => n.note == student.note); + return noteOption?.lehre ? 1 : 0; + } + + return count; +}; + +/** Tells you if the student can get one more attempt. */ +export const canAddPruefung = (student, config) => { + if (student.verlauf) return student.verlauf.canAdd; + return !student.kommPruef && antrittCountStudent(student, config) < maxAntrittCount(config); +}; + +/** + * Tells you if the new exam also creates the course grade. The value comes from the `verlauf` and + * not from `lv_note`, because `lv_note` contains RELEASED grades only. An entered grade that is + * not released still exists. The user interface uses this for a hint only. + */ +export const brauchtNeueLvNote = (student) => student.verlauf ? !student.verlauf.hatLvNote : !student.lv_note; + +/** + * The grade state from the two timestamps: offen = no grade entered (an empty row), changed = a + * grade is entered but not released, ok = released and not changed since the release. + */ +export const checkFreigabe = (freigabedatum, benotungsdatum) => { + if (!benotungsdatum) return "offen"; + if (!freigabedatum) return "changed"; + return benotungsdatum > freigabedatum ? "changed" : "ok"; +}; diff --git a/public/js/components/Cis/LvPlan/MyLvPlan.js b/public/js/components/Cis/LvPlan/MyLvPlan.js index cb43a06d9..3fab090a3 100644 --- a/public/js/components/Cis/LvPlan/MyLvPlan.js +++ b/public/js/components/Cis/LvPlan/MyLvPlan.js @@ -2,6 +2,7 @@ import FhcCalendar from "../../Calendar/LvPlan.js"; import ApiLvPlan from '../../../api/factory/lvPlan.js'; import ApiAuthinfo from '../../../api/factory/authinfo.js'; +import ApiRoomPlan from '../../../api/factory/calendar/roomPlan.js'; export const DEFAULT_MODE_LVPLAN_DESKTOP = "Week"; export const DEFAULT_MODE_LVPLAN_MOBILE = "List"; @@ -134,6 +135,24 @@ export default { this.isMitarbeiter = authInfo.isMitarbeiter; this.isStudent = authInfo.isStudent; }, + deleteEvent(event) { + if (event.type === "reservierung") { + this.deleteReservation(event); + } + }, + async deleteReservation(event) { + if ( + luxon.DateTime.fromISO(`${event.datum}T${event.beginn}`) < + luxon.DateTime.now() + ) + return; + + await this.$api.call( + ApiRoomPlan.deleteRoomReservation(event.reservierung_id), + ); + + this.$refs.calendar.reset(); + }, }, async created() { await this.fetchAuthInfo(); @@ -157,6 +176,7 @@ export default { @update:date="handleChangeDate" @update:mode="handleChangeMode" @update:range="updateRange" + @delete-event="(event) => deleteEvent(event)" class="responsive-calendar" >
!!fragment, + ); + this.otherPersonData.fullNameWithTitle = + fullNameWithTitleFragments.join(" "); this.otherPersonData.photo = viewData?.user_data?.foto; }, async redirectToMyLvPlanIfAuthUid() { @@ -225,12 +232,21 @@ export default { this.$router.push({ name: "MyLvPlan" }); } }, + copyFullNameWithTitleToClipboard() { + navigator.clipboard.writeText( + this.otherPersonData.fullNameWithTitle, + ); + this.$fhcAlert.alertSuccess( + this.$p.t("profil/name_title_clipboard_copy_confirmation"), + ); + }, }, async created() { await this.redirectToMyLvPlanIfAuthUid(); await this.fetchViewData(); + await this.$p.loadCategory(["profil"]); }, - template: ` + template: /*html*/ `

@@ -238,8 +254,11 @@ export default { {{ $p.t('lehre/stundenplan') + (studiensemester_kurzbz ? " " + studiensemester_kurzbz : "") }}
- - {{ otherPersonData.fullName }} + + {{ otherPersonData.fullNameWithTitle }} + + + profile picture
diff --git a/public/js/components/Cis/Mylv/RoomInformation.js b/public/js/components/Cis/Mylv/RoomInformation.js index cff9f0e4b..2c6a5b77c 100644 --- a/public/js/components/Cis/Mylv/RoomInformation.js +++ b/public/js/components/Cis/Mylv/RoomInformation.js @@ -2,6 +2,7 @@ import FhcCalendar from "../../Calendar/LvPlan.js"; import ApiLvPlan from '../../../api/factory/lvPlan.js'; import ApiRoomPlan from '../../../api/factory/calendar/roomPlan.js'; +import ApiRoom from '../../../api/factory/ort.js'; export const DEFAULT_MODE_RAUMINFO_MOBILE = 'List'; export const DEFAULT_MODE_RAUMINFO_DESKTOP = 'Week'; @@ -33,28 +34,14 @@ export default { scope: 'slot_room', show_all_fields: false, room_create_information: { - semester: [1, 2, 3, 4, 5, 6, 7, 8], - verband: ['A', 'B', 'C', 'D', 'E', 'F', 'V'], - gruppe: [1, 2, 3, 4], studiengaenge: [], searchGroup: this.searchGroup, searchLektor: this.searchLektor, }, - } + }, + roomAdditionalInfo: null, } }, - created() { - - this.$api.call(ApiRoomPlan.getRoomCreationInfo()) - .then(result => result.data) - .then(result => { - if (result.berechtigt) - { - this.createContext.room_create_information.studiengaenge = result.studiengaenge - } - this.createContext.show_all_fields = result.berechtigt; - }); - }, methods:{ handleChangeDate(day, newMode) { return this.handleChangeMode(newMode, day); @@ -72,12 +59,15 @@ export default { } }); }, - async handleCreateEvent(event) - { + async handleCreateEvent(event) { event.ort_kurzbz = this.propsViewData.ort_kurzbz; - this.$api.call(ApiRoomPlan.addRoomReservation(event)); - this.$refs.calendar.resetEventLoader(); - this.$refs.calendar.closeModal(); + const reservationCreationResponse = await this.$api.call( + ApiRoomPlan.addRoomReservation(event), + ); + if (reservationCreationResponse.meta.status === "success") { + this.$refs.calendar.resetEventLoader(); + this.$refs.calendar.closeModal(); + } }, async handleDeleteEvent(event) { @@ -87,7 +77,7 @@ export default { if (luxon.DateTime.fromISO(`${event.datum}T${event.beginn}`) < luxon.DateTime.now()) return; - this.$api.call(ApiRoomPlan.deleteRoomReservation(event.reservierung_id)); + await this.$api.call(ApiRoomPlan.deleteRoomReservation(event.reservierung_id)); this.$refs.calendar.reset(); @@ -152,9 +142,32 @@ export default { ]; } }, + async created() { + const roomCreationInfoResponse = await this.$api.call( + ApiRoomPlan.getRoomCreationInfo(), + ); + if (roomCreationInfoResponse.meta?.status === "success") { + const berechtigt = !!roomCreationInfoResponse.data?.berechtigt; + if (roomCreationInfoResponse.data?.berechtigt) { + this.createContext.room_create_information.studiengaenge = + roomCreationInfoResponse.data?.studiengaenge; + } + this.createContext.show_all_fields = + roomCreationInfoResponse.data?.berechtigt; + } + + const roomInfoResponse = await this.$api.call( + ApiRoom.getRoomInfo(this.$props.propsViewData.ort_kurzbz), + ); + this.roomAdditionalInfo = roomInfoResponse.data.ausstattung; + }, template: /*html*/`

{{ $p.t('rauminfo/rauminfo') }} {{ propsViewData.ort_kurzbz }}

+ + {{ $p.t("search/result_equipment") + ":" }} + +
@@ -285,6 +289,16 @@ export default {
+ +
+
+ +
+
diff --git a/public/js/components/Cis/Profil/ProfilComponents/ProfilInformation.js b/public/js/components/Cis/Profil/ProfilComponents/ProfilInformation.js index d714c9faa..2df4024ca 100644 --- a/public/js/components/Cis/Profil/ProfilComponents/ProfilInformation.js +++ b/public/js/components/Cis/Profil/ProfilComponents/ProfilInformation.js @@ -67,6 +67,21 @@ export default { return res; }, }, + methods: { + copyFullNameWithTitleToClipboard() { + let fragments = [ + this.$props.data.Titel, + this.$props.data.Vorname, + this.$props.data.Nachname, + this.$props.data.Postnomen, + ]; + fragments = fragments.filter((fragment) => !!fragment); + navigator.clipboard.writeText(fragments.join(" ")); + this.$fhcAlert.alertSuccess( + this.$p.t("profil/name_title_clipboard_copy_confirmation"), + ); + }, + }, template: /*html*/ `
@@ -124,6 +139,16 @@ export default { {{wert?wert:'-'}}
+ + + + + {{ $p.t("profil/name_title_copy") }} +

diff --git a/public/js/components/Cis/Raumsuche/Raumsuche.js b/public/js/components/Cis/Raumsuche/Raumsuche.js index dea747780..37cd8dadd 100644 --- a/public/js/components/Cis/Raumsuche/Raumsuche.js +++ b/public/js/components/Cis/Raumsuche/Raumsuche.js @@ -129,7 +129,7 @@ export const Raumsuche = { this.$api.call(ApiOrt.getRoomTypes()) .then(res => { res?.data?.forEach(type => { - type.beschreibung = type.beschreibung.replace('&', '&') + type.beschreibung = type.beschreibung?.replace('&', '&') }) this.selectedType = this.defaultType this.roomtypes = res?.data ?? [] diff --git a/public/js/components/Cis/Renderer/Lehreinheit/modalContent.js b/public/js/components/Cis/Renderer/Lehreinheit/modalContent.js index 738c0c75c..eb8908648 100644 --- a/public/js/components/Cis/Renderer/Lehreinheit/modalContent.js +++ b/public/js/components/Cis/Renderer/Lehreinheit/modalContent.js @@ -51,6 +51,16 @@ export default { return numberPadding(this.event.ende.getHours()) + ":" + numberPadding(this.event.ende.getMinutes()); } }, + watch: { + event: { + async handler() { + if (this.$props.event.type == "lehreinheit") { + await this.getLvMenu(); + } + }, + deep: true, + }, + }, methods: { mehtodNumberPadding: function (number) { return numberPadding(number); @@ -58,21 +68,31 @@ export default { methodFormatDate: function (d) { return formatDate(d); }, + async getLvMenu() { + const semesterResponse = await this.$api.call( + ApiLvPlan.getLehreinheitStudiensemester( + Array.isArray(this.event.lehreinheit_id) + ? this.event.lehreinheit_id[0] + : this.event.lehreinheit_id, + ), + ); + if (semesterResponse.meta.status !== "success") return; + + const studiensemesterKurzbz = semesterResponse.data; + const lvMenuResponse = await this.$api.call( + ApiAddons.getLvMenu( + this.event.lehrveranstaltung_id, + studiensemesterKurzbz, + ), + ); + if (lvMenuResponse.meta.status !== "success") return; + + this.lvMenu = lvMenuResponse.data; + }, }, - created() { - if (this.event.type == 'lehreinheit') { - this.$api - .call(ApiLvPlan.getLehreinheitStudiensemester(Array.isArray(this.event.lehreinheit_id) ? this.event.lehreinheit_id[0] : this.event.lehreinheit_id)) - .then(res => res.data) - .then(studiensemester_kurzbz => this.$api.call( - ApiAddons.getLvMenu( - this.event.lehrveranstaltung_id, - studiensemester_kurzbz - ) - )) - .then(res => { - this.lvMenu = res.data; - }); + async created() { + if (this.$props.event.type == 'lehreinheit') { + await this.getLvMenu(); } }, template: ` diff --git a/public/js/components/Cis/Renderer/Reservierungen/calendarEvent.js b/public/js/components/Cis/Renderer/Reservierungen/calendarEvent.js index 91c49d5e0..2a0f4db95 100644 --- a/public/js/components/Cis/Renderer/Reservierungen/calendarEvent.js +++ b/public/js/components/Cis/Renderer/Reservierungen/calendarEvent.js @@ -32,6 +32,11 @@ export default { this.$p.t('person/ort'), this.event.ort_kurzbz ].join(": ")); + + tooltipArray.push([ + this.$p.t('lehre/gruppe'), + this.event.gruppe[0].kuerzel.split("/")[0] + ].join(": ")); if (Array.isArray(this.event.lektor) && this.event.lektor.length > 0) { if (this.event.lektor.length > 3) { @@ -103,6 +108,7 @@ export default { {{ event.topic }} {{ event.ort_kurzbz }} + {{ event.gruppe[0].kuerzel.split("/")[0] }} + + {{ + $p.t('lehre','gruppe')? + $p.t('lehre','gruppe')+':' + :'' + }} + {{event.gruppe[0].kuerzel.split("/")[0]}} + {{ $p.t('lehre','lektor')? diff --git a/public/js/components/Cis/Renderer/Slot/roomModalContent.js b/public/js/components/Cis/Renderer/Slot/roomModalContent.js index e9f518c75..124c25192 100644 --- a/public/js/components/Cis/Renderer/Slot/roomModalContent.js +++ b/public/js/components/Cis/Renderer/Slot/roomModalContent.js @@ -1,9 +1,10 @@ -import FormInput from '../../../Form/Input.js'; +import FormInput from "../../../Form/Input.js"; +import ApiRoomPlan from "../../../../api/factory/calendar/roomPlan.js"; export default { - emits: ['create-event'], + emits: ["create-event"], components: { - FormInput + FormInput, }, inject: { timeGrid: "timeGrid", @@ -26,38 +27,85 @@ export default { verband: null, gruppe: null, selectedGruppe: null, - selectedLektoren: [] + selectedLektoren: [], + nestedGroupOptions: {}, }; }, - mounted() { - this.syncFromEvent(this.event); - }, + computed: { + semesterOptions() { + if (!this.studiengang) return []; + return Object.keys(this.nestedGroupOptions) + .map((val) => parseInt(val)) + .sort((a, b) => a - b); + }, + verbandOptions() { + if (!this.semester) return []; + + return Object.keys(this.nestedGroupOptions[this.semester]).sort(); + }, + gruppeOptions() { + if (!this.verband) return []; + + return this.nestedGroupOptions[this.semester][this.verband] + .map((val) => parseInt(val)) + .sort((a, b) => a - b); + }, + }, watch: { event: { handler(newEvent) { this.syncFromEvent(newEvent); }, - deep: true - } + deep: true, + }, + studiengang() { + this.semester = null; + this.verband = null; + this.gruppe = null; + this.nestedGroupOptions = {}; + this.fetchGroupOptions(); + }, + semester() { + this.verband = null; + }, + verband() { + this.gruppe = null; + }, }, methods: { syncFromEvent(newEvent) { if (!newEvent) return; - const startTime = newEvent.start?.setZone?.(this.timezone)?.toFormat?.('HH:mm:ss'); - const endTime = newEvent.end?.setZone?.(this.timezone)?.toFormat?.('HH:mm:ss'); + const startTime = newEvent.start + ?.setZone?.(this.timezone) + ?.toFormat?.("HH:mm:ss"); + const endTime = newEvent.end + ?.setZone?.(this.timezone) + ?.toFormat?.("HH:mm:ss"); - this.selectedStart = this.timeGrid.find(t => t.start === startTime)?.start || this.timeGrid[0]?.start; - this.selectedEnd = this.timeGrid.find(t => t.end === endTime)?.end || this.timeGrid.at(-1)?.end; + this.selectedStart = + this.timeGrid.find((t) => t.start === startTime)?.start || + this.timeGrid[0]?.start; + this.selectedEnd = + this.timeGrid.find((t) => t.end === endTime)?.end || + this.timeGrid.at(-1)?.end; }, saveEvent() { - const [startHour, startMinute] = this.selectedStart.split(':').map(Number); - const [endHour, endMinute] = this.selectedEnd.split(':').map(Number); - const selectedStart = this.event.start.startOf('day').set({ hour: startHour, minute: startMinute }); - const selectedEnd = this.event.start.startOf('day').set({ hour: endHour, minute: endMinute }); + const [startHour, startMinute] = this.selectedStart + .split(":") + .map(Number); + const [endHour, endMinute] = this.selectedEnd + .split(":") + .map(Number); + const selectedStart = this.event.start + .startOf("day") + .set({ hour: startHour, minute: startMinute }); + const selectedEnd = this.event.start + .startOf("day") + .set({ hour: endHour, minute: endMinute }); - const lektoren_uid = this.selectedLektoren.map(m => m.uid) + const lektoren_uid = this.selectedLektoren.map((m) => m.uid); const spezialgruppe = this.selectedGruppe?.gruppe_kurzbz; @@ -71,22 +119,41 @@ export default { verband: this.verband, gruppe: this.gruppe, spezialgruppe: spezialgruppe, - lektoren: lektoren_uid - } + lektoren: lektoren_uid, + }; - this.$emit('create-event', event); + this.$emit("create-event", event); }, async searchGroup(event) { - this.filteredGroups = await this.event.createContext.room_create_information.searchGroup(event) + this.filteredGroups = + await this.event.createContext.room_create_information.searchGroup( + event, + ); }, async searchLektor(event) { - this.filteredLektoren = await this.event.createContext.room_create_information.searchLektor(event) + this.filteredLektoren = + await this.event.createContext.room_create_information.searchLektor( + event, + ); }, - capitalize(text) - { - if (!text) return '' - return text.charAt(0).toUpperCase() + text.slice(1) - } + capitalize(text) { + if (!text) return ""; + return text.charAt(0).toUpperCase() + text.slice(1); + }, + async fetchGroupOptions() { + if (!this.studiengang) return; + + const groupOptionsResponse = await this.$api.call( + ApiRoomPlan.getGroupOptions(this.studiengang), + ); + + if (groupOptionsResponse.meta.status === "success") { + this.nestedGroupOptions = groupOptionsResponse.data; + } + }, + }, + mounted() { + this.syncFromEvent(this.event); }, template: ` @@ -130,6 +197,7 @@ export default { :label="capitalize($p.t('global', 'titel'))" type="text" container-class="col-3" + maxlength="10" v-model="title" name="title" /> @@ -138,6 +206,7 @@ export default { :label="capitalize($p.t('global', 'beschreibung'))" type="text" container-class="col-4" + maxlength="32" v-model="beschreibung" name="Beschreibung" /> @@ -184,7 +253,7 @@ export default { name="semester" >
- ` + `, }; diff --git a/public/js/components/Cis/Studium/Studium.js b/public/js/components/Cis/Studium/Studium.js index 815793f55..2b6fb5779 100644 --- a/public/js/components/Cis/Studium/Studium.js +++ b/public/js/components/Cis/Studium/Studium.js @@ -382,13 +382,13 @@ export default {