mirror of
https://github.com/FH-Complete/FHC-Core.git
synced 2026-10-12 15:04:04 +00:00
Compare commits
69
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aabec8d6fd | ||
|
|
0d82753833 | ||
|
|
a9b9cd59b0 | ||
|
|
c1939d0e23 | ||
|
|
73c63c791d | ||
|
|
7c9bcb6fd2 | ||
|
|
4399857f4a | ||
|
|
3212800643 | ||
|
|
6d2cbf4896 | ||
|
|
97be497fda | ||
|
|
3c9f46941c | ||
|
|
c9c92f82d3 | ||
|
|
56fcb23275 | ||
|
|
dba199b531 | ||
|
|
193dd166c3 | ||
|
|
431e0bb341 | ||
|
|
beb54c1bc5 | ||
|
|
4e6a2d5218 | ||
|
|
273f00a9c4 | ||
|
|
32de4aac6b | ||
|
|
8bf18b0d37 | ||
|
|
79c0f31ddc | ||
|
|
49d61342c2 | ||
|
|
be0943f1ba | ||
|
|
c56064d189 | ||
|
|
f6747713a1 | ||
|
|
5a6d20f817 | ||
|
|
ce5da22180 | ||
|
|
05b2c3c42b | ||
|
|
2dd732b924 | ||
|
|
110f73e622 | ||
|
|
bb0d118284 | ||
|
|
701ccadff3 | ||
|
|
34555504df | ||
|
|
d1f5220925 | ||
|
|
decd514b22 | ||
|
|
6cf7093293 | ||
|
|
17f11fa871 | ||
|
|
96812868a4 | ||
|
|
40c79158f7 | ||
|
|
054663ee00 | ||
|
|
81eee814e9 | ||
|
|
390a3c0d5a | ||
|
|
695dd655c0 | ||
|
|
957da460a6 | ||
|
|
2cee36d7b5 | ||
|
|
43925e3088 | ||
|
|
1c236cce02 | ||
|
|
4956a517ca | ||
|
|
a6167583a3 | ||
|
|
1e68eb0b90 | ||
|
|
332efd4106 | ||
|
|
f303191c54 | ||
|
|
d6c7f16ceb | ||
|
|
f1912fe739 | ||
|
|
2f7fe05d21 | ||
|
|
ee4b61f549 | ||
|
|
511a4256bc | ||
|
|
3c9db86df2 | ||
|
|
367204a1ee | ||
|
|
bbe55a75ea | ||
|
|
e58bf3a8cf | ||
|
|
1f2f866c61 | ||
|
|
6ccbc95697 | ||
|
|
52d9e0a195 | ||
|
|
6a3982347b | ||
|
|
fe7feeb74e | ||
|
|
658fe79ad7 | ||
|
|
5bbf05ac8a |
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
if (!defined('BASEPATH')) exit('No direct script access allowed');
|
||||
|
||||
// The configuration of the Benotungstool. The tool reads two sources:
|
||||
// this file $this->config->item('KEY')
|
||||
// global.config.inc.php define() flags of the old tool: CIS_GESAMTNOTE_PUNKTE, CIS_GESAMTNOTE_GEWICHTUNG,
|
||||
// CIS_GESAMTNOTE_FREIGABEMAIL_NOTE, CIS_GESAMTNOTE_PRUEFUNG_MOODLE_LE_NOTE and
|
||||
// CIS_GESAMTNOTE_PRUEFUNG_TERMIN2/TERMIN3/KOMMPRUEF (see CIS_GESAMTNOTE_MAX_ANTRITTE)
|
||||
//
|
||||
// The prefixes in this file:
|
||||
// NOTEN_, NOTE_ name a Note by its Bezeichnung. The server finds its key in lehre.tbl_note.
|
||||
// PRUEFUNG_ name a Pruefungstyp from lehre.tbl_pruefungstyp.
|
||||
// CIS_GESAMTNOTE_ the switches and values of the tool.
|
||||
//
|
||||
// Every key must be present. The code has no default values.
|
||||
|
||||
// --- Noten ---------------------------------------------------------------------------------------
|
||||
|
||||
// Noten that use no Antritt.
|
||||
$config['NOTEN_OHNE_ANTRITT_BEZEICHNUNGEN'] = ['Noch nicht eingetragen', 'entschuldigt'];
|
||||
|
||||
// Anrechnung Noten: an earlier result counts for this LV. As a Zeugnisnote they block every Pruefung.
|
||||
$config['NOTEN_ANRECHNUNG_BEZEICHNUNGEN'] = ['angerechnet', 'intern angerechnet'];
|
||||
|
||||
// How often a Note may occur in the Pruefungen of one student.
|
||||
$config['NOTEN_OCCURRENCE_LIMIT_MAP'] = ['entschuldigt' => 1];
|
||||
|
||||
// The Note 'entschuldigt'. An addon can report a Pruefung date as entschuldigt.
|
||||
$config['NOTE_ENTSCHULDIGT_BEZEICHNUNG'] = 'entschuldigt';
|
||||
|
||||
// The Note of a Pruefung without a result.
|
||||
$config['NOTE_NICHT_EINGETRAGEN_BEZEICHNUNG'] = 'Noch nicht eingetragen';
|
||||
|
||||
// Noten with no better one. They always close the Antritt chain.
|
||||
$config['NOTEN_ABSCHLIESSEND_BEZEICHNUNGEN'] = ['Sehr Gut', 'Bestanden', 'Approbiert', 'Erfolgreich absolviert'];
|
||||
|
||||
// The order of the Noten, best first. tbl_note.notenwert is NULL everywhere, so the order cannot come
|
||||
// from the database. A Note outside this list is not comparable.
|
||||
$config['NOTEN_RANGFOLGE_BEZEICHNUNGEN'] = ['Sehr Gut', 'Gut', 'Befriedigend', 'Genügend', 'Nicht Genügend'];
|
||||
|
||||
// The order of the Noten in the lists: 'skala' = 1-5 first, then alphabetical; 'bezeichnung' = alphabetical.
|
||||
$config['NOTEN_SORTIERUNG'] = 'skala';
|
||||
|
||||
// --- Antritte -------------------------------------------------------------------------------------
|
||||
|
||||
// The maximum number of Antritte, Antritt 1 and the kommissionelle Pruefung included.
|
||||
// null = 1 + CIS_GESAMTNOTE_PRUEFUNG_TERMIN2 + CIS_GESAMTNOTE_PRUEFUNG_TERMIN3 + CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF.
|
||||
// The Pruefungsordnung permits three Antritte: TERMIN2 and KOMMPRUEF give 3, TERMIN3 adds one more.
|
||||
$config['CIS_GESAMTNOTE_MAX_ANTRITTE'] = null;
|
||||
|
||||
// The Antritt from which the Pruefung is kommissionell:
|
||||
// 'letzter' the last Antritt (CIS_GESAMTNOTE_MAX_ANTRITTE)
|
||||
// 0 never
|
||||
// 3 from Antritt 3 on
|
||||
// A chain of one Antritt is never kommissionell.
|
||||
$config['CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT'] = 'letzter';
|
||||
|
||||
// The Benotungstool may create the kommissionelle Pruefung. Some installations enter it in the StV.
|
||||
// false: the tool shows an existing kommissionelle Pruefung, but it creates none. The chain then ends
|
||||
// one Antritt earlier for this tool.
|
||||
$config['CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF'] = true;
|
||||
|
||||
// Allow another Antritt after a positive Note. It uses an Antritt like any repeat.
|
||||
$config['CIS_GESAMTNOTE_NOTENVERBESSERUNG'] = false;
|
||||
|
||||
// A worse repeat keeps the better LV-Note. The Pruefung keeps its own Note.
|
||||
$config['CIS_GESAMTNOTE_VERBESSERUNG_BESSERE_GEWINNT'] = false;
|
||||
|
||||
// Create Antritt 1 as its own Pruefung when a Note becomes the LV-Note: the proposal, the import, the
|
||||
// Freigabe, and the Notenuebernahme in the StV.
|
||||
$config['CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME'] = true;
|
||||
|
||||
// --- Pruefungstypen ---------------------------------------------------------------------------------
|
||||
// The tool derives the position of a Pruefung from its date. The type marks a kommissionelle Pruefung,
|
||||
// sorts old Pruefungen without a date, separates Antritt 1 from a repeat (verlauf.hasRepeat), and feeds
|
||||
// the old reports of the StV.
|
||||
|
||||
// The Pruefungstypen of a kommissionelle Pruefung.
|
||||
$config['PRUEFUNG_KOMMISSIONELL_TYPEN'] = ['kommPruef', 'zusKommPruef'];
|
||||
|
||||
// The Pruefungstyp that the tool writes for a kommissionelle Pruefung.
|
||||
$config['PRUEFUNG_TYP_KOMMISSIONELL'] = 'kommPruef';
|
||||
|
||||
// Pruefungstypen that never use an Antritt. A zusKommPruef repeats a kommissionelle Pruefung with a
|
||||
// procedural fault. The StV enters it.
|
||||
$config['PRUEFUNG_TYPEN_OHNE_ANTRITT'] = ['zusKommPruef'];
|
||||
|
||||
// The Pruefungstyp that the tool writes for each Antritt. A type that tbl_pruefungstyp does not have
|
||||
// is skipped (Termin3 is absent by default).
|
||||
$config['PRUEFUNG_TYP_JE_ANTRITT'] = [1 => 'Termin1', 2 => 'Termin2', 3 => 'Termin3'];
|
||||
|
||||
// --- Pruefung dates -------------------------------------------------------------------------------
|
||||
|
||||
// Allow a new Pruefung on the day of an existing one.
|
||||
$config['CIS_GESAMTNOTE_PRUEFUNG_GLEICHER_TAG'] = false;
|
||||
|
||||
// A later Pruefung locks the Note of an earlier one. Its date stays editable.
|
||||
$config['CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETERER_PRUEFUNG'] = true;
|
||||
|
||||
// Allow a Benotungsdatum in the future: the day of Antritt 1 when the LV-Note is written. A Pruefung
|
||||
// may always lie in the future: without a Note it waits for its result.
|
||||
$config['CIS_GESAMTNOTE_DATUM_ZUKUNFT'] = false;
|
||||
|
||||
// The minimum number of days between two Antritte. A Pruefung without an Antritt does not count.
|
||||
// null = off
|
||||
$config['CIS_GESAMTNOTE_ANTRITT_MIN_ABSTAND_TAGE'] = null;
|
||||
|
||||
// The maximum number of days between two Antritte. null = off
|
||||
$config['CIS_GESAMTNOTE_ANTRITT_MAX_ABSTAND_TAGE'] = null;
|
||||
|
||||
// --- Frist ----------------------------------------------------------------------------------------
|
||||
// The Frist (Pruefungsordnung §1) is a day in the year of the Studiensemester:
|
||||
// Sommersemester SSyyyy -> the same year
|
||||
// Wintersemester WSyyyy -> the next year
|
||||
|
||||
$config['NOTENEINTRAGUNGSFRIST_SS'] = ['month' => 11, 'day' => 15];
|
||||
$config['NOTENEINTRAGUNGSFRIST_WS'] = ['month' => 5, 'day' => 15];
|
||||
|
||||
// No entry after the Frist.
|
||||
$config['CIS_GESAMTNOTE_FRIST_EINGABE'] = true;
|
||||
|
||||
// No Pruefung date after the Frist.
|
||||
// Example: a Pruefung must take place before the Frist, but the Note may come later -> EINGABE false.
|
||||
$config['CIS_GESAMTNOTE_FRIST_PRUEFUNGSDATUM'] = true;
|
||||
|
||||
// Permissions that may enter after the Frist. The Pruefung date has no exception.
|
||||
// Example: ['admin', 'lehre/benotungstool_assistenz']
|
||||
$config['CIS_GESAMTNOTE_FRIST_AUSNAHME'] = [];
|
||||
|
||||
// --- Roles and Freigabe ---------------------------------------------------------------------------
|
||||
|
||||
// The actions of each permission. The keys are also the permissions that open the tool.
|
||||
// lvnote write the LV-Note (the proposal)
|
||||
// pruefung create or edit a Pruefung
|
||||
// kommpruef create the kommissionelle Pruefung (also needs CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF)
|
||||
// freigabe the Freigabe
|
||||
// import the two imports
|
||||
// A user with several permissions gets all their actions.
|
||||
$config['CIS_GESAMTNOTE_ROLLENMATRIX'] = [
|
||||
'lehre/benotungstool' => ['lvnote', 'pruefung', 'kommpruef', 'freigabe', 'import'],
|
||||
'lehre/benotungstool_assistenz' => ['lvnote', 'pruefung', 'kommpruef', 'freigabe', 'import']
|
||||
];
|
||||
|
||||
// A Lektor sees only the LVs that the Lektor teaches.
|
||||
$config['CIS_GESAMTNOTE_LEKTOR_NUR_EIGENE_LV'] = true;
|
||||
|
||||
// The Freigabe asks for the password of the user.
|
||||
$config['CIS_GESAMTNOTE_FREIGABE_PASSWORT'] = true;
|
||||
|
||||
// The Freigabe sends a mail.
|
||||
$config['CIS_GESAMTNOTE_FREIGABEMAIL'] = true;
|
||||
|
||||
// The recipients of the mail: 'studiengang' = the addresses of the Studiengang, 'aufrufer' = the user.
|
||||
// An entry with an '@' is a fixed address.
|
||||
$config['CIS_GESAMTNOTE_FREIGABEMAIL_EMPFAENGER'] = ['studiengang', 'aufrufer'];
|
||||
|
||||
// The Sancho template of the mail. The text is in the database.
|
||||
$config['CIS_GESAMTNOTE_FREIGABEMAIL_VORLAGE'] = 'Notenfreigabe';
|
||||
|
||||
// A freigegeben LV-Note is final. No path can change it.
|
||||
$config['CIS_GESAMTNOTE_FREIGABE_FINAL'] = false;
|
||||
|
||||
// A new or changed Pruefung cancels the Freigabe of the LV-Note: it sets the benotungsdatum.
|
||||
$config['CIS_GESAMTNOTE_PRUEFUNG_HEBT_FREIGABE_AUF'] = true;
|
||||
|
||||
// --- LV-Note and proposal -------------------------------------------------------------------------
|
||||
|
||||
// An LV-Note must be a Note of the Lehre (tbl_note.lehre).
|
||||
$config['CIS_GESAMTNOTE_LVNOTE_NUR_LEHRENOTEN'] = true;
|
||||
|
||||
// The proposal stays open after a repeat. false: after a repeat the Lektor changes the LV-Note
|
||||
// through the Pruefung.
|
||||
$config['CIS_GESAMTNOTE_VORSCHLAG_NACH_WIEDERHOLUNG'] = false;
|
||||
|
||||
// The rounding of the Teilnoten average. The smaller number is the better Note:
|
||||
// 'kaufmaennisch' 2.5 -> 3
|
||||
// 'besser' 2.5 -> 2
|
||||
// 'schlechter' 2.1 -> 3
|
||||
$config['CIS_GESAMTNOTE_VORSCHLAG_RUNDUNG'] = 'kaufmaennisch';
|
||||
|
||||
// The decimals of the Punkte average before the Notenschluessel applies.
|
||||
$config['CIS_GESAMTNOTE_VORSCHLAG_PUNKTE_STELLEN'] = 2;
|
||||
|
||||
// --- Import and display ---------------------------------------------------------------------------
|
||||
|
||||
// The two imports. With both on, the tool shows two buttons.
|
||||
$config['CIS_GESAMTNOTE_PRUEFUNGSIMPORT'] = true; // one Pruefung per row: Kennung, Datum, Note
|
||||
$config['CIS_GESAMTNOTE_NOTENIMPORT'] = false; // one LV-Note per row: Kennung, Note
|
||||
|
||||
// The Note column of an import accepts the key of lehre.tbl_note. true also accepts the short form
|
||||
// from lehre.tbl_note.anmerkung ('nb', 'ea', 'en') of the Excel Notenliste.
|
||||
$config['CIS_GESAMTNOTE_IMPORT_NOTENKUERZEL'] = false;
|
||||
|
||||
// The column order of an import. Allowed: 'kennung', 'datum', 'note'.
|
||||
$config['CIS_GESAMTNOTE_IMPORT_SPALTEN_NOTEN'] = ['kennung', 'note'];
|
||||
$config['CIS_GESAMTNOTE_IMPORT_SPALTEN_PRUEFUNG'] = ['kennung', 'datum', 'note'];
|
||||
|
||||
// The date format of an import: 'dd.MM.yyyy' or 'yyyy-MM-dd'.
|
||||
$config['CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT'] = 'dd.MM.yyyy';
|
||||
|
||||
// An import stops at the first rejected row. false: it reports the row and writes the others.
|
||||
$config['CIS_GESAMTNOTE_IMPORT_ABBRUCH'] = false;
|
||||
|
||||
// The Pruefung columns of the table. The user can switch the layout; the browser keeps the choice.
|
||||
// 'antritt' one column per Antritt, the date is in the cell
|
||||
// 'datum' one column per Pruefung date
|
||||
$config['CIS_GESAMTNOTE_PRUEFUNGSSPALTEN'] = 'antritt';
|
||||
|
||||
// The badge in a Pruefung cell; {n} is the Antritt number.
|
||||
$config['CIS_GESAMTNOTE_ANTRITT_ZEICHEN'] = [
|
||||
'kommissionell' => '{n}-K',
|
||||
'kommissionell_ohne_antritt' => 'K',
|
||||
'antritt' => '{n}',
|
||||
'ohne_antritt' => '–'
|
||||
];
|
||||
@@ -64,6 +64,7 @@ $route['api/v1/system/[S|s]prache/(:any)'] = 'api/v1/system/sprache2/$1';
|
||||
$route['Cis/LvPlan/.*'] = 'Cis/LvPlan/index/$1';
|
||||
$route['Cis/MyLvPlan/.*'] = 'Cis/MyLvPlan/index/$1';
|
||||
$route['Cis/MyLv/.*'] = 'Cis/MyLv/index/$1';
|
||||
$route['Cis/Benotungstool/.*'] = 'Cis/Benotungstool/index/$1';
|
||||
$route['Cis/OtherLvPlan/.*'] = 'Cis/OtherLvPlan/index/$1';
|
||||
//Route for LV Plan Stg/Semester/Verband/Gruppe
|
||||
$route['Cis/StgOrgLvPlan/.*'] = 'Cis/StgOrgLvPlan/index/$1';
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
if (! defined('BASEPATH')) exit('No direct script access allowed');
|
||||
|
||||
// the constructor needs the permissions before CodeIgniter can load a helper
|
||||
require_once APPPATH . 'helpers/hlp_benotungstool_helper.php';
|
||||
|
||||
/**
|
||||
* The page of the Benotungstool. The same permissions open it as the Noten API.
|
||||
*/
|
||||
class Benotungstool extends Auth_Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct([
|
||||
'index' => benotungstoolPermissions()
|
||||
]);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
// Public methods
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$viewData = array(
|
||||
'uid'=>getAuthUID(),
|
||||
);
|
||||
|
||||
$this->load->view('CisRouterView/CisRouterView.php', ['viewData' => $viewData, 'route' => 'Benotungstool']);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -18,18 +18,9 @@
|
||||
|
||||
if (! defined('BASEPATH')) exit('No direct script access allowed');
|
||||
|
||||
//require_once('../../../include/studiengang.class.php');
|
||||
//require_once('../../../include/student.class.php');
|
||||
//require_once('../../../include/datum.class.php');
|
||||
//require_once('../../../include/mail.class.php');
|
||||
//require_once('../../../include/benutzerberechtigung.class.php');
|
||||
//require_once('../../../include/phrasen.class.php');
|
||||
//require_once('../../../include/projektarbeit.class.php');
|
||||
//require_once('../../../include/projektbetreuer.class.php');
|
||||
|
||||
class Lehre extends FHCAPI_Controller
|
||||
{
|
||||
|
||||
|
||||
/**
|
||||
* Object initialization
|
||||
*/
|
||||
@@ -41,37 +32,53 @@ class Lehre extends FHCAPI_Controller
|
||||
'Pruefungen' => self::PERM_LOGGED,
|
||||
'semesterAverageGrade' => self::PERM_LOGGED,
|
||||
]);
|
||||
|
||||
|
||||
$this->load->library('PhrasesLib');
|
||||
|
||||
$this->loadPhrases(
|
||||
array(
|
||||
'global',
|
||||
'ui',
|
||||
'abgabetool'
|
||||
)
|
||||
);
|
||||
|
||||
$this->load->helper('hlp_sancho_helper');
|
||||
|
||||
require_once(FHCPATH . 'include/studiengang.class.php');
|
||||
require_once(FHCPATH . 'include/student.class.php');
|
||||
require_once(FHCPATH . 'include/projektarbeit.class.php');
|
||||
require_once(FHCPATH . 'include/projektbetreuer.class.php');
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Public methods
|
||||
|
||||
/**
|
||||
/**
|
||||
* constructs the emails of the groups from a lehrveranstaltung
|
||||
*/
|
||||
public function lvStudentenMail()
|
||||
public function lvStudentenMail()
|
||||
{
|
||||
$lehreinheit_id = $this->input->get("lehreinheit_id",TRUE);
|
||||
|
||||
// return early if the required parameter is missing
|
||||
if(!isset($lehreinheit_id))
|
||||
{
|
||||
$this->terminateWithError('Missing required parameter', self::ERROR_TYPE_GENERAL);
|
||||
}
|
||||
$lehreinheit_id = $this->input->get("lehreinheit_id",TRUE);
|
||||
|
||||
$this->load->model('education/Lehreinheit_model', 'LehreinheitModel');
|
||||
|
||||
$studentenMails = $this->LehreinheitModel->getStudentenMail($lehreinheit_id);
|
||||
// return early if the required parameter is missing
|
||||
if(!isset($lehreinheit_id))
|
||||
{
|
||||
$this->terminateWithError('Missing required parameter', self::ERROR_TYPE_GENERAL);
|
||||
}
|
||||
|
||||
$studentenMails = $this->getDataOrTerminateWithError($studentenMails);
|
||||
$this->load->model('education/Lehreinheit_model', 'LehreinheitModel');
|
||||
|
||||
$studentenMails = $this->LehreinheitModel->getStudentenMail($lehreinheit_id);
|
||||
|
||||
$studentenMails = $this->getDataOrTerminateWithError($studentenMails);
|
||||
|
||||
//convert array of objects into array of strings
|
||||
$studentenMails = array_map(function($element){
|
||||
return $element->mail;
|
||||
}, $studentenMails);
|
||||
|
||||
$this->terminateWithSuccess($studentenMails);
|
||||
$this->terminateWithSuccess($studentenMails);
|
||||
}
|
||||
|
||||
public function LV($studiensemester_kurzbz, $lehrveranstaltung_id)
|
||||
@@ -81,13 +88,13 @@ class Lehre extends FHCAPI_Controller
|
||||
$result = $this->LehrveranstaltungModel->getLvsByStudentWithGrades(getAuthUID(), $studiensemester_kurzbz, getUserLanguage(), $lehrveranstaltung_id);
|
||||
|
||||
$result = current($this->getDataOrTerminateWithError($result));
|
||||
|
||||
|
||||
$this->terminateWithSuccess($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* fetches all Pruefungen of a student for a specific lehrveranstaltung
|
||||
* if the student passed the Pruefung on the first attempt, no information about the Pruefungen is stored in the database
|
||||
* if the student passed the Pruefung on the first attempt, no information about the Pruefungen is stored in the database
|
||||
* @param mixed $lehrveranstaltung_id
|
||||
* @return void
|
||||
*/
|
||||
@@ -145,5 +152,5 @@ class Lehre extends FHCAPI_Controller
|
||||
|
||||
$this->terminateWithSuccess(['average_grade' => $averageGrade, 'weighted_average_grade' => $weightedAverageGrade]);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
if (! defined('BASEPATH')) exit('No direct script access allowed');
|
||||
|
||||
/**
|
||||
* The permissions that open the Benotungstool: the keys of CIS_GESAMTNOTE_ROLLENMATRIX. The page and
|
||||
* the Noten API read this one list. A constructor needs it before CodeIgniter can load a configuration,
|
||||
* so this reads the file itself.
|
||||
*
|
||||
* @return array one 'permission:rw' entry per key
|
||||
*/
|
||||
function benotungstoolPermissions()
|
||||
{
|
||||
$config = array();
|
||||
include APPPATH . 'config/noten.php';
|
||||
|
||||
$permissions = array();
|
||||
foreach (array_keys($config['CIS_GESAMTNOTE_ROLLENMATRIX']) as $permission) $permissions[] = $permission . ':rw';
|
||||
|
||||
return $permissions;
|
||||
}
|
||||
@@ -0,0 +1,765 @@
|
||||
<?php
|
||||
/**
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
if (! defined('BASEPATH')) exit('No direct script access allowed');
|
||||
|
||||
/**
|
||||
* The Verlauf of one student in one LV: the order of the Pruefungen, the Antritte and the limits.
|
||||
*
|
||||
* This class holds the rules of the Antritt chain. The controller holds access, Frist and Freigabe.
|
||||
* The client only reads the Verlauf.
|
||||
*
|
||||
* The StV calls getPruefungen() and legacyTypFuerAntritt(). Keep their names and signatures.
|
||||
*/
|
||||
class PruefungsverlaufLib
|
||||
{
|
||||
/** Antritt 1. Without a Pruefung the LV-Note itself is Antritt 1. */
|
||||
const ROLE_ERSTANTRITT = 'erstantritt';
|
||||
|
||||
/** Each Antritt after the first one. */
|
||||
const ROLE_PRUEFUNG = 'pruefung';
|
||||
|
||||
/** The Antritt before a commission (CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT). */
|
||||
const ROLE_KOMMISSIONELL = 'kommissionell';
|
||||
|
||||
/** upsertErstantritt(): an existing Antritt 1 keeps its date. */
|
||||
const KEEP_DATUM = false;
|
||||
|
||||
/** upsertErstantritt(): an existing Antritt 1 takes the new date. */
|
||||
const SET_DATUM = true;
|
||||
|
||||
private $_ci;
|
||||
private $_noteCache = [];
|
||||
private $_specialNotes = null;
|
||||
private $_types = 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');
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the Verlauf. Each Pruefung gets position, is_antritt, antritt_nr, kommissionell and
|
||||
* note_locked. The rules and the client read this one object.
|
||||
*
|
||||
* @param array $pruefungen the Pruefungen of one student in one LV
|
||||
* @param mixed|null $lvNote Antritt 1 while no Pruefung is an Antritt
|
||||
* @param mixed|null $zeugnisnote an Anrechnung blocks every Pruefung
|
||||
* @return stdClass
|
||||
*/
|
||||
public function buildVerlauf($pruefungen, $lvNote, $zeugnisnote)
|
||||
{
|
||||
$pruefungen = $this->sortPruefungen($pruefungen);
|
||||
|
||||
// a later Pruefung locks the Note of an earlier one; its date stays editable
|
||||
$lockedByLater = $this->configBool('CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETERER_PRUEFUNG');
|
||||
|
||||
$antrittCount = 0;
|
||||
$terminal = false;
|
||||
$entries = [];
|
||||
|
||||
foreach ($pruefungen as $i => $pruefung) {
|
||||
$isAntritt = $this->isAntritt($pruefung->note, $pruefung->pruefungstyp_kurzbz);
|
||||
if ($isAntritt) $antrittCount++;
|
||||
|
||||
// A kommissionelle Pruefung closes the chain, also if its Note uses no Antritt.
|
||||
$kommissionell = $this->isKommissionell($pruefung->pruefungstyp_kurzbz);
|
||||
if ($kommissionell) $terminal = true;
|
||||
|
||||
$entry = clone $pruefung;
|
||||
$entry->position = $i + 1;
|
||||
$entry->is_antritt = $isAntritt;
|
||||
$entry->antritt_nr = $isAntritt ? $antrittCount : null;
|
||||
$entry->kommissionell = $kommissionell;
|
||||
$entry->note_locked = $lockedByLater && $i < count($pruefungen) - 1;
|
||||
$entries[] = $entry;
|
||||
}
|
||||
|
||||
// without a Pruefung that is an Antritt, the LV-Note itself is Antritt 1
|
||||
$implicit = ($antrittCount === 0 && $lvNote !== null && $this->isAntritt($lvNote));
|
||||
if ($implicit) $antrittCount = 1;
|
||||
|
||||
// the deciding Note: the last Antritt, else the LV-Note
|
||||
$currentNote = null;
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry->is_antritt) $currentNote = $entry->note;
|
||||
}
|
||||
if ($currentNote === null && $implicit) $currentNote = $lvNote;
|
||||
|
||||
// a closing Note always ends the chain, any other positive Note only without Notenverbesserung
|
||||
$bestanden = $this->isClosingNote($currentNote)
|
||||
|| (!$this->allowsImprovement() && $this->isPositiveNote($currentNote));
|
||||
if ($bestanden) $terminal = true;
|
||||
|
||||
$verlauf = new stdClass();
|
||||
$verlauf->pruefungen = $entries;
|
||||
$verlauf->antrittCount = $antrittCount;
|
||||
$verlauf->maxAntritte = $this->getMaxAntritte();
|
||||
$verlauf->terminal = $terminal;
|
||||
// closed by a pass, not by the limit
|
||||
$verlauf->bestanden = $bestanden;
|
||||
// the row stays visible, but nobody can enter anything
|
||||
$verlauf->angerechnet = $this->isAnrechnungNote($zeugnisnote);
|
||||
$verlauf->implicitErstantritt = $implicit;
|
||||
$verlauf->canAdd = !$terminal && !$verlauf->angerechnet && $antrittCount < $verlauf->maxAntritte;
|
||||
|
||||
// a second Pruefung, or one Pruefung that is not Antritt 1 (next to an implicit Antritt 1).
|
||||
// This is the only rule that reads the legacy Pruefungstyp back.
|
||||
$verlauf->hasRepeat = count($entries) > 1
|
||||
|| (count($entries) === 1 && $entries[0]->pruefungstyp_kurzbz !== $this->legacyTypFuerAntritt(1));
|
||||
|
||||
// The role follows from the position (§17 Abs 1), so the user interface offers no type.
|
||||
if (count($entries) === 0 && !$implicit) {
|
||||
$verlauf->nextRole = self::ROLE_ERSTANTRITT;
|
||||
} else {
|
||||
// a Pruefung exists: the next one is a repeat, also if no Pruefung is an Antritt yet
|
||||
$verlauf->nextRole = $this->roleForAntritt(max(2, $antrittCount + 1));
|
||||
}
|
||||
|
||||
// the next Antritt is kommissionell, and this tool may not create it
|
||||
$verlauf->kommPruefLocked = $verlauf->canAdd
|
||||
&& $verlauf->nextRole === self::ROLE_KOMMISSIONELL
|
||||
&& !$this->canCreateKommPruef();
|
||||
if ($verlauf->kommPruefLocked) $verlauf->canAdd = false;
|
||||
|
||||
$verlauf->earliestNewDay = $this->earliestNewDay($entries);
|
||||
$verlauf->notenAtLimit = $this->notenAtLimit($entries);
|
||||
|
||||
return $verlauf;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed|null $lvNote see buildVerlauf
|
||||
* @param mixed|null $zeugnisnote see buildVerlauf
|
||||
* @return stdClass
|
||||
*/
|
||||
public function getVerlauf($student_uid, $lv_id, $sem_kurzbz, $lvNote, $zeugnisnote)
|
||||
{
|
||||
return $this->buildVerlauf($this->getPruefungen($student_uid, $lv_id, $sem_kurzbz), $lvNote, $zeugnisnote);
|
||||
}
|
||||
|
||||
/** All Pruefungen of one student in one LV, in the order of the Verlauf. The StV calls it. @return array */
|
||||
public function getPruefungen($student_uid, $lv_id, $sem_kurzbz)
|
||||
{
|
||||
$result = $this->_ci->LePruefungModel->getPruefungenByStudent($student_uid, $lv_id, $sem_kurzbz);
|
||||
if (isError($result) || !hasData($result)) return [];
|
||||
|
||||
return $this->sortPruefungen(getData($result));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a NEW Antritt: A the limit, B the order of the dates, C the occurrence limit, D the gap.
|
||||
*
|
||||
* @param stdClass $verlauf from buildVerlauf
|
||||
* @return array|null [phraseKey, params] or null
|
||||
*/
|
||||
public function validateAdd($verlauf, $note, $datum)
|
||||
{
|
||||
if ($verlauf->angerechnet) return ['c4angerechnetKeinePruefung', []];
|
||||
if ($verlauf->kommPruefLocked) return ['kommPruefNichtErlaubt', []];
|
||||
|
||||
// A: Antritt 1 only writes the LV-Note down, so it needs no free Antritt
|
||||
if ($verlauf->nextRole !== self::ROLE_ERSTANTRITT && !$verlauf->canAdd) {
|
||||
if ($verlauf->bestanden) return ['pruefungNachBestandenerNote', []];
|
||||
|
||||
return ['maxAntritteReached', [$verlauf->maxAntritte]];
|
||||
}
|
||||
|
||||
// one Pruefung without a result per Verlauf; more of them break the order of the Antritte
|
||||
if ($this->isOpen($note) && $this->hasOpenPruefung($verlauf->pruefungen)) return ['pruefungOhneErgebnis', []];
|
||||
|
||||
// B: no Pruefung before an existing one
|
||||
$newDay = substr((string) $datum, 0, 10);
|
||||
if ($verlauf->earliestNewDay !== null && $newDay < $verlauf->earliestNewDay) return ['pruefungDatumBeforeExisting', []];
|
||||
|
||||
// C: for example one 'entschuldigt' only
|
||||
if ($this->exceedsNoteLimit($verlauf->pruefungen, $note)) return ['noteOccuranceLimitReached', []];
|
||||
|
||||
// D
|
||||
return $this->checkAntrittGap($verlauf, $newDay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a CHANGED Pruefung. The date must stay between its neighbours, and a later Pruefung locks
|
||||
* the Note.
|
||||
*
|
||||
* @param stdClass $verlauf from buildVerlauf
|
||||
* @return array|null [phraseKey, params] or null
|
||||
*/
|
||||
public function validateEdit($verlauf, $pruefung_id, $note, $datum)
|
||||
{
|
||||
if ($verlauf->angerechnet) return ['c4angerechnetKeinePruefung', []];
|
||||
|
||||
// only a Pruefung of this student in this LV; the StV owns the types without an Antritt
|
||||
$current = $this->findPruefung($verlauf->pruefungen, $pruefung_id);
|
||||
if ($current === null || in_array($current->pruefungstyp_kurzbz, $this->getTypesWithoutAntritt())) {
|
||||
return ['pruefungNichtBearbeitbar', []];
|
||||
}
|
||||
|
||||
if ($this->isOpen($note) && $this->hasOpenPruefung($verlauf->pruefungen, $pruefung_id)) return ['pruefungOhneErgebnis', []];
|
||||
|
||||
if ($current->note_locked && $note != $current->note) return ['pruefungNoteLocked', []];
|
||||
|
||||
// the date must stay strictly between the dates of the neighbours
|
||||
$currentDay = substr((string) $current->datum, 0, 10);
|
||||
$newDay = substr((string) $datum, 0, 10);
|
||||
$lower = null;
|
||||
$upper = null;
|
||||
foreach ($verlauf->pruefungen as $pruefung) {
|
||||
$day = substr((string) $pruefung->datum, 0, 10);
|
||||
if ($pruefung->pruefung_id == $current->pruefung_id || $day === '') continue;
|
||||
|
||||
if ($day < $currentDay && ($lower === null || $day > $lower)) $lower = $day;
|
||||
if ($day > $currentDay && ($upper === null || $day < $upper)) $upper = $day;
|
||||
}
|
||||
if (($lower !== null && $newDay <= $lower) || ($upper !== null && $newDay >= $upper)) {
|
||||
return ['pruefungDatumOutOfRange', []];
|
||||
}
|
||||
|
||||
if ($this->exceedsNoteLimit($verlauf->pruefungen, $note, $current->pruefung_id)) {
|
||||
return ['noteOccuranceLimitReached', []];
|
||||
}
|
||||
|
||||
// only a new date: an old gap outside the rule must not block a Note correction
|
||||
if ($newDay === $currentDay) return null;
|
||||
|
||||
return $this->checkAntrittGap($verlauf, $newDay, $current->pruefung_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* The LV-Note after one Pruefung write: the Note of the last Antritt. Without an Antritt the
|
||||
* implicit Antritt 1 stays, else nothing is entered. The LV-Note is never 'entschuldigt'.
|
||||
*
|
||||
* With CIS_GESAMTNOTE_VERBESSERUNG_BESSERE_GEWINNT a worse new Antritt keeps the old LV-Note.
|
||||
*
|
||||
* @param stdClass $verlauf from buildVerlauf, before this write
|
||||
* @param mixed $pruefung_id set = the changed Pruefung, empty = a new Pruefung after all others
|
||||
* @return array [note, punkte]
|
||||
*/
|
||||
public function deriveLvNote($verlauf, $pruefung_id, $note, $punkte, $lvNote, $lvPunkte)
|
||||
{
|
||||
$isNew = $pruefung_id === null || $pruefung_id === '';
|
||||
|
||||
$result = null;
|
||||
foreach ($verlauf->pruefungen as $pruefung) {
|
||||
$row = (!$isNew && $pruefung->pruefung_id == $pruefung_id) ? [$note, $punkte] : [$pruefung->note, $pruefung->punkte];
|
||||
if ($this->isAntritt($row[0], $pruefung->pruefungstyp_kurzbz)) $result = $row;
|
||||
}
|
||||
if ($isNew && $this->isAntritt($note)) $result = [$note, $punkte];
|
||||
|
||||
if ($result === null) {
|
||||
if ($verlauf->implicitErstantritt) return [$lvNote, $lvPunkte];
|
||||
|
||||
return [$this->getNoteNichtEingetragen(), null];
|
||||
}
|
||||
|
||||
if ($result[0] == $note
|
||||
&& $this->configBool('CIS_GESAMTNOTE_VERBESSERUNG_BESSERE_GEWINNT')
|
||||
&& $this->isWorse($note, $lvNote)) {
|
||||
return [$lvNote, $lvPunkte];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes Antritt 1 as its own Pruefung. An LV-Note without a Pruefung IS Antritt 1. While that row
|
||||
* is missing, the next Pruefung becomes Antritt 2 and the Pruefungstypen of the chain shift.
|
||||
*
|
||||
* Writes nothing once a repeat exists, and nothing into a Pruefung with a result of its own.
|
||||
*
|
||||
* @param bool $datumMode self::SET_DATUM or self::KEEP_DATUM for an existing Antritt 1
|
||||
* @return stdClass|null the Pruefung, or null if nothing was written
|
||||
*/
|
||||
public function upsertErstantritt($student_uid, $lv_id, $sem_kurzbz, $note, $punkte, $datum, $datumMode, $mitarbeiter_uid, $lehreinheit_id)
|
||||
{
|
||||
$pruefungen = $this->getPruefungen($student_uid, $lv_id, $sem_kurzbz);
|
||||
// hasRepeat reads only the Pruefungen
|
||||
if ($this->buildVerlauf($pruefungen, null, null)->hasRepeat) return null;
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$day = substr((string) $datum, 0, 10);
|
||||
|
||||
if (count($pruefungen) === 1) {
|
||||
if (!$this->canOverwriteErstantritt($pruefungen[0])) return null;
|
||||
|
||||
$data = [
|
||||
'note' => $note,
|
||||
'punkte' => $punkte,
|
||||
'updateamum' => $now,
|
||||
'updatevon' => getAuthUID()
|
||||
];
|
||||
if ($datumMode === self::SET_DATUM) $data['datum'] = $day;
|
||||
|
||||
$this->_ci->LePruefungModel->update($pruefungen[0]->pruefung_id, $data);
|
||||
|
||||
return $this->loadPruefung($pruefungen[0]->pruefung_id);
|
||||
}
|
||||
|
||||
if ($lehreinheit_id === null) return null;
|
||||
|
||||
$result = $this->_ci->LePruefungModel->insert([
|
||||
'lehreinheit_id' => $lehreinheit_id,
|
||||
'student_uid' => $student_uid,
|
||||
'mitarbeiter_uid' => $mitarbeiter_uid,
|
||||
'note' => $note,
|
||||
'punkte' => $punkte,
|
||||
'pruefungstyp_kurzbz' => $this->legacyTypFuerAntritt(1),
|
||||
'datum' => $day,
|
||||
'anmerkung' => '',
|
||||
'insertamum' => $now,
|
||||
'insertvon' => getAuthUID(),
|
||||
'updateamum' => null,
|
||||
'updatevon' => null,
|
||||
'ext_id' => null
|
||||
]);
|
||||
|
||||
return $result ? $this->loadPruefung($result->retval) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Pruefungstyp that the tool writes for an Antritt. The StV still reads that column and calls
|
||||
* this method. A type that tbl_pruefungstyp does not have breaks the foreign key, so step down.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function legacyTypFuerAntritt($antrittNr)
|
||||
{
|
||||
$antrittNr = max(1, (int) $antrittNr);
|
||||
$types = $this->getTypes();
|
||||
|
||||
$kommissionell = $this->_ci->config->item('PRUEFUNG_TYP_KOMMISSIONELL');
|
||||
if ($this->roleForAntritt($antrittNr) === self::ROLE_KOMMISSIONELL && isset($types[$kommissionell])) {
|
||||
return $kommissionell;
|
||||
}
|
||||
|
||||
$typePerAntritt = $this->configArray('PRUEFUNG_TYP_JE_ANTRITT');
|
||||
for ($nr = $antrittNr; $nr >= 1; $nr--) {
|
||||
if (isset($typePerAntritt[$nr]) && isset($types[$typePerAntritt[$nr]])) return $typePerAntritt[$nr];
|
||||
}
|
||||
|
||||
return $typePerAntritt[1];
|
||||
}
|
||||
|
||||
/** The Pruefungstyp of a new repeat; never below Antritt 2. @return string */
|
||||
public function legacyTypeForRepeat($verlauf)
|
||||
{
|
||||
return $this->legacyTypFuerAntritt(max(2, $verlauf->antrittCount + 1));
|
||||
}
|
||||
|
||||
/** Antritte, Antritt 1 and the kommissionelle Pruefung included. @return int */
|
||||
public function getMaxAntritte()
|
||||
{
|
||||
$max = $this->_ci->config->item('CIS_GESAMTNOTE_MAX_ANTRITTE');
|
||||
if ($max !== null) return (int) $max;
|
||||
|
||||
// the flags of the old tool: Antritt 1 plus one Antritt per flag
|
||||
return 1 + (int) CIS_GESAMTNOTE_PRUEFUNG_TERMIN2 + (int) CIS_GESAMTNOTE_PRUEFUNG_TERMIN3 + (int) CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF;
|
||||
}
|
||||
|
||||
/** The Antritt from which a Pruefung is kommissionell, or null. @return int|null */
|
||||
public function getKommissionellFromAntritt()
|
||||
{
|
||||
$value = $this->_ci->config->item('CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT');
|
||||
|
||||
if ($value === 'letzter') {
|
||||
$max = $this->getMaxAntritte();
|
||||
|
||||
// a chain of one has nothing to close
|
||||
return $max > 1 ? $max : null;
|
||||
}
|
||||
|
||||
return (int) $value >= 1 ? (int) $value : null;
|
||||
}
|
||||
|
||||
/** May this tool create the kommissionelle Pruefung? It always shows an existing one. @return bool */
|
||||
public function canCreateKommPruef()
|
||||
{
|
||||
return $this->configBool('CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF');
|
||||
}
|
||||
|
||||
/** May a student take another Antritt after a positive Note? @return bool */
|
||||
public function allowsImprovement()
|
||||
{
|
||||
return $this->configBool('CIS_GESAMTNOTE_NOTENVERBESSERUNG');
|
||||
}
|
||||
|
||||
/** @return array the Pruefungstypen that never use an Antritt */
|
||||
public function getTypesWithoutAntritt()
|
||||
{
|
||||
return $this->configArray('PRUEFUNG_TYPEN_OHNE_ANTRITT');
|
||||
}
|
||||
|
||||
/**
|
||||
* The special Noten. The configuration names them, lehre.tbl_note gives their keys: a key means
|
||||
* something else in every installation. An unknown name is dropped.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getSpecialNotes()
|
||||
{
|
||||
if ($this->_specialNotes !== null) return $this->_specialNotes;
|
||||
|
||||
$entschuldigt = $this->getNoteByBezeichnung($this->_ci->config->item('NOTE_ENTSCHULDIGT_BEZEICHNUNG'));
|
||||
|
||||
$ohneAntritt = $this->resolveNoten('NOTEN_OHNE_ANTRITT_BEZEICHNUNGEN');
|
||||
if ($entschuldigt !== null && !in_array($entschuldigt, $ohneAntritt)) $ohneAntritt[] = $entschuldigt;
|
||||
|
||||
// the configuration names a Note, the rules work with its key
|
||||
$limitMap = [];
|
||||
foreach ($this->configArray('NOTEN_OCCURRENCE_LIMIT_MAP') as $bezeichnung => $limit) {
|
||||
$note = $this->getNoteByBezeichnung($bezeichnung);
|
||||
if ($note !== null) $limitMap[$note] = $limit;
|
||||
}
|
||||
|
||||
$this->_specialNotes = [
|
||||
'entschuldigt' => $entschuldigt,
|
||||
'ohneAntritt' => array_values($ohneAntritt),
|
||||
'anrechnung' => $this->resolveNoten('NOTEN_ANRECHNUNG_BEZEICHNUNGEN'),
|
||||
'abschliessend' => $this->resolveNoten('NOTEN_ABSCHLIESSEND_BEZEICHNUNGEN'),
|
||||
'nichtEingetragen' => $this->getNoteByBezeichnung($this->_ci->config->item('NOTE_NICHT_EINGETRAGEN_BEZEICHNUNG')),
|
||||
// best first
|
||||
'rangfolge' => $this->resolveNoten('NOTEN_RANGFOLGE_BEZEICHNUNGEN'),
|
||||
'limitMap' => $limitMap
|
||||
];
|
||||
return $this->_specialNotes;
|
||||
}
|
||||
|
||||
/** The Note of a Pruefung without a result ('Noch nicht eingetragen'). @return mixed|null */
|
||||
public function getNoteNichtEingetragen()
|
||||
{
|
||||
return $this->getSpecialNotes()['nichtEingetragen'];
|
||||
}
|
||||
|
||||
/** 'angerechnet' or 'intern angerechnet'. Ask it about the Zeugnisnote. @return bool */
|
||||
public function isAnrechnungNote($note)
|
||||
{
|
||||
if ($note === null || $note === '') return false;
|
||||
|
||||
return in_array($note, $this->getSpecialNotes()['anrechnung']);
|
||||
}
|
||||
|
||||
/** Would one more $note cross NOTEN_OCCURRENCE_LIMIT_MAP? $pruefung_id skips the changed row. @return bool */
|
||||
public function exceedsNoteLimit($pruefungen, $note, $pruefung_id = null)
|
||||
{
|
||||
$limit = null;
|
||||
foreach ($this->getSpecialNotes()['limitMap'] as $limitNote => $value) {
|
||||
if ($limitNote == $note) $limit = $value;
|
||||
}
|
||||
if ($limit === null) return false;
|
||||
|
||||
$count = 0;
|
||||
foreach ($pruefungen as $pruefung) {
|
||||
if ($pruefung_id !== null && $pruefung->pruefung_id == $pruefung_id) continue;
|
||||
if ($pruefung->note == $note) $count++;
|
||||
}
|
||||
|
||||
return $count + 1 > $limit;
|
||||
}
|
||||
|
||||
// --- private --------------------------------------------------------------------------------
|
||||
|
||||
/** The role of one Antritt: 1 = erstantritt, later = pruefung, from the commission on = kommissionell. @return string */
|
||||
private function roleForAntritt($antrittNr)
|
||||
{
|
||||
$nr = max(1, (int) $antrittNr);
|
||||
$fromAntritt = $this->getKommissionellFromAntritt();
|
||||
|
||||
if ($fromAntritt !== null && $nr >= $fromAntritt) return self::ROLE_KOMMISSIONELL;
|
||||
|
||||
return $nr === 1 ? self::ROLE_ERSTANTRITT : self::ROLE_PRUEFUNG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this Note use an Antritt? The Note decides; the type only through PRUEFUNG_TYPEN_OHNE_ANTRITT.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function isAntritt($note, $type = null)
|
||||
{
|
||||
if ($type !== null && in_array($type, $this->getTypesWithoutAntritt())) return false;
|
||||
if ($note === null || $note === '') return false;
|
||||
if (in_array($note, $this->getSpecialNotes()['ohneAntritt'])) return false;
|
||||
|
||||
// an Anrechnung is no assessment
|
||||
if ($this->isAnrechnungNote($note)) return false;
|
||||
|
||||
$noteRow = $this->getNote($note);
|
||||
return $noteRow ? (bool) $noteRow->lehre : false;
|
||||
}
|
||||
|
||||
/** Is this Pruefungstyp kommissionell? @return bool */
|
||||
private function isKommissionell($type)
|
||||
{
|
||||
return in_array($type, $this->configArray('PRUEFUNG_KOMMISSIONELL_TYPEN'));
|
||||
}
|
||||
|
||||
/** Is the new Note worse? A Note outside NOTEN_RANGFOLGE_BEZEICHNUNGEN is never worse. @return bool */
|
||||
private function isWorse($newNote, $oldNote)
|
||||
{
|
||||
$rangfolge = $this->getSpecialNotes()['rangfolge'];
|
||||
|
||||
$newRank = array_search($newNote, $rangfolge);
|
||||
$oldRank = array_search($oldNote, $rangfolge);
|
||||
if ($newRank === false || $oldRank === false) return false;
|
||||
|
||||
return $newRank > $oldRank;
|
||||
}
|
||||
|
||||
/** The order of the Verlauf: datum, then tbl_pruefungstyp.sort (old rows without a date), then pruefung_id. */
|
||||
private function sortPruefungen($pruefungen)
|
||||
{
|
||||
$pruefungen = array_values($pruefungen);
|
||||
$types = $this->getTypes();
|
||||
|
||||
usort($pruefungen, function ($a, $b) use ($types) {
|
||||
$dayA = substr((string) $a->datum, 0, 10);
|
||||
$dayB = substr((string) $b->datum, 0, 10);
|
||||
|
||||
if ($dayA !== $dayB) {
|
||||
if ($dayA === '') return -1;
|
||||
if ($dayB === '') return 1;
|
||||
return ($dayA < $dayB) ? -1 : 1;
|
||||
}
|
||||
|
||||
$sortA = isset($types[$a->pruefungstyp_kurzbz]) ? (int) $types[$a->pruefungstyp_kurzbz]->sort : 0;
|
||||
$sortB = isset($types[$b->pruefungstyp_kurzbz]) ? (int) $types[$b->pruefungstyp_kurzbz]->sort : 0;
|
||||
if ($sortA !== $sortB) return ($sortA < $sortB) ? -1 : 1;
|
||||
|
||||
return ((int) $a->pruefung_id < (int) $b->pruefung_id) ? -1 : 1;
|
||||
});
|
||||
|
||||
return $pruefungen;
|
||||
}
|
||||
|
||||
/**
|
||||
* May the LV-Note go into this Pruefung? Antritt 1 and the LV-Note are the same result, and an
|
||||
* empty Pruefung waits for its result. A Pruefung with a result of its own ('entschuldigt',
|
||||
* 'Nicht beurteilt') keeps it: the LV-Note then stays the implicit Antritt 1.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function canOverwriteErstantritt($pruefung)
|
||||
{
|
||||
if (in_array($pruefung->pruefungstyp_kurzbz, $this->getTypesWithoutAntritt())) return false;
|
||||
if ($this->isAntritt($pruefung->note, $pruefung->pruefungstyp_kurzbz)) return true;
|
||||
|
||||
return $this->isOpen($pruefung->note);
|
||||
}
|
||||
|
||||
/**
|
||||
* The first day for a new Pruefung: it comes after all others. CIS_GESAMTNOTE_PRUEFUNG_GLEICHER_TAG
|
||||
* allows the day of the last one.
|
||||
*
|
||||
* @return string|null Y-m-d, or null without a dated Pruefung
|
||||
*/
|
||||
private function earliestNewDay($entries)
|
||||
{
|
||||
$lastDay = null;
|
||||
foreach ($entries as $entry) {
|
||||
$day = substr((string) $entry->datum, 0, 10);
|
||||
if ($day !== '' && ($lastDay === null || $day > $lastDay)) $lastDay = $day;
|
||||
}
|
||||
|
||||
if ($lastDay === null || $this->configBool('CIS_GESAMTNOTE_PRUEFUNG_GLEICHER_TAG')) return $lastDay;
|
||||
|
||||
return date('Y-m-d', strtotime($lastDay . ' +1 day'));
|
||||
}
|
||||
|
||||
/** The Noten that NOTEN_OCCURRENCE_LIMIT_MAP allows no more time in this Verlauf. @return array Note => limit */
|
||||
private function notenAtLimit($entries)
|
||||
{
|
||||
$atLimit = [];
|
||||
foreach ($this->getSpecialNotes()['limitMap'] as $limitNote => $limit) {
|
||||
$count = 0;
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry->note == $limitNote) $count++;
|
||||
}
|
||||
if ($count >= $limit) $atLimit[$limitNote] = $limit;
|
||||
}
|
||||
|
||||
return $atLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* The gaps between a Pruefung on $newDay and the Antritte next to it: the last one on or before
|
||||
* the day, and the first one after it. A new Pruefung has no Antritt after it. A Pruefung without
|
||||
* an Antritt does not count: an entschuldigt date must not push the next Antritt away.
|
||||
*
|
||||
* @param mixed $pruefung_id the changed Pruefung, which is no neighbour of itself; null for a new one
|
||||
* @return array|null [phraseKey, params] or null
|
||||
*/
|
||||
private function checkAntrittGap($verlauf, $newDay, $pruefung_id = null)
|
||||
{
|
||||
$min = $this->_ci->config->item('CIS_GESAMTNOTE_ANTRITT_MIN_ABSTAND_TAGE');
|
||||
$max = $this->_ci->config->item('CIS_GESAMTNOTE_ANTRITT_MAX_ABSTAND_TAGE');
|
||||
if (($min === null && $max === null) || $newDay === '') return null;
|
||||
|
||||
$before = null;
|
||||
$after = null;
|
||||
foreach ($verlauf->pruefungen as $pruefung) {
|
||||
$day = substr((string) $pruefung->datum, 0, 10);
|
||||
if (!$pruefung->is_antritt || $day === '' || ($pruefung_id !== null && $pruefung->pruefung_id == $pruefung_id)) continue;
|
||||
|
||||
if ($day <= $newDay && ($before === null || $day > $before)) $before = $day;
|
||||
if ($day > $newDay && ($after === null || $day < $after)) $after = $day;
|
||||
}
|
||||
|
||||
foreach ([[$before, $newDay], [$newDay, $after]] as list($from, $to)) {
|
||||
$gap = ($from !== null && $to !== null) ? $this->daysBetween($from, $to) : null;
|
||||
if ($gap === null) continue;
|
||||
|
||||
if ($min !== null && $gap < (int) $min) return ['pruefungAbstandZuKurz', [(int) $min]];
|
||||
if ($max !== null && $gap > (int) $max) return ['pruefungAbstandZuLang', [(int) $max]];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @return array the list under $key, or an empty list */
|
||||
private function configArray($key)
|
||||
{
|
||||
$value = $this->_ci->config->item($key);
|
||||
return is_array($value) ? $value : [];
|
||||
}
|
||||
|
||||
/** @return bool */
|
||||
private function configBool($key)
|
||||
{
|
||||
return (bool) $this->_ci->config->item($key);
|
||||
}
|
||||
|
||||
/** Whole days from $from to $to (Y-m-d). @return int|null */
|
||||
private function daysBetween($from, $to)
|
||||
{
|
||||
$a = DateTime::createFromFormat('Y-m-d|', $from);
|
||||
$b = DateTime::createFromFormat('Y-m-d|', $to);
|
||||
if ($a === false || $b === false) return null;
|
||||
|
||||
$diff = $a->diff($b);
|
||||
|
||||
return $diff->invert ? -((int) $diff->days) : (int) $diff->days;
|
||||
}
|
||||
|
||||
/** @return stdClass|null */
|
||||
private function findPruefung($pruefungen, $pruefung_id)
|
||||
{
|
||||
foreach ($pruefungen as $pruefung) {
|
||||
if ($pruefung->pruefung_id == $pruefung_id) return $pruefung;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @return stdClass|null the tbl_note row */
|
||||
private function getNote($note)
|
||||
{
|
||||
if (!array_key_exists($note, $this->_noteCache)) {
|
||||
$result = $this->_ci->NoteModel->load($note);
|
||||
$this->_noteCache[$note] = (!isError($result) && hasData($result)) ? getData($result)[0] : null;
|
||||
}
|
||||
|
||||
return $this->_noteCache[$note];
|
||||
}
|
||||
|
||||
/** @return mixed|null the key of the Note, or null if no Note has this Bezeichnung */
|
||||
private function getNoteByBezeichnung($bezeichnung)
|
||||
{
|
||||
if (!is_string($bezeichnung) || trim($bezeichnung) === '') return null;
|
||||
|
||||
$result = $this->_ci->NoteModel->loadWhere(['bezeichnung' => $bezeichnung]);
|
||||
return (!isError($result) && hasData($result)) ? getData($result)[0]->note : null;
|
||||
}
|
||||
|
||||
/** lehre.tbl_pruefungstyp by pruefungstyp_kurzbz. @return array */
|
||||
private function getTypes()
|
||||
{
|
||||
if ($this->_types !== null) return $this->_types;
|
||||
|
||||
$this->_types = [];
|
||||
$result = $this->_ci->PruefungstypModel->load();
|
||||
if (!isError($result) && hasData($result)) {
|
||||
foreach (getData($result) as $type) $this->_types[$type->pruefungstyp_kurzbz] = $type;
|
||||
}
|
||||
|
||||
return $this->_types;
|
||||
}
|
||||
|
||||
/** Does a Pruefung other than $pruefung_id still wait for its result? @return bool */
|
||||
private function hasOpenPruefung($pruefungen, $pruefung_id = null)
|
||||
{
|
||||
foreach ($pruefungen as $pruefung) {
|
||||
if ($pruefung->pruefung_id != $pruefung_id && $this->isOpen($pruefung->note)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** A Note with no better one ends the chain for good. @return bool */
|
||||
private function isClosingNote($note)
|
||||
{
|
||||
if ($note === null || $note === '') return false;
|
||||
|
||||
return in_array($note, $this->getSpecialNotes()['abschliessend']);
|
||||
}
|
||||
|
||||
/** 'Noch nicht eingetragen': the Pruefung waits for its result. @return bool */
|
||||
private function isOpen($note)
|
||||
{
|
||||
$open = $this->getNoteNichtEingetragen();
|
||||
|
||||
return $open !== null && $note !== null && $note !== '' && $note == $open;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this Note a pass? tbl_note.positiv is also true for 'entschuldigt' and 'Teilgenommen', so ask
|
||||
* it only about a Note that uses an Antritt.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function isPositiveNote($note)
|
||||
{
|
||||
if ($note === null || $note === '') return false;
|
||||
|
||||
$noteRow = $this->getNote($note);
|
||||
return $noteRow ? (bool) $noteRow->positiv : false;
|
||||
}
|
||||
|
||||
/** @return stdClass|null */
|
||||
private function loadPruefung($pruefung_id)
|
||||
{
|
||||
$result = $this->_ci->LePruefungModel->load($pruefung_id);
|
||||
return (!isError($result) && hasData($result)) ? getData($result)[0] : null;
|
||||
}
|
||||
|
||||
/** The keys of the Noten that one configuration key names. @return array */
|
||||
private function resolveNoten($key)
|
||||
{
|
||||
$noten = [];
|
||||
foreach ($this->configArray($key) as $bezeichnung) {
|
||||
$note = $this->getNoteByBezeichnung($bezeichnung);
|
||||
if ($note !== null && !in_array($note, $noten)) $noten[] = $note;
|
||||
}
|
||||
|
||||
return $noten;
|
||||
}
|
||||
}
|
||||
@@ -11,4 +11,73 @@ class Mobilitaet_model extends DB_Model
|
||||
$this->dbTable = 'bis.tbl_mobilitaet';
|
||||
$this->pk = 'mobilitaet_id';
|
||||
}
|
||||
|
||||
public function getMobilityZusatzForUids($uids) {
|
||||
$qry = "SELECT distinct on(nachname, vorname, public.tbl_benutzer.person_id) uid,
|
||||
tbl_mitarbeiter.mitarbeiter_uid,
|
||||
tbl_note.lkt_ueberschreibbar, tbl_note.anmerkung,
|
||||
tbl_mobilitaet.mobilitaetstyp_kurzbz,
|
||||
(CASE WHEN bis.tbl_mobilitaet.studiensemester_kurzbz = vw_student_lehrveranstaltung.studiensemester_kurzbz THEN 1 ELSE 0 END) as doubledegree,
|
||||
public.tbl_prestudent.gsstudientyp_kurzbz as ddtype,
|
||||
(SELECT status_kurzbz FROM public.tbl_prestudentstatus
|
||||
WHERE prestudent_id=tbl_student.prestudent_id
|
||||
ORDER BY datum DESC, insertamum DESC, ext_id DESC LIMIT 1) as studienstatus
|
||||
FROM
|
||||
campus.vw_student_lehrveranstaltung
|
||||
JOIN public.tbl_benutzer USING(uid)
|
||||
JOIN public.tbl_person USING(person_id)
|
||||
LEFT JOIN public.tbl_student ON(uid=student_uid)
|
||||
LEFT JOIN public.tbl_mitarbeiter ON(uid=mitarbeiter_uid)
|
||||
LEFT JOIN public.tbl_studentlehrverband USING(student_uid,studiensemester_kurzbz)
|
||||
LEFT JOIN lehre.tbl_zeugnisnote on(vw_student_lehrveranstaltung.lehrveranstaltung_id=tbl_zeugnisnote.lehrveranstaltung_id
|
||||
AND tbl_zeugnisnote.student_uid=tbl_student.student_uid
|
||||
AND tbl_zeugnisnote.studiensemester_kurzbz=tbl_studentlehrverband.studiensemester_kurzbz)
|
||||
LEFT JOIN lehre.tbl_note USING (note)
|
||||
LEFT JOIN bis.tbl_bisio ON(uid=tbl_bisio.student_uid)
|
||||
LEFT JOIN public.tbl_studiengang ON(tbl_student.studiengang_kz=tbl_studiengang.studiengang_kz)
|
||||
LEFT JOIN bis.tbl_mobilitaet USING(prestudent_id)
|
||||
LEFT JOIN public.tbl_prestudent USING(prestudent_id)
|
||||
WHERE uid IN ?";
|
||||
|
||||
return $this->execReadOnlyQuery($qry, [$uids]);
|
||||
}
|
||||
|
||||
public function formatZusatz($entry, $erhalter_kz) {
|
||||
$zusatz = '';
|
||||
|
||||
if (isset($entry->studienstatus) && $entry->studienstatus === 'Incoming') {
|
||||
$zusatz = '(i)';
|
||||
}
|
||||
|
||||
if (isset($entry->lkt_ueberschreibbar) && $entry->lkt_ueberschreibbar === false) {
|
||||
$zusatz .= ' (' . ($entry->anmerkung ?? '') . ')';
|
||||
}
|
||||
|
||||
if (isset($entry->mitarbeiter_uid) && $entry->mitarbeiter_uid !== null) {
|
||||
$zusatz .= ' (ma)';
|
||||
}
|
||||
|
||||
if (isset($entry->stg_kz_student) && $entry->stg_kz_student == $erhalter_kz) {
|
||||
$zusatz .= ' (a.o.)';
|
||||
}
|
||||
|
||||
if (
|
||||
isset($entry->mobilitaetstyp_kurzbz) && $entry->mobilitaetstyp_kurzbz &&
|
||||
isset($entry->doubledegree) && $entry->doubledegree === 1
|
||||
) {
|
||||
$zusatz .= ' (d.d.';
|
||||
|
||||
$ddtype = $entry->ddtype ?? null;
|
||||
|
||||
if ($ddtype == 'Intern') {
|
||||
$zusatz .= 'int.)';
|
||||
} elseif ($ddtype == 'Extern') {
|
||||
$zusatz .= 'ext.)';
|
||||
} else {
|
||||
$zusatz .= ')';
|
||||
}
|
||||
}
|
||||
|
||||
return $zusatz;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,4 +52,47 @@ class LePruefung_model extends DB_Model
|
||||
'student_uid' => $student_uid
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* All Pruefungen of one LV in one Studiensemester.
|
||||
*
|
||||
* Note and Pruefungstyp are LEFT JOINs: a Pruefung with a type that tbl_pruefungstyp does not know
|
||||
* stays in the Verlauf. Else the rules count one Antritt too few.
|
||||
*
|
||||
* No ORDER BY: PruefungsverlaufLib sorts the rows into the order of the Verlauf.
|
||||
*/
|
||||
public function getPruefungenByLvStudiensemester($lv_id, $sem_kurzbz) {
|
||||
$qry = "SELECT tbl_pruefung.*, tbl_lehrveranstaltung.bezeichnung as lehrveranstaltung_bezeichnung, tbl_lehrveranstaltung.lehrveranstaltung_id,
|
||||
tbl_note.bezeichnung as note_bezeichnung, tbl_pruefungstyp.beschreibung as typ_beschreibung, tbl_lehreinheit.studiensemester_kurzbz as studiensemester_kurzbz
|
||||
FROM lehre.tbl_pruefung
|
||||
JOIN lehre.tbl_lehreinheit ON (tbl_pruefung.lehreinheit_id = tbl_lehreinheit.lehreinheit_id)
|
||||
JOIN lehre.tbl_lehrveranstaltung ON (tbl_lehreinheit.lehrveranstaltung_id = tbl_lehrveranstaltung.lehrveranstaltung_id)
|
||||
LEFT JOIN lehre.tbl_note ON (tbl_pruefung.note = tbl_note.note)
|
||||
LEFT JOIN lehre.tbl_pruefungstyp ON (tbl_pruefung.pruefungstyp_kurzbz = tbl_pruefungstyp.pruefungstyp_kurzbz)
|
||||
WHERE tbl_lehrveranstaltung.lehrveranstaltung_id = ?
|
||||
AND tbl_lehreinheit.studiensemester_kurzbz = ?";
|
||||
|
||||
return $this->execReadOnlyQuery($qry, array($lv_id, $sem_kurzbz));
|
||||
}
|
||||
|
||||
/**
|
||||
* All Pruefungen of one student in one LV.
|
||||
*
|
||||
* Note and Pruefungstyp are LEFT JOINs. PruefungsverlaufLib derives the Antritt numbers from this
|
||||
* list, and a missing row shifts the whole chain.
|
||||
*/
|
||||
public function getPruefungenByStudent($student_uid, $lv_id, $sem_kurzbz) {
|
||||
$qry = "SELECT tbl_pruefung.*, tbl_lehrveranstaltung.bezeichnung as lehrveranstaltung_bezeichnung, tbl_lehrveranstaltung.lehrveranstaltung_id,
|
||||
tbl_note.bezeichnung as note_bezeichnung, tbl_pruefungstyp.beschreibung as typ_beschreibung, tbl_lehreinheit.studiensemester_kurzbz as studiensemester_kurzbz
|
||||
FROM lehre.tbl_pruefung
|
||||
JOIN lehre.tbl_lehreinheit ON (tbl_pruefung.lehreinheit_id = tbl_lehreinheit.lehreinheit_id)
|
||||
JOIN lehre.tbl_lehrveranstaltung ON (tbl_lehreinheit.lehrveranstaltung_id = tbl_lehrveranstaltung.lehrveranstaltung_id)
|
||||
LEFT JOIN lehre.tbl_note ON (tbl_pruefung.note = tbl_note.note)
|
||||
LEFT JOIN lehre.tbl_pruefungstyp ON (tbl_pruefung.pruefungstyp_kurzbz = tbl_pruefungstyp.pruefungstyp_kurzbz)
|
||||
WHERE tbl_pruefung.student_uid = ?
|
||||
AND tbl_lehrveranstaltung.lehrveranstaltung_id = ?
|
||||
AND tbl_lehreinheit.studiensemester_kurzbz = ?";
|
||||
|
||||
return $this->execReadOnlyQuery($qry, array($student_uid, $lv_id, $sem_kurzbz));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -741,4 +741,28 @@ EOSQL;
|
||||
)";
|
||||
}
|
||||
|
||||
|
||||
public function getLehreinheitenForLv($lv_id, $sem_kurzbz)
|
||||
{
|
||||
// every Lehreinheit of the LV; the caller checks the access
|
||||
$query = "SELECT DISTINCT tbl_lehreinheit.lehreinheit_id, tbl_lehreinheit.lehrveranstaltung_id, tbl_lehreinheit.lehrform_kurzbz,
|
||||
tbl_gruppe.direktinskription,
|
||||
tbl_lehreinheitgruppe.semester,
|
||||
tbl_lehreinheitgruppe.verband,
|
||||
tbl_lehreinheitgruppe.gruppe,
|
||||
tbl_lehreinheitgruppe.gruppe_kurzbz,
|
||||
tbl_lehrveranstaltung.kurzbz,
|
||||
tbl_studiengang.kurzbzlang,
|
||||
(SELECT COUNT(DISTINCT datum) FROM campus.vw_stundenplan WHERE lehreinheit_id = lehre.tbl_lehreinheit.lehreinheit_id) as termincount,
|
||||
(SELECT COUNT(*) FROM campus.vw_student_lehrveranstaltung WHERE lehreinheit_id = lehre.tbl_lehreinheit.lehreinheit_id) as studentcount
|
||||
FROM lehre.tbl_lehreinheit
|
||||
JOIN lehre.tbl_lehreinheitgruppe USING(lehreinheit_id)
|
||||
JOIN lehre.tbl_lehrveranstaltung USING(lehrveranstaltung_id)
|
||||
JOIN public.tbl_studiengang ON (tbl_lehreinheitgruppe.studiengang_kz = tbl_studiengang.studiengang_kz)
|
||||
LEFT JOIN public.tbl_gruppe ON (tbl_gruppe.gruppe_kurzbz = tbl_lehreinheitgruppe.gruppe_kurzbz)
|
||||
WHERE lehrveranstaltung_id = ? AND tbl_lehreinheit.studiensemester_kurzbz = ?
|
||||
ORDER BY tbl_lehreinheitgruppe.gruppe_kurzbz";
|
||||
|
||||
return $this->execReadOnlyQuery($query, [$lv_id, $sem_kurzbz]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,7 +317,7 @@ class Lehrveranstaltung_model extends DB_Model
|
||||
tbl_bisio.bisio_id, tbl_bisio.von, tbl_bisio.bis, tbl_student.studiengang_kz AS stg_kz_student,
|
||||
tbl_zeugnisnote.note, tbl_mitarbeiter.mitarbeiter_uid, tbl_person.matr_nr, tbl_benutzer.uid,
|
||||
UPPER(tbl_studiengang.typ::varchar(1) || tbl_studiengang.kurzbz) as kuerzel, tbl_studiengang.orgform_kurzbz, vw_student_lehrveranstaltung.semester, vw_student_lehrveranstaltung.studiensemester_kurzbz, vw_student_lehrveranstaltung.bezeichnung,
|
||||
tbl_student.prestudent_id
|
||||
tbl_student.prestudent_id, campus.vw_student_lehrveranstaltung.lehreinheit_id
|
||||
FROM
|
||||
campus.vw_student_lehrveranstaltung
|
||||
JOIN public.tbl_benutzer USING(uid)
|
||||
@@ -685,10 +685,22 @@ class Lehrveranstaltung_model extends DB_Model
|
||||
]);
|
||||
|
||||
$this->dbTable = $tmp;
|
||||
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* All Lehreinheiten of a student in a Lehrveranstaltung, lowest id first.
|
||||
*/
|
||||
public function getLeIdsByStudent($student_uid, $studiensemester_kurzbz, $lehrveranstaltung_id)
|
||||
{
|
||||
$qry = "SELECT DISTINCT lehreinheit_id FROM campus.vw_student_lehrveranstaltung
|
||||
WHERE uid = ? AND lehrveranstaltung_id = ? AND studiensemester_kurzbz = ?
|
||||
ORDER BY lehreinheit_id";
|
||||
|
||||
return $this->execReadOnlyQuery($qry, array($student_uid, $lehrveranstaltung_id, $studiensemester_kurzbz));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sucht nach LV Templates und gibt Id und Label ("bezeichnung [kurzbz]") aus
|
||||
* Diese funktion ist für autocomplete gedacht
|
||||
@@ -1379,4 +1391,73 @@ class Lehrveranstaltung_model extends DB_Model
|
||||
|
||||
return $this->execQuery($qry, $params);
|
||||
}
|
||||
|
||||
public function getLvForLektorInSemester($sem_kurzbz, $uid) {
|
||||
$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,
|
||||
(SELECT kurzbz FROM public.tbl_mitarbeiter
|
||||
WHERE mitarbeiter_uid=tbl_lehreinheitmitarbeiter.mitarbeiter_uid) as lektor,
|
||||
lehre.tbl_lehrveranstaltung.orgform_kurzbz as orgform
|
||||
|
||||
FROM
|
||||
lehre.tbl_lehreinheit JOIN lehre.tbl_lehreinheitmitarbeiter USING(lehreinheit_id)
|
||||
JOIN lehre.tbl_lehrveranstaltung USING(lehrveranstaltung_id)
|
||||
JOIN public.tbl_studiengang USING(studiengang_kz)
|
||||
JOIN lehre.tbl_lehrveranstaltung as lehrfach ON(tbl_lehreinheit.lehrfach_id=lehrfach.lehrveranstaltung_id)
|
||||
WHERE
|
||||
tbl_lehreinheit.studiensemester_kurzbz = ?
|
||||
AND mitarbeiter_uid = ?
|
||||
ORDER BY stg_kurzbz,lv_semester,lv_bezeichnung";
|
||||
|
||||
return $this->execReadOnlyQuery($qry, array($sem_kurzbz, $uid));
|
||||
}
|
||||
|
||||
/**
|
||||
* All Lehrveranstaltungen of a Studiengang offered in a Studiensemester.
|
||||
*/
|
||||
public function getLvForStudiengangInSemester($sem_kurzbz, $studiengang_kz) {
|
||||
$qry = "SELECT DISTINCT (tbl_lehrveranstaltung.lehrveranstaltung_id),
|
||||
UPPER(tbl_studiengang.typ::varchar(1) || tbl_studiengang.kurzbz) as stg_kurzbz,
|
||||
tbl_studiengang.studiengang_kz,
|
||||
tbl_lehrveranstaltung.semester as lv_semester,
|
||||
tbl_lehrveranstaltung.bezeichnung as lv_bezeichnung,
|
||||
tbl_lehrveranstaltung.orgform_kurzbz as orgform
|
||||
FROM
|
||||
lehre.tbl_lehrveranstaltung JOIN public.tbl_studiengang USING(studiengang_kz)
|
||||
WHERE
|
||||
tbl_lehrveranstaltung.studiengang_kz = ?
|
||||
AND tbl_lehrveranstaltung.aktiv = TRUE
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM lehre.tbl_lehreinheit le
|
||||
WHERE le.lehrveranstaltung_id = tbl_lehrveranstaltung.lehrveranstaltung_id
|
||||
AND le.studiensemester_kurzbz = ?
|
||||
)
|
||||
ORDER BY stg_kurzbz,lv_semester,lv_bezeichnung";
|
||||
|
||||
return $this->execReadOnlyQuery($qry, array($studiengang_kz, $sem_kurzbz));
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of Lehreinheit assignments the given mitarbeiter has for a Lehrveranstaltung (i.e. whether
|
||||
* they teach it), optionally restricted to a Studiensemester. Returns a single row with a `teaches`
|
||||
* count - check `teaches > 0`. Used to scope teacher access in the Benotungstool to their own LVs.
|
||||
*/
|
||||
public function getLektorIsTeachingLva($lva_id, $ma_uid, $studiensemester_kurzbz = null)
|
||||
{
|
||||
$params = array($lva_id, $ma_uid);
|
||||
$query = "SELECT COUNT(*) AS teaches
|
||||
FROM lehre.tbl_lehreinheitmitarbeiter
|
||||
JOIN lehre.tbl_lehreinheit USING (lehreinheit_id)
|
||||
JOIN lehre.tbl_lehrveranstaltung USING (lehrveranstaltung_id)
|
||||
WHERE lehrveranstaltung_id = ? AND mitarbeiter_uid = ?";
|
||||
if ($studiensemester_kurzbz !== null && $studiensemester_kurzbz !== '') {
|
||||
$query .= " AND tbl_lehreinheit.studiensemester_kurzbz = ?";
|
||||
$params[] = $studiensemester_kurzbz;
|
||||
}
|
||||
|
||||
return $this->execReadOnlyQuery($query, $params);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,4 +50,27 @@ class Lvgesamtnote_model extends DB_Model
|
||||
|
||||
return $this->loadWhere($where);
|
||||
}
|
||||
|
||||
/**
|
||||
* The LV-Note of one student, without the Freigabe filter of getLvGesamtNoten().
|
||||
*/
|
||||
public function getByStudent($lehrveranstaltung_id, $student_uid, $studiensemester_kurzbz)
|
||||
{
|
||||
return $this->loadWhere(array(
|
||||
'student_uid' => $student_uid,
|
||||
'lehrveranstaltung_id' => $lehrveranstaltung_id,
|
||||
'studiensemester_kurzbz' => $studiensemester_kurzbz
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* All LV-Noten of one LV in one Studiensemester, without the Freigabe filter.
|
||||
*/
|
||||
public function getByLvStudiensemester($lehrveranstaltung_id, $studiensemester_kurzbz)
|
||||
{
|
||||
return $this->loadWhere(array(
|
||||
'lehrveranstaltung_id' => $lehrveranstaltung_id,
|
||||
'studiensemester_kurzbz' => $studiensemester_kurzbz
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,12 +11,31 @@ class Note_model extends DB_Model
|
||||
$this->dbTable = 'lehre.tbl_note';
|
||||
$this->pk = 'note';
|
||||
}
|
||||
|
||||
public function getAllActive() {
|
||||
|
||||
/**
|
||||
* Sorted like the Notenliste of the StV: the scale 1-5 by its value first, every other Note
|
||||
* alphabetically after it.
|
||||
*
|
||||
* @param string|null $sortOrder 'skala' or 'bezeichnung'. The caller holds the configuration.
|
||||
* Without a value the model reads NOTEN_SORTIERUNG itself, for the
|
||||
* callers outside the Benotungstool.
|
||||
*/
|
||||
public function getAllActive($sortOrder = null) {
|
||||
$qry ="SELECT *
|
||||
FROM lehre.tbl_note
|
||||
WHERE aktiv = true";
|
||||
|
||||
if($sortOrder === null) {
|
||||
$this->config->load('noten', FALSE, TRUE);
|
||||
$sortOrder = $this->config->item('NOTEN_SORTIERUNG');
|
||||
}
|
||||
|
||||
$qry .= ($sortOrder === 'bezeichnung')
|
||||
? " ORDER BY bezeichnung"
|
||||
: " ORDER BY CASE WHEN note BETWEEN 1 AND 5 THEN 0 ELSE 1 END,
|
||||
CASE WHEN note BETWEEN 1 AND 5 THEN note END,
|
||||
bezeichnung";
|
||||
|
||||
return $this->execReadOnlyQuery($qry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ class Notenschluesselaufteilung_model extends DB_Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert die Note zu Punkten einer Lehrveranstaltung
|
||||
* Gives the Note for the Punkte of an LV.
|
||||
*
|
||||
* @param number $points
|
||||
* @param integer $lehrveranstaltung_id
|
||||
@@ -23,9 +23,18 @@ class Notenschluesselaufteilung_model extends DB_Model
|
||||
*/
|
||||
public function getNote($points, $lehrveranstaltung_id, $studiensemester_kurzbz)
|
||||
{
|
||||
// Without valid Punkte no Note follows. Without this guard the query builder makes a bad
|
||||
// statement from "punkte <=" => null ('punkte' < 'IS' 'NULL'), and the request fails with
|
||||
// HTTP 500 instead of an answer that says "no Note".
|
||||
if (!is_numeric($points))
|
||||
return success(null);
|
||||
|
||||
$this->load->model('education/Notenschluesselzuordnung_model', 'NotenschluesselzuordnungModel');
|
||||
$notenschluessel_kurzbz = $this->NotenschluesselzuordnungModel->getKurzbzForLv($lehrveranstaltung_id, $studiensemester_kurzbz);
|
||||
|
||||
if($notenschluessel_kurzbz == null)
|
||||
return success(null);
|
||||
|
||||
$this->addSelect("note");
|
||||
$this->addOrder("punkte", "DESC");
|
||||
$this->addLimit(1);
|
||||
|
||||
@@ -306,4 +306,5 @@ class Pruefung_model extends DB_Model
|
||||
|
||||
return $this->loadWhereCommitteeExamsFailed();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -59,6 +59,37 @@ class Studienplan_model extends DB_Model
|
||||
'tbl_studienplan_lehrveranstaltung.semester' => $semester
|
||||
));
|
||||
}
|
||||
|
||||
public function getStudienplanByLvaSemKurzbz($lehrveranstaltung_id, $studiensemester_kurzbz) {
|
||||
$qry= "
|
||||
SELECT
|
||||
DISTINCT tbl_studienplan.*
|
||||
FROM
|
||||
lehre.tbl_studienplan
|
||||
JOIN lehre.tbl_studienplan_lehrveranstaltung
|
||||
USING(studienplan_id)
|
||||
WHERE
|
||||
tbl_studienplan_lehrveranstaltung.lehrveranstaltung_id IN (
|
||||
SELECT
|
||||
lv.lehrveranstaltung_id
|
||||
FROM
|
||||
lehre.tbl_lehrveranstaltung AS lv
|
||||
LEFT JOIN lehre.tbl_lehrveranstaltung AS t ON t.lehrveranstaltung_id=lv.lehrveranstaltung_template_id
|
||||
WHERE
|
||||
lv.lehrtyp_kurzbz<>'tpl'
|
||||
AND (lv.lehrveranstaltung_id= ? OR (lv.lehrveranstaltung_template_id= ? AND t.lehrtyp_kurzbz='tpl'))
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM
|
||||
lehre.tbl_studienplan_semester
|
||||
WHERE studienplan_id=tbl_studienplan.studienplan_id
|
||||
AND studiensemester_kurzbz= ?
|
||||
AND semester = tbl_studienplan_lehrveranstaltung.semester)
|
||||
ORDER BY bezeichnung";
|
||||
|
||||
return $this->execReadOnlyQuery($qry, array($lehrveranstaltung_id, $lehrveranstaltung_id, $studiensemester_kurzbz));
|
||||
}
|
||||
|
||||
public function getStudienplanLehrveranstaltungForPrestudent($studienplan_id, $semester, $prestudent_id)
|
||||
{
|
||||
|
||||
@@ -23,7 +23,8 @@ $includesArray = array(
|
||||
'public/css/components/FormUnderline.css',
|
||||
'public/css/components/abgabetool/abgabe.css',
|
||||
'public/css/Cis4/Cms.css',
|
||||
'public/css/Cis4/Studium.css'
|
||||
'public/css/Cis4/Studium.css',
|
||||
'public/css/Cis4/Benotungstool.css'
|
||||
),
|
||||
'customJSs' => array(
|
||||
'vendor/npm-asset/primevue/accordion/accordion.min.js',
|
||||
@@ -37,11 +38,15 @@ $includesArray = array(
|
||||
'vendor/npm-asset/primevue/timeline/timeline.min.js',
|
||||
'vendor/npm-asset/primevue/inplace/inplace.min.js',
|
||||
'vendor/npm-asset/primevue/message/message.min.js',
|
||||
'vendor/npm-asset/primevue/divider/divider.min.js',
|
||||
'vendor/npm-asset/primevue/password/password.js',
|
||||
'vendor/npm-asset/primevue/multiselect/multiselect.js',
|
||||
'vendor/npm-asset/primevue/tieredmenu/tieredmenu.js',
|
||||
'vendor/moment/luxonjs/luxon.min.js'
|
||||
),
|
||||
'customJSModules' => array(
|
||||
'public/js/apps/Cis/Cis.js',
|
||||
'vendor/olifolkerd/tabulator5/src/js/modules/ColumnCalcs/ColumnCalcs.js'
|
||||
),
|
||||
|
||||
);
|
||||
|
||||
@@ -1824,7 +1824,7 @@ $htmlstring .= "
|
||||
|
||||
</table>
|
||||
";
|
||||
if ($anzahlChanged > 0)
|
||||
if (isset($anzahlChanged) && $anzahlChanged > 0)
|
||||
echo '<div id="notenWarning" class="warning">' . $p->t('benotungstool/geaenderteNotenVorhanden') . '</div>';
|
||||
|
||||
echo $htmlstring;
|
||||
|
||||
@@ -159,6 +159,41 @@ if (!$berechtigung->isBerechtigt('admin')
|
||||
// let's merge
|
||||
$format_title->setAlign('merge');
|
||||
|
||||
// Importkonfiguration aus dem CI-Config (application/config/noten.php) lesen, damit nur die
|
||||
// Spalten des jeweils aktiven Importtyps hervorgehoben (grau) werden.
|
||||
$CIS_GESAMTNOTE_NOTENIMPORT = false;
|
||||
$CIS_GESAMTNOTE_PRUEFUNGSIMPORT = true;
|
||||
$notenConfigFile = __DIR__.'/../../../application/config/noten.php';
|
||||
if(is_file($notenConfigFile))
|
||||
{
|
||||
if(!defined('BASEPATH')) define('BASEPATH', true);
|
||||
$config = array();
|
||||
include($notenConfigFile);
|
||||
if(isset($config['CIS_GESAMTNOTE_NOTENIMPORT'])) $CIS_GESAMTNOTE_NOTENIMPORT = $config['CIS_GESAMTNOTE_NOTENIMPORT'];
|
||||
if(isset($config['CIS_GESAMTNOTE_PRUEFUNGSIMPORT'])) $CIS_GESAMTNOTE_PRUEFUNGSIMPORT = $config['CIS_GESAMTNOTE_PRUEFUNGSIMPORT'];
|
||||
unset($config);
|
||||
}
|
||||
|
||||
// plain (non-highlighted) variants used when an import type is disabled
|
||||
$format_plain =& $workbook->addFormat();
|
||||
$format_plain->setAlign('left');
|
||||
$format_plain->setNumFormat(49);
|
||||
|
||||
$format_plainright =& $workbook->addFormat();
|
||||
$format_plainright->setAlign('right');
|
||||
$format_plainright->setNumFormat(49);
|
||||
|
||||
// highlight only the cells belonging to an enabled import flow:
|
||||
// - Notenimport -> Personenkennzeichen + Note (main block)
|
||||
// - Prüfungsimport -> Personenkennzeichen + Datum + Note (Termin2/Termin3 blocks)
|
||||
$fmtNoteImportPkz = $CIS_GESAMTNOTE_NOTENIMPORT ? $format_highlight : $format_plain;
|
||||
$fmtNoteImportNote = $CIS_GESAMTNOTE_NOTENIMPORT ? $format_highlightright : $format_plainright;
|
||||
$fmtPruefImportPkz = $CIS_GESAMTNOTE_PRUEFUNGSIMPORT ? $format_highlight : $format_plain;
|
||||
$fmtPruefImportDate = $CIS_GESAMTNOTE_PRUEFUNGSIMPORT ? $format_highlightright_date : $format_plainright;
|
||||
$fmtPruefImportNote = $CIS_GESAMTNOTE_PRUEFUNGSIMPORT ? $format_highlightright : $format_plainright;
|
||||
|
||||
$lvobj = new lehrveranstaltung($lvid);
|
||||
|
||||
$worksheet->write(0,0,$p->t('anwesenheitsliste/notenliste')." ".($sprache=='English'?$lvobj->bezeichnung_english:$lvobj->bezeichnung),$format_bold);
|
||||
|
||||
$stg_obj = new studiengang($stg);
|
||||
@@ -351,7 +386,7 @@ if (!$berechtigung->isBerechtigt('admin')
|
||||
{
|
||||
$worksheet->write($lines,3,$elem->vorname);
|
||||
}
|
||||
$worksheet->writeString($lines,4,$elem->semester.$elem->verband.$elem->gruppe);
|
||||
$worksheet->write($lines,4,$elem->semester.$elem->verband.$elem->gruppe);
|
||||
$worksheet->write($lines,5,trim($elem->matrikelnr),$format_highlight);
|
||||
$worksheet->write($lines,6, $note, $format_highlightright);
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
const { defineConfig } = require("cypress");
|
||||
|
||||
require("dotenv").config({ path: "tests/cypress/.env" });
|
||||
|
||||
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(", ")}`);
|
||||
|
||||
// each suite registers its own tasks and Cypress.env() values
|
||||
return require("./tests/cypress/suites/noten").setupNodeEvents(on, config);
|
||||
},
|
||||
},
|
||||
|
||||
env: {
|
||||
adminusername: process.env.USER_NAME,
|
||||
adminpassword: process.env.USER_PASSWORD,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"scripts": {
|
||||
"noten:check": "node tests/cypress/tools/dbCheck.js noten",
|
||||
"noten:api": "cypress run --spec \"tests/cypress/e2e/specs/api/**/*.cy.js\"",
|
||||
"noten:ui": "cypress run --browser chrome --spec \"tests/cypress/e2e/specs/ui/**/*.cy.js\""
|
||||
},
|
||||
"//": [
|
||||
"pg: the suite resets and seeds the test database directly (tasks/db.js, tools/dbCheck.js)",
|
||||
"dotenv: cypress.config.js and the suite read URL, accounts and DB access from the .env files"
|
||||
],
|
||||
"dependencies": {
|
||||
"cypress": "^15.21.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"pg": "^8.13.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/* Pinned columns (tableLayout.js). The four columns that can be pinned always have .sticky-col.
|
||||
A `sticky-on-<field>` class on #notentable pins one of them, and --sl-<field> is its left offset,
|
||||
so that the pinned columns stand next to each other. */
|
||||
#notentable .tabulator-header .tabulator-col.sticky-col,
|
||||
#notentable .tabulator-tableholder .tabulator-row .tabulator-cell.sticky-col {
|
||||
background-color: #fff; /* the cells below do not show through */
|
||||
}
|
||||
|
||||
/* also on row hover */
|
||||
#notentable .tabulator-row:hover .tabulator-cell.sticky-col {
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
/* the container pins a column */
|
||||
#notentable.sticky-on-selectCol .sticky-col[tabulator-field="selectCol"],
|
||||
#notentable.sticky-on-uid .sticky-col[tabulator-field="uid"],
|
||||
#notentable.sticky-on-vorname .sticky-col[tabulator-field="vorname"],
|
||||
#notentable.sticky-on-nachname .sticky-col[tabulator-field="nachname"] {
|
||||
position: sticky;
|
||||
z-index: 10; /* above the Pruefung columns */
|
||||
border-right: 1px solid #ddd;
|
||||
}
|
||||
|
||||
#notentable.sticky-on-selectCol .sticky-col[tabulator-field="selectCol"] { left: var(--sl-selectCol, 0); }
|
||||
#notentable.sticky-on-uid .sticky-col[tabulator-field="uid"] { left: var(--sl-uid, 0); }
|
||||
#notentable.sticky-on-vorname .sticky-col[tabulator-field="vorname"] { left: var(--sl-vorname, 0); }
|
||||
#notentable.sticky-on-nachname .sticky-col[tabulator-field="nachname"] { left: var(--sl-nachname, 0); }
|
||||
|
||||
|
||||
/* the content of a cell, from the formatters in Benotungstool.js */
|
||||
#notentable .cell-start,
|
||||
#notentable .cell-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#notentable .cell-start { justify-content: start; }
|
||||
#notentable .cell-center { justify-content: center; }
|
||||
|
||||
/* a Zeugnisnote that differs from the LV-Note */
|
||||
#notentable .zeugnisnote-differs {
|
||||
color: red;
|
||||
border: 1px solid red;
|
||||
}
|
||||
|
||||
/* a proposal that the user cannot change */
|
||||
#notentable .proposal-locked {
|
||||
color: gray;
|
||||
font-style: italic;
|
||||
background-color: #f0f0f0;
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
user-select: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
#notentable .teilnote-negativ { color: red; }
|
||||
#notentable .freigegeben-icon { color: green; }
|
||||
#notentable .mail-icon { color: #00649C; }
|
||||
|
||||
|
||||
/* the editable proposal columns */
|
||||
#notentable .tabulator-tableholder .tabulator-editable[tabulator-field="proposed_punkte"] {
|
||||
position: relative;
|
||||
background-color: rgba(255, 255, 157, 0.73);
|
||||
}
|
||||
|
||||
#notentable .tabulator-tableholder .tabulator-editable[tabulator-field="proposed_note"] {
|
||||
position: relative;
|
||||
background-color: rgba(255, 255, 157, 0.73);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#notentable .tabulator-tableholder .tabulator-editable[tabulator-field="proposed_note"]::after {
|
||||
content: "▾";
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%) rotate(0deg);
|
||||
transform-origin: center;
|
||||
color: rgba(120, 120, 60, 0.85);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
/* the arrow points up while the list is open */
|
||||
#notentable .tabulator-tableholder .tabulator-cell.tabulator-editing[tabulator-field="proposed_note"]::after {
|
||||
transform: translateY(-50%) rotate(180deg);
|
||||
}
|
||||
|
||||
/* A Pruefung cell: fixed tracks for Note | date | action, so that they align in all rows. */
|
||||
.pruefung-cell {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 6.5rem; /* Note | action */
|
||||
align-items: center;
|
||||
column-gap: 0.5rem;
|
||||
height: 100%;
|
||||
padding-left: 44px;
|
||||
padding-right: 6px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.pruefung-cell.with-datum {
|
||||
grid-template-columns: minmax(0, 1fr) 4.75rem 6.5rem; /* Note | datum | action */
|
||||
}
|
||||
|
||||
.pruefung-cell.without-action {
|
||||
grid-template-columns: minmax(0, 1fr); /* Note */
|
||||
}
|
||||
|
||||
.pruefung-cell.without-action.with-datum {
|
||||
grid-template-columns: minmax(0, 1fr) 4.75rem; /* Note | datum */
|
||||
}
|
||||
|
||||
.pruefung-cell .pruefung-note {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* the reason instead of a button, for example after a pass */
|
||||
.pruefung-cell .pruefung-hint {
|
||||
grid-column: 1 / -1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.8rem;
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.pruefung-cell .pruefung-datum {
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* always the last track, also in an empty cell with only the add button */
|
||||
.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 {
|
||||
content: attr(data-antritt);
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 5px;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pruefung-badge.antritt-1::before { background: #0072B2; }
|
||||
.pruefung-badge.antritt-2::before { background: #E69F00; }
|
||||
.pruefung-badge.antritt-3::before { background: #CC79A7; }
|
||||
.pruefung-badge.antritt-k::before { background: #D55E00; }
|
||||
.pruefung-badge.antritt-none::before { background: #767676; }
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Copyright (C) 2025 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export default {
|
||||
getCisConfig() {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/Noten/getCisConfig'
|
||||
};
|
||||
},
|
||||
getNoten() {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/Noten/getNoten'
|
||||
};
|
||||
},
|
||||
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 }
|
||||
};
|
||||
},
|
||||
getLehreinheitenForLv(lv_id, sem_kurzbz) {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/Noten/getLehreinheitenForLv',
|
||||
params: { lv_id, sem_kurzbz }
|
||||
};
|
||||
},
|
||||
getLektorenForLehreinheit(lv_id, sem_kurzbz, lehreinheit_id) {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/Noten/getLektorenForLehreinheit',
|
||||
params: { lv_id, sem_kurzbz, lehreinheit_id }
|
||||
};
|
||||
},
|
||||
// -> { students, domain }
|
||||
getStudentenNoten(lv_id, sem_kurzbz) {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/Noten/getStudentenNoten',
|
||||
params: { lv_id, sem_kurzbz }
|
||||
};
|
||||
},
|
||||
getNoteByPunkte(lv_id, sem_kurzbz, punkte) {
|
||||
return {
|
||||
method: 'post',
|
||||
url: '/api/frontend/v1/Noten/getNoteByPunkte',
|
||||
params: { lv_id, sem_kurzbz, punkte }
|
||||
};
|
||||
},
|
||||
|
||||
// Each write answers { <uid>: { lvgesamtnote, verlauf, pruefung } }. A rejected row of a bulk
|
||||
// write is { <uid>: { error: { code, message } } }.
|
||||
|
||||
// datum: the day of Antritt 1 (YYYY-MM-DD); without it the server takes today
|
||||
saveLvNote(lv_id, sem_kurzbz, student_uid, note, punkte, datum) {
|
||||
return {
|
||||
method: 'post',
|
||||
url: '/api/frontend/v1/Noten/saveLvNote',
|
||||
params: { lv_id, sem_kurzbz, student_uid, note, punkte, datum }
|
||||
};
|
||||
},
|
||||
// lv_noten: [{ uid, note, punkte }]
|
||||
importLvNoten(lv_id, sem_kurzbz, lv_noten) {
|
||||
return {
|
||||
method: 'post',
|
||||
url: '/api/frontend/v1/Noten/importLvNoten',
|
||||
params: { lv_id, sem_kurzbz, lv_noten }
|
||||
};
|
||||
},
|
||||
// pruefung: { pruefung_id (null = a new Pruefung), lehreinheit_id, datum, note, punkte, mitarbeiter_uid }
|
||||
savePruefung(lv_id, sem_kurzbz, student_uid, pruefung) {
|
||||
return {
|
||||
method: 'post',
|
||||
url: '/api/frontend/v1/Noten/savePruefung',
|
||||
params: { lv_id, sem_kurzbz, student_uid, ...pruefung }
|
||||
};
|
||||
},
|
||||
// students: [{ uid, lehreinheit_id }]; pruefung: { datum, note, punkte, mitarbeiter_uid }
|
||||
createPruefungen(lv_id, sem_kurzbz, students, pruefung) {
|
||||
return {
|
||||
method: 'post',
|
||||
url: '/api/frontend/v1/Noten/createPruefungen',
|
||||
params: { lv_id, sem_kurzbz, students, ...pruefung }
|
||||
};
|
||||
},
|
||||
// pruefungen: [{ uid, lehreinheit_id, datum, note, punkte }]
|
||||
importPruefungen(lv_id, sem_kurzbz, pruefungen) {
|
||||
return {
|
||||
method: 'post',
|
||||
url: '/api/frontend/v1/Noten/importPruefungen',
|
||||
params: { lv_id, sem_kurzbz, pruefungen }
|
||||
};
|
||||
},
|
||||
saveFreigabe(lv_id, sem_kurzbz, password, uids) {
|
||||
return {
|
||||
method: 'post',
|
||||
url: '/api/frontend/v1/Noten/saveFreigabe',
|
||||
params: { lv_id, sem_kurzbz, password, uids }
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -17,6 +17,7 @@ import AbgabetoolMitarbeiter from "../../components/Cis/Abgabetool/AbgabetoolMit
|
||||
import AbgabetoolAssistenz from "../../components/Cis/Abgabetool/AbgabetoolAssistenz.js";
|
||||
import DeadlineOverview from "../../components/Cis/Abgabetool/DeadlineOverview.js";
|
||||
import Studium from "../../components/Cis/Studium/Studium.js";
|
||||
import Benotungstool from "../../components/Cis/Benotungstool/Benotungstool.js";
|
||||
import StgOrgLvPlan from "../../components/Cis/LvPlan/StgOrg.js";
|
||||
import OtherLvPlan from "../../components/Cis/LvPlan/OtherLvPlan.js";
|
||||
|
||||
@@ -77,6 +78,13 @@ const router = VueRouter.createRouter({
|
||||
component: Raumsuche,
|
||||
props: true
|
||||
},
|
||||
{
|
||||
// semester first: a URL without a course must not read the semester as lv_id
|
||||
path: `/Cis/Benotungstool/:sem_kurzbz?/:lv_id?`,
|
||||
name: 'Benotungstool',
|
||||
component: Benotungstool,
|
||||
props: true
|
||||
},
|
||||
// Redirect old links to new format
|
||||
{
|
||||
path: "/CisVue/Cms/getRoomInformation/:ort_kurzbz",
|
||||
@@ -152,7 +160,7 @@ const router = VueRouter.createRouter({
|
||||
path: `/Cis/MyLv/:studiensemester?`,
|
||||
name: 'MyLv',
|
||||
component: MylvStudent,
|
||||
props: true,
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: `/Cis/MyLv/Info/:studien_semester/:lehrveranstaltung_id`,
|
||||
|
||||
@@ -148,4 +148,4 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,100 @@
|
||||
import BsModal from '../../Bootstrap/Modal.js';
|
||||
|
||||
/**
|
||||
* The Freigabe dialog: the changed LV-Noten and the password. It emits the password; the parent
|
||||
* writes the Freigabe.
|
||||
*
|
||||
* open() -> 'save-freigabe' (password)
|
||||
*/
|
||||
export default {
|
||||
name: "BenotungstoolFreigabeDialog",
|
||||
components: {
|
||||
BsModal,
|
||||
Password: primevue.password
|
||||
},
|
||||
props: {
|
||||
// the rows with an LV-Note that changed since the last Freigabe
|
||||
lvNoten: { type: Array, default: () => [] },
|
||||
notenOptions: { type: Array, default: () => [] }
|
||||
},
|
||||
emits: ['save-freigabe'],
|
||||
data() {
|
||||
return {
|
||||
visible: false,
|
||||
password: ''
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
/** The Freigabe writes the LV-Note, not the Zeugnisnote. So the dialog shows both. */
|
||||
summary() {
|
||||
return this.lvNoten.map(s => ({
|
||||
uid: s.uid,
|
||||
name: `${s.vorname} ${s.nachname}`,
|
||||
zeugnisnote: this.bezeichnungOf(s.zeugnisnote) ?? s.zeugnisnote ?? '—',
|
||||
lvNote: this.bezeichnungOf(s.lv_note) ?? s.lv_note ?? '—',
|
||||
changed: (s.zeugnisnote ?? '') != (s.lv_note ?? '')
|
||||
}));
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
open() {
|
||||
this.$refs.modal.show();
|
||||
},
|
||||
|
||||
submit() {
|
||||
const password = this.password;
|
||||
|
||||
// the password leaves the memory with the dialog
|
||||
this.password = '';
|
||||
this.$refs.modal.hide();
|
||||
this.$emit('save-freigabe', password);
|
||||
},
|
||||
|
||||
bezeichnungOf(note) {
|
||||
return this.notenOptions.find(n => n.note == note)?.bezeichnung;
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<bs-modal data-cy="modal-freigabe" ref="modal" class="bootstrap-prompt" dialogClass="modal-lg" bodyClass="px-4 py-4"
|
||||
@hideBsModal="visible = false"
|
||||
@showBsModal="visible = true">
|
||||
<template v-slot:title>{{ $p.t('benotungstool/noteneingabeSpeichern') }}</template>
|
||||
<template v-slot:default>
|
||||
<div v-html="$capitalize($p.t('benotungstool/notenfreigabeHinweistextv4'))"></div>
|
||||
<div v-if="summary.length" class="mt-3">
|
||||
<div class="fw-bold mb-1">{{ $p.t('benotungstool/c4freigabeSummaryHeading', [summary.length]) }}</div>
|
||||
<div class="border rounded" style="max-height: 250px; overflow-y: auto;">
|
||||
<table class="table table-sm table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="bg-body sticky-top">{{ $capitalize($p.t('benotungstool/c4student')) }}</th>
|
||||
<th class="bg-body sticky-top">{{ $capitalize($p.t('benotungstool/c4freigabeSummaryCurrent')) }}</th>
|
||||
<th class="bg-body sticky-top"></th>
|
||||
<th class="bg-body sticky-top">{{ $capitalize($p.t('benotungstool/c4freigabeSummaryReleased')) }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in summary" :key="row.uid" :data-cy="'freigabe-row-' + row.uid">
|
||||
<td>{{ row.name }} <span class="text-muted">({{ row.uid }})</span></td>
|
||||
<td>{{ row.zeugnisnote }}</td>
|
||||
<td class="text-center"><i class="fa fa-arrow-right"></i></td>
|
||||
<td data-cy="freigabe-row-lvnote" :class="{ 'fw-bold text-success': row.changed }">{{ row.lvNote }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="small text-muted mt-1">{{ $p.t('benotungstool/c4freigabeSummaryLegend') }}</div>
|
||||
</div>
|
||||
<div v-else class="mt-3 text-center text-muted" data-cy="freigabe-summary-empty">{{ $p.t('benotungstool/c4freigabeSummaryEmpty') }}</div>
|
||||
<!-- The password field exists only while the dialog is open. Else the password manager
|
||||
offers its entries on every other text field, for example the date of the proposal. -->
|
||||
<div v-if="visible" class="mt-3 d-flex justify-content-center" data-cy="freigabe-password">
|
||||
<Password v-model="password" :feedback="false" showIcon="fa fa-eye" :toggleMask="true" :promptLabel="$p.t('benotungstool/passwort')"></Password>
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot:footer>
|
||||
<button type="button" data-cy="freigabe-submit" class="btn btn-primary" :disabled="!summary.length" @click="submit">{{ $p.t('benotungstool/noteneingabeBestätigen') }}</button>
|
||||
</template>
|
||||
</bs-modal>
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,204 @@
|
||||
import BsModal from '../../Bootstrap/Modal.js';
|
||||
import NotenlisteLinks from "./NotenlisteLinks.js";
|
||||
import {dmyToIso, isoToDmy} from "../../../helpers/DateHelpers.js";
|
||||
|
||||
/**
|
||||
* The two import dialogs. The user pastes lines from Excel: one row per line, the cells separated by a
|
||||
* tab. The dialog finds the student and checks the format of each cell. It warns for a bad line and
|
||||
* emits the other rows. The parent checks the Antritt chain and sends the rows.
|
||||
*
|
||||
* Pruefung import Kennung, Datum, Note -> 'import-pruefungen' [{ uid, lehreinheit_id, datum, note, punkte }]
|
||||
* Noten import Kennung, Note -> 'import-lv-noten' [{ uid, note, punkte }]
|
||||
*
|
||||
* CIS_GESAMTNOTE_IMPORT_SPALTEN_* gives the column order. In the Punkte mode the Note cell holds Punkte.
|
||||
*/
|
||||
export default {
|
||||
name: "BenotungstoolImport",
|
||||
components: {
|
||||
BsModal,
|
||||
NotenlisteLinks,
|
||||
Textarea: primevue.textarea
|
||||
},
|
||||
props: {
|
||||
students: { type: Array, default: () => [] },
|
||||
config: { type: Object, default: null },
|
||||
notenOptions: { type: Array, default: () => [] },
|
||||
lehrveranstaltung: { type: Object, default: null },
|
||||
semKurzbz: { type: String, default: null },
|
||||
lehreinheiten: { type: Array, default: () => [] },
|
||||
selectedLehreinheit: { type: Object, default: null }
|
||||
},
|
||||
emits: ['import-pruefungen', 'import-lv-noten'],
|
||||
data() {
|
||||
return {
|
||||
pruefungenText: '',
|
||||
lvNotenText: ''
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
pruefungenHint() {
|
||||
return this.$capitalize(this.$p.t('benotungstool/notenimportHinweistextv6')) + this.kuerzelHint;
|
||||
},
|
||||
lvNotenHint() {
|
||||
return this.$capitalize(this.$p.t('benotungstool/notenimportHinweistextv5')) + this.kuerzelHint;
|
||||
},
|
||||
/** One more bullet while the short form of tbl_note.anmerkung is allowed. */
|
||||
kuerzelHint() {
|
||||
if (!this.config?.CIS_GESAMTNOTE_IMPORT_NOTENKUERZEL) return '';
|
||||
|
||||
return '• ' + this.$capitalize(this.$p.t('benotungstool/c4importNotenkuerzel')) + '<br>';
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
openPruefungen() {
|
||||
this.$refs.modalPruefungen.show();
|
||||
},
|
||||
|
||||
openLvNoten() {
|
||||
this.$refs.modalLvNoten.show();
|
||||
},
|
||||
|
||||
submitPruefungen() {
|
||||
const rows = this.parseLines(this.pruefungenText, 3, 'c4importRowNotDateFormat', this.parsePruefungRow);
|
||||
this.$refs.modalPruefungen.hide();
|
||||
this.$emit('import-pruefungen', rows);
|
||||
},
|
||||
|
||||
submitLvNoten() {
|
||||
const rows = this.parseLines(this.lvNotenText, 2, 'c4importRowNotNoteFormat', this.parseLvNoteRow);
|
||||
this.$refs.modalLvNoten.hide();
|
||||
this.$emit('import-lv-noten', rows);
|
||||
},
|
||||
|
||||
/** Splits the text into rows. A line with the wrong number of cells names only its line number. */
|
||||
parseLines(text, cellCount, formatPhrase, parseRow) {
|
||||
const rows = [];
|
||||
|
||||
text.split('\n').forEach((line, index) => {
|
||||
if (line.trim() === '') return;
|
||||
|
||||
const lineNr = index + 1;
|
||||
const cells = line.split('\t');
|
||||
if (cells.length !== cellCount) {
|
||||
this.$fhcAlert.alertWarning(this.$p.t('benotungstool/' + formatPhrase, [lineNr]));
|
||||
return;
|
||||
}
|
||||
|
||||
const row = parseRow(cells, lineNr);
|
||||
if (row) rows.push(row);
|
||||
});
|
||||
|
||||
return rows;
|
||||
},
|
||||
|
||||
parsePruefungRow(cells, lineNr) {
|
||||
const kennung = cells[this.columnIndex('pruefung', 'kennung')];
|
||||
|
||||
const student = this.findStudent(kennung);
|
||||
if (!student) return this.warnLine('c4importNoStudentFoundForIdInRow', kennung, lineNr);
|
||||
|
||||
const datum = this.parseDatum(cells[this.columnIndex('pruefung', 'datum')]);
|
||||
if (!datum) return this.warnLine('c4importInvalidDateFoundForIdInRow', kennung, lineNr);
|
||||
|
||||
const value = this.parseNoteCell(cells[this.columnIndex('pruefung', 'note')]);
|
||||
if (!value) return this.warnLine('c4importNoGradeFoundForIdInRow', kennung, lineNr);
|
||||
|
||||
return { uid: student.uid, lehreinheit_id: student.lehreinheit_id, datum, ...value };
|
||||
},
|
||||
|
||||
parseLvNoteRow(cells, lineNr) {
|
||||
const kennung = cells[this.columnIndex('noten', 'kennung')];
|
||||
|
||||
const student = this.findStudent(kennung);
|
||||
if (!student) return this.warnLine('c4importNoStudentFoundForIdInRow', kennung, lineNr);
|
||||
|
||||
const value = this.parseNoteCell(cells[this.columnIndex('noten', 'note')]);
|
||||
if (!value) return this.warnLine('c4importNoGradeFoundForIdInRow', kennung, lineNr);
|
||||
|
||||
return { uid: student.uid, ...value };
|
||||
},
|
||||
|
||||
/** @param {'pruefung'|'noten'} kind */
|
||||
columnIndex(kind, name) {
|
||||
const key = kind === 'pruefung' ? 'CIS_GESAMTNOTE_IMPORT_SPALTEN_PRUEFUNG' : 'CIS_GESAMTNOTE_IMPORT_SPALTEN_NOTEN';
|
||||
return this.config[key].indexOf(name);
|
||||
},
|
||||
|
||||
/** A Kennung is a uid (it starts with a letter) or a Matrikelnummer (it starts with a digit). */
|
||||
findStudent(kennung) {
|
||||
const value = String(kennung ?? '').trim();
|
||||
|
||||
if (/^[0-9]/.test(value)) return this.students.find(s => s.matrikelnr?.trim() === value);
|
||||
if (/^[a-zA-Z]/.test(value)) return this.students.find(s => s.uid?.trim() === value);
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
/** @returns {string|null} 'yyyy-MM-dd', or null if the value does not match CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT */
|
||||
parseDatum(value) {
|
||||
const text = String(value ?? '').trim();
|
||||
|
||||
if (this.config.CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT === 'yyyy-MM-dd') {
|
||||
// the round trip rejects a day that does not exist, for example 2026-02-31
|
||||
return dmyToIso(isoToDmy(text)) === text ? text : null;
|
||||
}
|
||||
|
||||
return dmyToIso(text);
|
||||
},
|
||||
|
||||
/**
|
||||
* The Note cell: in the Punkte mode a number (a decimal comma counts as a point), else a Note of
|
||||
* the Lehre, by its key or, with CIS_GESAMTNOTE_IMPORT_NOTENKUERZEL, by its short form.
|
||||
*
|
||||
* @returns {{note, punkte}|null}
|
||||
*/
|
||||
parseNoteCell(value) {
|
||||
const text = String(value ?? '').trim();
|
||||
|
||||
if (this.config.CIS_GESAMTNOTE_PUNKTE) return { note: null, punkte: Number.parseFloat(text.replace(',', '.')) };
|
||||
|
||||
const option = this.findNote(text);
|
||||
return option?.lehre ? { note: option.note, punkte: null } : null;
|
||||
},
|
||||
|
||||
findNote(text) {
|
||||
if (text === '') return null;
|
||||
|
||||
const byKey = this.notenOptions.find(n => String(n.note).trim() === text);
|
||||
if (byKey || !this.config.CIS_GESAMTNOTE_IMPORT_NOTENKUERZEL) return byKey ?? null;
|
||||
|
||||
const byKuerzel = this.notenOptions.filter(n => String(n.anmerkung ?? '').trim().toLowerCase() === text.toLowerCase());
|
||||
return byKuerzel.length === 1 ? byKuerzel[0] : null;
|
||||
},
|
||||
|
||||
warnLine(phrase, kennung, lineNr) {
|
||||
this.$fhcAlert.alertWarning(this.$p.t('benotungstool/' + phrase, [kennung, lineNr]));
|
||||
return null;
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<bs-modal data-cy="modal-pruefung-import" ref="modalPruefungen" class="bootstrap-prompt" dialogClass="modal-lg" bodyClass="px-4 py-4">
|
||||
<template v-slot:title>{{ $capitalize($p.t('benotungstool/c4pruefungImportieren')) }}</template>
|
||||
<template v-slot:default>
|
||||
<div v-html="pruefungenHint"></div>
|
||||
<Textarea data-cy="pruefung-import-text" v-model="pruefungenText" rows="5" class="w-100 mt-3" :placeholder="$p.t('benotungstool/c4importPlaceholder')"></Textarea>
|
||||
<NotenlisteLinks class="mt-3" :lehrveranstaltung="lehrveranstaltung" :sem_kurzbz="semKurzbz" :lehreinheiten="lehreinheiten" :selected-lehreinheit="selectedLehreinheit" />
|
||||
</template>
|
||||
<template v-slot:footer>
|
||||
<button type="button" data-cy="pruefung-import-submit" class="btn btn-primary" @click="submitPruefungen">{{ $capitalize($p.t('benotungstool/c4import')) }}</button>
|
||||
</template>
|
||||
</bs-modal>
|
||||
|
||||
<bs-modal data-cy="modal-lvnoten-import" ref="modalLvNoten" class="bootstrap-prompt" dialogClass="modal-lg" bodyClass="px-4 py-4">
|
||||
<template v-slot:title>{{ $capitalize($p.t('benotungstool/c4notenImportieren')) }}</template>
|
||||
<template v-slot:default>
|
||||
<div v-html="lvNotenHint"></div>
|
||||
<Textarea data-cy="lvnoten-import-text" v-model="lvNotenText" rows="5" class="w-100 mt-3" :placeholder="$p.t('benotungstool/c4importNotePlaceholder')"></Textarea>
|
||||
<NotenlisteLinks class="mt-3" :lehrveranstaltung="lehrveranstaltung" :sem_kurzbz="semKurzbz" :lehreinheiten="lehreinheiten" :selected-lehreinheit="selectedLehreinheit" />
|
||||
</template>
|
||||
<template v-slot:footer>
|
||||
<button type="button" data-cy="lvnoten-import-submit" class="btn btn-primary" @click="submitLvNoten">{{ $capitalize($p.t('benotungstool/c4import')) }}</button>
|
||||
</template>
|
||||
</bs-modal>
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
// The links to the Excel Notenliste of the LV and of each Lehreinheit.
|
||||
export const NotenlisteLinks = {
|
||||
name: "NotenlisteLinks",
|
||||
props: {
|
||||
lehrveranstaltung: { type: Object, default: null },
|
||||
sem_kurzbz: { type: String, default: null },
|
||||
lehreinheiten: { type: Array, default: () => [] },
|
||||
selectedLehreinheit: { type: Object, default: null }
|
||||
},
|
||||
computed: {
|
||||
/** A selected Lehreinheit shows only its own link. */
|
||||
shownLehreinheiten() {
|
||||
if (this.selectedLehreinheit?.lehreinheit_id == null) return this.lehreinheiten;
|
||||
|
||||
return this.lehreinheiten.filter(le => le.lehreinheit_id === this.selectedLehreinheit.lehreinheit_id);
|
||||
},
|
||||
baseUrl() {
|
||||
return FHC_JS_DATA_STORAGE_OBJECT.app_root
|
||||
+ 'cis/private/lehre/notenliste.xls.php';
|
||||
},
|
||||
ready() {
|
||||
const lv = this.lehrveranstaltung;
|
||||
return !!(lv && lv.studiengang_kz != null && lv.lv_semester != null
|
||||
&& lv.lehrveranstaltung_id != null && this.sem_kurzbz);
|
||||
},
|
||||
fullListUrl() {
|
||||
if (!this.ready) return null;
|
||||
return this.buildUrl();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
buildUrl(lehreinheit_id = null) {
|
||||
const lv = this.lehrveranstaltung;
|
||||
const params = new URLSearchParams({
|
||||
stg: lv.studiengang_kz,
|
||||
sem: lv.lv_semester,
|
||||
lvid: lv.lehrveranstaltung_id,
|
||||
stsem: this.sem_kurzbz
|
||||
});
|
||||
if (lehreinheit_id != null) params.set('lehreinheit_id', lehreinheit_id);
|
||||
return this.baseUrl + '?' + params.toString();
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div v-if="ready">
|
||||
<div class="fw-bold mb-2">{{ $capitalize($p.t('benotungstool/c4notenlisten')) }}</div>
|
||||
|
||||
<div class="mb-1">
|
||||
<a class="Item" :href="fullListUrl" target="_blank" rel="noopener">
|
||||
{{ $capitalize($p.t('benotungstool/c4gesamtliste')) }} {{ lehrveranstaltung.lv_bezeichnung }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div v-for="le in shownLehreinheiten" :key="le.lehreinheit_id" class="mb-1" style="padding-left: 1.5rem;">
|
||||
<a class="Item" :href="buildUrl(le.lehreinheit_id)" target="_blank" rel="noopener">
|
||||
{{ le.infoString }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-muted">
|
||||
{{ $capitalize($p.t('benotungstool/c4keineStudentenGefunden')) }}
|
||||
</div>
|
||||
`
|
||||
};
|
||||
|
||||
export default NotenlisteLinks;
|
||||
@@ -0,0 +1,295 @@
|
||||
import BsModal from '../../Bootstrap/Modal.js';
|
||||
import VueDatePicker from '../../vueDatepicker.js.php';
|
||||
import ApiNoten from "../../../api/factory/noten.js";
|
||||
import {debounce} from "../../../helpers/debounce.js";
|
||||
import {today, toIsoDate, parseIsoDate, addDays} from "../../../helpers/DateHelpers.js";
|
||||
|
||||
/**
|
||||
* The Pruefung dialog. It collects the input and emits it. The parent checks the Antritt chain and
|
||||
* writes.
|
||||
*
|
||||
* openForCell(student, pruefung, field) a new or a changed Pruefung of one student -> 'save-pruefung' (student, pruefung)
|
||||
* openForSelection() one new Pruefung on one day for the selection -> 'create-pruefungen' (pruefung)
|
||||
*
|
||||
* The table holds the selection. The multiselect emits 'select-students', the parent selects the rows,
|
||||
* and the prop selectedStudents follows.
|
||||
*/
|
||||
export default {
|
||||
name: "BenotungstoolPruefungDialog",
|
||||
components: {
|
||||
BsModal,
|
||||
Datepicker: VueDatePicker,
|
||||
Dropdown: primevue.dropdown,
|
||||
InputNumber: primevue.inputnumber,
|
||||
Multiselect: primevue.multiselect
|
||||
},
|
||||
props: {
|
||||
config: { type: Object, default: null },
|
||||
notenOptions: { type: Array, default: () => [] },
|
||||
lvId: { default: null },
|
||||
semKurzbz: { type: String, default: null },
|
||||
// the students that can get a Pruefung, in the order of the table
|
||||
studentOptions: { type: Array, default: () => [] },
|
||||
selectedStudents: { type: Array, default: () => [] }
|
||||
},
|
||||
emits: ['save-pruefung', 'create-pruefungen', 'select-students'],
|
||||
data() {
|
||||
return {
|
||||
// the student of the cell; null in the dialog for the selection
|
||||
student: null,
|
||||
pruefung: null,
|
||||
selectedDate: today(),
|
||||
selectedNote: null,
|
||||
selectedPunkte: null,
|
||||
// a later Pruefung locks the Note; the date stays editable
|
||||
noteLocked: false,
|
||||
dateMin: null,
|
||||
dateMax: null,
|
||||
// the Pruefung names the Lektor of the Lehreinheit, not the user; with several Lektoren the user selects one
|
||||
lektoren: [],
|
||||
selectedLektor: null,
|
||||
debouncedShowNoteForPunkte: null
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
title() {
|
||||
if (!this.student) return this.$capitalize(this.$p.t('benotungstool/c4addNewPruefung'));
|
||||
|
||||
const phrase = this.pruefung ? 'benotungstool/editPruefungFor' : 'benotungstool/createPruefungFor';
|
||||
return this.$capitalize(this.$p.t(phrase)) + ' ' + this.student.vorname + ' ' + this.student.nachname;
|
||||
},
|
||||
lehreNoten() {
|
||||
return this.notenOptions.filter(n => n.lehre === true);
|
||||
},
|
||||
/** The students that get the Pruefung. */
|
||||
students() {
|
||||
return this.student ? [this.student] : this.selectedStudents;
|
||||
},
|
||||
/** The hint: for these students the new Pruefung also writes the LV-Note. */
|
||||
withoutLvNote() {
|
||||
return this.pruefung ? [] : this.students.filter(s => !s.verlauf.hasLvNote);
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
/** Warns early if the selected Note reached its limit (verlauf.notenAtLimit); the server checks it too. */
|
||||
selectedNote(option, previous) {
|
||||
if (!option || !this.student) return;
|
||||
|
||||
// the changed Pruefung may keep its own Note
|
||||
const limit = this.student.verlauf.notenAtLimit[option.note];
|
||||
if (limit == null || option.note == this.pruefung?.note) return;
|
||||
|
||||
this.$fhcAlert.alertWarning(this.$capitalize(this.$p.t('benotungstool/c4noteLimitUeberschritten', [
|
||||
option.bezeichnung, this.student.uid, limit
|
||||
])));
|
||||
this.selectedNote = previous;
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.debouncedShowNoteForPunkte = debounce(this.showNoteForPunkte, 500);
|
||||
},
|
||||
methods: {
|
||||
openForCell(student, pruefung, field) {
|
||||
this.student = student;
|
||||
this.pruefung = pruefung;
|
||||
this.loadLektoren([student.lehreinheit_id]);
|
||||
|
||||
// the 'antritt' column has no date, so a new Pruefung starts with today
|
||||
const datum = pruefung?.datum ?? (/^\d{4}-\d{2}-\d{2}$/.test(field) ? field : null);
|
||||
this.selectedDate = datum ? parseIsoDate(datum) : today();
|
||||
this.selectedNote = pruefung ? this.notenOptions.find(n => n.note == pruefung.note) ?? null : null;
|
||||
this.selectedPunkte = pruefung?.punkte ?? null;
|
||||
// the server locks the Note of a Pruefung with a later one
|
||||
this.noteLocked = !!pruefung?.note_locked;
|
||||
|
||||
const bounds = this.pruefungDateBounds(student, pruefung, field);
|
||||
this.dateMin = bounds.min;
|
||||
this.dateMax = bounds.max;
|
||||
|
||||
this.$refs.modal.show();
|
||||
},
|
||||
|
||||
openForSelection() {
|
||||
this.student = null;
|
||||
this.pruefung = null;
|
||||
this.loadLektoren(this.selectedStudents.map(s => s.lehreinheit_id));
|
||||
|
||||
// empty, without a Note
|
||||
this.selectedDate = today();
|
||||
this.selectedNote = null;
|
||||
this.selectedPunkte = null;
|
||||
this.noteLocked = false;
|
||||
this.dateMin = null;
|
||||
this.dateMax = null;
|
||||
|
||||
this.$refs.modal.show();
|
||||
},
|
||||
|
||||
/**
|
||||
* The date of a Pruefung stays strictly between its neighbours, so the order of the Antritte
|
||||
* stays. A column without a date ('antritt_2') means a new Pruefung after all others.
|
||||
*
|
||||
* @returns {{min: Date|null, max: Date|null}} the inclusive bounds of the date picker
|
||||
*/
|
||||
pruefungDateBounds(student, pruefung, field) {
|
||||
const own = String(pruefung?.datum ?? field ?? '').slice(0, 10);
|
||||
const reference = /^\d{4}-\d{2}-\d{2}$/.test(own) ? own : '9999-12-31';
|
||||
|
||||
let lower = null;
|
||||
let upper = null;
|
||||
(student.verlauf.pruefungen ?? []).forEach(p => {
|
||||
if (pruefung && p.pruefung_id === pruefung.pruefung_id) return;
|
||||
|
||||
const day = String(p.datum ?? '').slice(0, 10);
|
||||
if (!day) return;
|
||||
if (day < reference && (lower === null || day > lower)) lower = day;
|
||||
if (day > reference && (upper === null || day < upper)) upper = day;
|
||||
});
|
||||
|
||||
return {
|
||||
min: lower ? addDays(parseIsoDate(lower), 1) : null,
|
||||
max: upper ? addDays(parseIsoDate(upper), -1) : null
|
||||
};
|
||||
},
|
||||
|
||||
submit() {
|
||||
if (this.student) this.submitForCell(); else this.submitForSelection();
|
||||
},
|
||||
|
||||
submitForCell() {
|
||||
// a typed date can pass the bounds of the date picker
|
||||
if ((this.dateMin && this.selectedDate < this.dateMin) || (this.dateMax && this.selectedDate > this.dateMax)) {
|
||||
this.$fhcAlert.alertWarning(this.$capitalize(this.$p.t('benotungstool/pruefungDatumOutOfRangeHint')));
|
||||
return;
|
||||
}
|
||||
|
||||
// Without a selection the Note stays empty; the server writes 'Noch nicht eingetragen'. Without
|
||||
// the Punkte mode a 0 would be a value: the server derives the Note from it.
|
||||
const pruefung = {
|
||||
pruefung_id: this.pruefung?.pruefung_id ?? null,
|
||||
lehreinheit_id: this.student.lehreinheit_id,
|
||||
datum: toIsoDate(this.selectedDate),
|
||||
note: this.noteLocked ? this.pruefung.note : (this.selectedNote?.note ?? ''),
|
||||
punkte: this.config.CIS_GESAMTNOTE_PUNKTE ? (this.noteLocked ? this.pruefung.punkte : this.selectedPunkte) ?? null : null,
|
||||
mitarbeiter_uid: this.selectedLektor
|
||||
};
|
||||
|
||||
this.$refs.modal.hide();
|
||||
this.$emit('save-pruefung', this.student, pruefung);
|
||||
},
|
||||
|
||||
submitForSelection() {
|
||||
// a missing LV-Note blocks nothing: the server writes it with the Note of this Pruefung
|
||||
const pruefung = {
|
||||
datum: toIsoDate(this.selectedDate),
|
||||
note: this.selectedNote?.note ?? null,
|
||||
punkte: this.selectedPunkte ?? null,
|
||||
mitarbeiter_uid: this.selectedLektor
|
||||
};
|
||||
|
||||
this.$refs.modal.hide();
|
||||
this.$emit('create-pruefungen', pruefung);
|
||||
},
|
||||
|
||||
/** The parent selects the rows in the table; the Lektoren follow the new selection. */
|
||||
selectStudents(students) {
|
||||
this.$emit('select-students', students);
|
||||
this.loadLektoren(students.map(s => s.lehreinheit_id));
|
||||
},
|
||||
|
||||
/** The Lektoren of the Lehreinheit. With exactly one Lektor the selection stays empty; the server sets that Lektor. */
|
||||
loadLektoren(lehreinheitIds) {
|
||||
this.lektoren = [];
|
||||
this.selectedLektor = null;
|
||||
|
||||
// a Lektor selection makes sense only if all students share one Lehreinheit
|
||||
const distinct = [...new Set(lehreinheitIds.filter(id => id != null))];
|
||||
if (distinct.length !== 1) return;
|
||||
|
||||
this.$api.call(ApiNoten.getLektorenForLehreinheit(this.lvId, this.semKurzbz, distinct[0])).then(res => {
|
||||
this.lektoren = res.data.map(l => ({
|
||||
...l,
|
||||
name: `${l.vorname ?? ''} ${l.nachname ?? ''}`.trim() || l.mitarbeiter_uid
|
||||
}));
|
||||
|
||||
// a selection only if there is something to select
|
||||
if (this.lektoren.length > 1) {
|
||||
const current = this.pruefung?.mitarbeiter_uid;
|
||||
this.selectedLektor = this.lektoren.some(l => l.mitarbeiter_uid === current) ? current : this.lektoren[0].mitarbeiter_uid;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/** In the Punkte mode the dialog shows the Note of the Punkte, from the Notenschluessel of the LV. */
|
||||
showNoteForPunkte(event) {
|
||||
const punkte = event.value === '' ? null : event.value;
|
||||
|
||||
this.$api.call(ApiNoten.getNoteByPunkte(this.lvId, this.semKurzbz, punkte)).then(res => {
|
||||
if (res.data >= 0) this.selectedNote = this.notenOptions.find(n => n.note == res.data);
|
||||
});
|
||||
},
|
||||
|
||||
/** The multiselect label: "uid – Nachname Vorname – Antritte: n". */
|
||||
studentLabel(student) {
|
||||
return `${student.uid} – ${student.nachname} ${student.vorname} – ${this.$capitalize(this.$p.t('benotungstool/c4antrittCountv2'))}: ${student.verlauf.antrittCount}`;
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<bs-modal data-cy="modal-pruefung" ref="modal" class="bootstrap-prompt" dialogClass="modal-lg" bodyClass="px-4 py-4">
|
||||
<template v-slot:title>{{ title }}</template>
|
||||
<template v-slot:default>
|
||||
<div class="row align-items-center justify-content-center">
|
||||
<div class="col-3 text-center">{{ $capitalize($p.t('benotungstool/c4date')) }}:</div>
|
||||
<div class="col-6" data-cy="pruefung-datum">
|
||||
<datepicker v-model="selectedDate" :clearable="false" :enableTimePicker="false" format="dd.MM.yyyy" placeholder="TT.MM.JJJJ"
|
||||
:min-date="dateMin" :max-date="dateMax" :text-input="true" :auto-apply="true">
|
||||
</datepicker>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="noteLocked" class="row mt-2 justify-content-center">
|
||||
<div class="col-9 text-center text-muted small" data-cy="pruefung-note-locked">{{ $capitalize($p.t('benotungstool/pruefungNoteLockedHint')) }}</div>
|
||||
</div>
|
||||
<div v-if="config?.CIS_GESAMTNOTE_PUNKTE" class="row mt-3 align-items-center justify-content-center">
|
||||
<div class="col-3 text-center">{{ $capitalize($p.t('benotungstool/c4punkte')) }}:</div>
|
||||
<div class="col-6">
|
||||
<InputNumber data-cy="pruefung-punkte" v-model="selectedPunkte" @input="debouncedShowNoteForPunkte" :disabled="noteLocked"
|
||||
inputId="pruefungPunkte" :min="0" :max="100000" class="w-100">
|
||||
</InputNumber>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3 align-items-center justify-content-center">
|
||||
<div class="col-3 text-center">{{ $capitalize($p.t('lehre/note')) }}:</div>
|
||||
<div class="col-6">
|
||||
<Dropdown data-cy="pruefung-note" :placeholder="$capitalize($p.t('lehre/note'))" :disabled="config?.CIS_GESAMTNOTE_PUNKTE || noteLocked"
|
||||
:style="{'width': '100%'}" optionLabel="bezeichnung" v-model="selectedNote" :options="lehreNoten" showClear>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!student" class="row mt-3 align-items-center justify-content-center">
|
||||
<div class="col-3 text-center">{{ $capitalize($p.t('benotungstool/prueflingSelectionv2')) }}:</div>
|
||||
<div class="col-6">
|
||||
<Multiselect data-cy="pruefung-students" :modelValue="selectedStudents" @update:modelValue="selectStudents" :options="studentOptions" :optionLabel="studentLabel"
|
||||
:placeholder="$capitalize($p.t('benotungstool/prueflingSelectionv2'))" :maxSelectedLabels="3" showToggleAll class="w-100" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="lektoren.length > 1" class="row mt-3 align-items-center justify-content-center">
|
||||
<div class="col-3 text-center">{{ $capitalize($p.t('benotungstool/c4lektor')) }}:</div>
|
||||
<div class="col-6">
|
||||
<Dropdown data-cy="pruefung-lektor" :style="{'width': '100%'}" v-model="selectedLektor" :options="lektoren"
|
||||
optionLabel="name" optionValue="mitarbeiter_uid">
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="withoutLvNote.length" class="alert alert-info mt-3 mb-0 py-2" data-cy="pruefung-without-lvnote">
|
||||
<div>{{ $p.t('benotungstool/c4lvNoteWirdAngelegtHinweis') }}</div>
|
||||
<div v-if="!student" class="small mt-1">{{ withoutLvNote.map(s => s.vorname + ' ' + s.nachname + ' (' + s.uid + ')').join(', ') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot:footer>
|
||||
<button type="button" data-cy="pruefung-submit" class="btn btn-primary" @click="submit">
|
||||
{{ student ? $capitalize($p.t('global/speichern')) : $capitalize($p.t('benotungstool/c4addNewPruefung')) }}
|
||||
</button>
|
||||
</template>
|
||||
</bs-modal>
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* The layout of the Benotungstool table: what the browser keeps (column order, width, visibility,
|
||||
* filters, sort), the sticky columns, and the scroll position during a render.
|
||||
*/
|
||||
|
||||
/** Reads a value that the browser keeps. Private mode or a broken value give `fallback`. */
|
||||
export function loadKept(key, fallback) {
|
||||
try {
|
||||
const value = localStorage.getItem(key);
|
||||
return value === null ? fallback : JSON.parse(value);
|
||||
} catch (e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveKept(key, value) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (e) {
|
||||
// private mode: the value lasts until the next load
|
||||
}
|
||||
}
|
||||
|
||||
/** Keeps the layout of the columns in `fields`. The Pruefung columns change per LV, so they are left out. */
|
||||
export function saveLayout(key, table, fields) {
|
||||
saveKept(key, {
|
||||
columns: table.getColumnLayout()
|
||||
.filter(col => fields.includes(col.field))
|
||||
.map(col => ({ field: col.field, visible: col.visible, width: col.width })),
|
||||
sort: table.getSorters().map(s => ({ field: s.field, dir: s.dir })),
|
||||
filters: table.getFilters(),
|
||||
headerFilters: table.getHeaderFilters()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The column definitions in the kept order, with the kept width and visibility. New columns come last.
|
||||
* Apply it BEFORE Tabulator takes the columns: after that the definitions and the Vue titles conflict.
|
||||
*/
|
||||
export function restoreColumns(columns, layout) {
|
||||
if (!layout?.columns) return columns;
|
||||
|
||||
const byField = new Map(columns.map(c => [c.field, c]));
|
||||
const restored = [];
|
||||
|
||||
layout.columns.forEach(kept => {
|
||||
const column = byField.get(kept.field);
|
||||
if (!column) return;
|
||||
|
||||
restored.push({ ...column, width: kept.width, visible: kept.visible });
|
||||
byField.delete(kept.field);
|
||||
});
|
||||
|
||||
byField.forEach(column => restored.push(column));
|
||||
return restored;
|
||||
}
|
||||
|
||||
/** Applies the kept filters and sort. Call it once, after the first rows: then the Pruefung columns exist. */
|
||||
export function restoreFiltersAndSort(table, layout) {
|
||||
if (layout?.filters?.length) table.setFilter(layout.filters);
|
||||
|
||||
(layout?.headerFilters ?? []).forEach(hf => table.setHeaderFilterValue(hf.field, hf.value));
|
||||
|
||||
// a Pruefung column of another LV does not exist in this table
|
||||
const fields = table.getColumns().map(column => column.getField());
|
||||
const sorters = (layout?.sort ?? [])
|
||||
.filter(s => fields.includes(s.field))
|
||||
.map(s => ({ column: s.field, dir: s.dir }));
|
||||
if (sorters.length) table.setSort(sorters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pins the selected columns to the left. A `sticky-on-<field>` class on the container switches one
|
||||
* column on (see Benotungstool.css), and `--sl-<field>` is its left offset: the width of the pinned
|
||||
* columns before it, so that the pinned columns stand next to each other.
|
||||
*/
|
||||
export function applyStickyColumns(element, table, fields, selected) {
|
||||
if (!element || !table) return;
|
||||
|
||||
let offset = 0;
|
||||
// the display order, so a moved column keeps the right offset
|
||||
table.getColumns().forEach(column => {
|
||||
const field = column.getField();
|
||||
if (!fields.includes(field)) return;
|
||||
|
||||
const pinned = selected.includes(field);
|
||||
element.classList.toggle('sticky-on-' + field, pinned);
|
||||
element.style.setProperty('--sl-' + field, offset + 'px');
|
||||
if (pinned) offset += column.getWidth();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the scroll position while `operation` renders the table again.
|
||||
*
|
||||
* Read and write .tabulator-tableholder directly: Tabulator does not update rowManager.scrollLeft.
|
||||
* Set the position again in the next two frames: Tabulator renders later and resets it, and
|
||||
* Vue.nextTick is too early.
|
||||
*/
|
||||
export function preserveScroll(table, operation) {
|
||||
const holder = table?.element?.querySelector('.tabulator-tableholder');
|
||||
const left = holder?.scrollLeft ?? 0;
|
||||
const top = holder?.scrollTop ?? 0;
|
||||
|
||||
const result = operation();
|
||||
|
||||
// nothing to keep; 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 returns a promise; set the position again after the load
|
||||
if (result && typeof result.then === 'function') result.then(() => requestAnimationFrame(restore));
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
|
||||
export const MobilityLegende = {
|
||||
name: 'MobilityLegende',
|
||||
template:`
|
||||
<div class="col-auto" style="max-width: 60vw">
|
||||
<!-- TODO: phrasen definieren & verwenden-->
|
||||
<div class="row" style="font-weight: bold"><h6>Legende</h6></div>
|
||||
<div class="row"><h6>(i) ... Incoming</h6></div>
|
||||
<div class="row"><h6>(o) ... Outgoing</h6></div>
|
||||
<div class="row"><h6>(ar) ... angerechnet </h6></div>
|
||||
<div class="row"><h6>(iar) ... intern angerechnet</h6></div>
|
||||
<div class="row"><h6>(nz) ... nicht zugelassen</h6></div>
|
||||
<div class="row"><h6>(ma) ... MitarbeiterIn</h6></div>
|
||||
<div class="row"><h6>(a.o.) ... Außerordentliche/r HörerIn</h6></div>
|
||||
<div class="row"><h6>(d.d.) ... Double Degree Program</h6></div>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
|
||||
export default MobilityLegende;
|
||||
@@ -59,3 +59,112 @@ export function formatDate(d) {
|
||||
throw new TypeError("The parameter provided for this function is not a string or a Date object, please refere to the function formatDate in the DateHelpers.js file");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* today in the time zone of the instance, as a Date at midnight
|
||||
*
|
||||
* The browser can stand in another time zone than the server. A date from the browser then names the
|
||||
* next day, and the server refuses the entry as a date in the future.
|
||||
*
|
||||
* @returns {Date} today at 00:00 local time, with the day of the instance time zone.
|
||||
*/
|
||||
export function today() {
|
||||
const zone = (typeof FHC_JS_DATA_STORAGE_OBJECT !== "undefined")
|
||||
? FHC_JS_DATA_STORAGE_OBJECT.timezone
|
||||
: null;
|
||||
|
||||
if (zone && typeof luxon !== "undefined")
|
||||
{
|
||||
const now = luxon.DateTime.local().setZone(zone);
|
||||
if (now.isValid) return new Date(now.year, now.month - 1, now.day);
|
||||
}
|
||||
|
||||
// without the time zone of the instance the browser decides
|
||||
const local = new Date();
|
||||
return new Date(local.getFullYear(), local.getMonth(), local.getDate());
|
||||
}
|
||||
|
||||
/**
|
||||
* Date -> 'YYYY-MM-DD' in local time. An API takes the day, never a timestamp.
|
||||
*
|
||||
* @param {Date} date
|
||||
* @returns {string}
|
||||
*/
|
||||
export function toIsoDate(date) {
|
||||
return `${date.getFullYear()}-${numberPadding(date.getMonth() + 1)}-${numberPadding(date.getDate())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 'YYYY-MM-DD' (a longer string is cut) -> Date at 00:00 local time.
|
||||
*
|
||||
* @param {string} iso
|
||||
* @returns {Date|null}
|
||||
*/
|
||||
export function parseIsoDate(iso) {
|
||||
const parts = String(iso ?? '').slice(0, 10).split('-');
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
return new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 'YYYY-MM-DD HH:MM:SS' or 'YYYY-MM-DD' -> Date in local time.
|
||||
*
|
||||
* @param {string} timestamp
|
||||
* @returns {Date|null}
|
||||
*/
|
||||
export function parseTimestamp(timestamp) {
|
||||
if (!timestamp) return null;
|
||||
|
||||
const [datePart, timePart] = String(timestamp).trim().split(' ');
|
||||
const [year, month, day] = (datePart ?? '').split('-').map(Number);
|
||||
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null;
|
||||
|
||||
const [hour, minute, second] = (timePart ?? '').split(':').map(Number);
|
||||
|
||||
return new Date(year, month - 1, day,
|
||||
Number.isFinite(hour) ? hour : 0,
|
||||
Number.isFinite(minute) ? minute : 0,
|
||||
Number.isFinite(second) ? second : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 'YYYY-MM-DD' (a longer string is cut) -> 'DD.MM.YYYY'. A string operation: no time zone applies.
|
||||
*
|
||||
* @param {string} iso
|
||||
* @returns {string} an empty string for an invalid value
|
||||
*/
|
||||
export function isoToDmy(iso) {
|
||||
const parts = String(iso ?? '').slice(0, 10).split('-');
|
||||
if (parts.length !== 3) return '';
|
||||
|
||||
return `${parts[2]}.${parts[1]}.${parts[0]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 'DD.MM.YYYY' -> 'YYYY-MM-DD'.
|
||||
*
|
||||
* @param {string} dmy
|
||||
* @returns {string|null} null if the value has another format or the day does not exist (31.02.)
|
||||
*/
|
||||
export function dmyToIso(dmy) {
|
||||
const match = String(dmy ?? '').trim().match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
|
||||
if (!match) return null;
|
||||
|
||||
const [, day, month, year] = match.map(Number);
|
||||
const date = new Date(year, month - 1, day);
|
||||
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) return null;
|
||||
|
||||
return `${match[3]}-${match[2]}-${match[1]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Date} date
|
||||
* @param {number} days
|
||||
* @returns {Date} a new Date, $days later
|
||||
*/
|
||||
export function addDays(date, days) {
|
||||
const result = new Date(date);
|
||||
result.setDate(result.getDate() + days);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
/**
|
||||
* Makes a value safe for an HTML string. A Tabulator formatter string becomes innerHTML, therefore
|
||||
* a value from the database or from an addon must not carry markup.
|
||||
*/
|
||||
export function escapeHtml(value) {
|
||||
if (value === null || value === undefined) return '';
|
||||
|
||||
const replacements = {'&': '&', '<': '<', '>': '>', '"': '"', "'": '''};
|
||||
|
||||
return String(value).replace(/[&<>"']/g, (character) => replacements[character]);
|
||||
}
|
||||
|
||||
export function capitalize(string) {
|
||||
if (!string) return "";
|
||||
return string[0].toUpperCase() + string.slice(1);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export function debounce(fn, delay) {
|
||||
let timeoutId;
|
||||
return (...args) => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => {
|
||||
fn(...args)
|
||||
}, delay);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {escapeHtml} from "../../helpers/StringHelpers.js";
|
||||
|
||||
export function centeredTextFormatter(cell) {
|
||||
const longForm = cell.getValue()
|
||||
if(!longForm) return
|
||||
const data = cell.getData()
|
||||
const entry = Object.entries(data).find(entry => entry[1] == longForm)
|
||||
|
||||
// shortFormKey must have same keyname as longForm but with 'Short' appended
|
||||
const shortForm = data[entry[0]+'Short']
|
||||
|
||||
if(shortForm && longForm) {
|
||||
return `<div style="display: flex; justify-content: start; align-items: center; height: 100%; width: 100%;">
|
||||
<span class="full-text" style="max-width: 100%; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; margin: 0px;">
|
||||
${escapeHtml(longForm)}
|
||||
</span>
|
||||
<span class="short-text" style="font-weight: bold; display: none;">
|
||||
${escapeHtml(shortForm)}
|
||||
</span>
|
||||
</div>`;
|
||||
} else {
|
||||
return '<div style="display: flex; justify-content: start; align-items: center; height: 100%">' +
|
||||
'<p style="max-width: 100%; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; margin: 0px;">'+escapeHtml(longForm)+'</p></div>'
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,8 @@ $berechtigungen = array(
|
||||
array('lehre','Berechtigung fuer CIS-Seite'),
|
||||
array('lehre/abgabetool','Projektabgabetool, Studentenansicht'),
|
||||
array('lehre/abgabetool:download','Download von Projektarbeitsabgaben'),
|
||||
array('lehre/benotungstool','Cis4 Gesamtnoteneingabe'),
|
||||
array('lehre/benotungstool_assistenz','Cis4 Gesamtnoteneingabe für Assistenz'),
|
||||
array('lehre/freifach','Freifachverwaltung'),
|
||||
array('lehre/lehrfach','Lehrfachverwaltung'),
|
||||
array('lehre/lehrfach:begrenzt','Lehrfachverwaltung - nur aktiv aenderbar, nur aktive LF werden angezeigt'),
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
if (! defined('DB_NAME')) exit('No direct script access allowed');
|
||||
|
||||
if($result = $db->db_query("SELECT 1 FROM public.tbl_vorlage WHERE vorlage_kurzbz = 'Notenfreigabe'"))
|
||||
{
|
||||
if($db->db_num_rows($result) === 0)
|
||||
{
|
||||
$qry = "INSERT INTO public.tbl_vorlage (vorlage_kurzbz, bezeichnung, anmerkung, mimetype)
|
||||
VALUES ('Notenfreigabe', 'Notenfreigabe', null, 'text/html')
|
||||
ON CONFLICT (vorlage_kurzbz) DO NOTHING;";
|
||||
|
||||
if(!$db->db_query($qry))
|
||||
echo '<strong>system.tbl_vorlage: '.$db->db_last_error().'</strong><br>';
|
||||
else
|
||||
echo "<br>system.tbl_vorlage Notenfreigabe hinzugefuegt";
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,720 @@
|
||||
-- Seeder of feature-60873/GesamtnoteneingabeCis4. A "-- ==== <group> ====" line starts a group; tests/cypress/tools/dbCheck.js applies single groups.
|
||||
|
||||
-- ==== helper_functions ====
|
||||
-- Semester helpers for group studiengang_5. BS001_helper_functions.sql does not define them.
|
||||
|
||||
CREATE OR REPLACE FUNCTION CurrentSemester() RETURNS varchar(32) AS $$
|
||||
DECLARE res varchar(32);
|
||||
BEGIN
|
||||
SELECT studiensemester_kurzbz into res FROM public.tbl_studiensemester WHERE start<=now() AND ende>=now() ORDER BY start DESC LIMIT 1;
|
||||
return res;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION LastActiveSemester() RETURNS varchar(32) AS $$
|
||||
DECLARE res varchar(32);
|
||||
BEGIN
|
||||
SELECT studiensemester_kurzbz into res FROM public.tbl_studiensemester WHERE start<=now() ORDER BY start DESC LIMIT 1;
|
||||
return res;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- ==== studiengang_5 ====
|
||||
-- Demodaten fuer Studiengang 5: Organisationseinheit, Studienordnung, Studiengang, Lehrveranstaltungen
|
||||
|
||||
INSERT INTO public.tbl_organisationseinheit (oe_kurzbz, oe_parent_kurzbz, bezeichnung, organisationseinheittyp_kurzbz, aktiv, mailverteiler, freigabegrenze, kurzzeichen, lehre, standort, warn_semesterstunden_frei, warn_semesterstunden_fix, standort_id)
|
||||
VALUES('stg5', 'studiengaenge', 'Studiengang 5', 'Studiengang', true, false, NULL, NULL, true, NULL, NULL, NULL, NULL);
|
||||
|
||||
INSERT INTO public.tbl_studiengang (studiengang_kz,kurzbz,kurzbzlang,typ,bezeichnung,english,farbe,email,telefon,max_semester,max_verband,max_gruppe,erhalter_kz,bescheid,bescheidbgbl1,bescheidbgbl2,bescheidgz,bescheidvom,titelbescheidvom,aktiv,ext_id,orgform_kurzbz,zusatzinfo_html,moodle,oe_kurzbz,sprache,testtool_sprachwahl,studienplaetze,lgartcode,mischform,projektarbeit_note_anzeige,melderelevant,foerderrelevant,standort_code,onlinebewerbung,melde_studiengang_kz) VALUES
|
||||
(5,'S5','STG5','b','Studiengang 5','Studiengang 5',NULL,'[email protected]',NULL,6,'B','2',5,NULL,NULL,NULL,NULL,NULL,NULL,true,NULL,'VZ','',true,'stg5','German',true,NULL,NULL,false,true,true,true,NULL,false,'0002')
|
||||
;
|
||||
|
||||
INSERT INTO "system".tbl_benutzerrolle
|
||||
(rolle_kurzbz, berechtigung_kurzbz, uid, funktion_kurzbz, oe_kurzbz, art, studiensemester_kurzbz, "start", ende, negativ, updateamum, updatevon, insertamum, insertvon, kostenstelle_id, anmerkung)
|
||||
VALUES('assistenz', NULL, 'demoassistenz', NULL, 'stg5', 'suid', NULL, '2025-09-18', NULL, false, NULL, NULL, '2025-09-18 15:46:02.000', 'demoadmin', NULL, NULL);
|
||||
|
||||
INSERT INTO public.tbl_lehrverband (studiengang_kz, semester, verband, gruppe, aktiv, bezeichnung, ext_id, orgform_kurzbz) VALUES
|
||||
(5, 1, '','', true, null, null, null),
|
||||
(5, 1, 'A','', true, null, null, null),
|
||||
(5, 1, 'B','', true, null, null, null),
|
||||
(5, 2, '','', true, null, null, null),
|
||||
(5, 2, 'A','', true, null, null, null),
|
||||
(5, 2, 'B','', true, null, null, null),
|
||||
(5, 3, '','', true, null, null, null),
|
||||
(5, 3, 'A','', true, null, null, null),
|
||||
(5, 3, 'B','', true, null, null, null),
|
||||
(5, 4, '','', true, null, null, null),
|
||||
(5, 4, 'A','', true, null, null, null),
|
||||
(5, 4, 'B','', true, null, null, null),
|
||||
(5, 5, '','', true, null, null, null),
|
||||
(5, 5, 'A','', true, null, null, null),
|
||||
(5, 5, 'B','', true, null, null, null),
|
||||
(5, 6, '','', true, null, null, null),
|
||||
(5, 6, 'A','', true, null, null, null),
|
||||
(5, 6, 'B','', true, null, null, null)
|
||||
;
|
||||
|
||||
INSERT INTO lehre.tbl_studienordnung (studienordnung_id, studiengang_kz,"version",gueltigvon,gueltigbis,bezeichnung,ects,studiengangbezeichnung,studiengangbezeichnung_englisch,studiengangkurzbzlang,akadgrad_id,insertamum,insertvon,updateamum,updatevon,ext_id,status_kurzbz,standort_id) VALUES
|
||||
(501, 5,'1','WS2023',NULL,'BS5-WS2023',180.00,'Studiengang 5','Studiengang 5','STG5',NULL,'2023-08-24 14:25:41.728215','auto',NULL,NULL,NULL,'development',NULL);
|
||||
|
||||
INSERT INTO lehre.tbl_studienplan (studienplan_id, studienordnung_id,orgform_kurzbz,"version",bezeichnung,regelstudiendauer,sprache,aktiv,semesterwochen,testtool_sprachwahl,insertamum,insertvon,updateamum,updatevon,ext_id,ects_stpl,pflicht_sws,pflicht_lvs,onlinebewerbung_studienplan) VALUES
|
||||
(5011, 501,'VZ','1','BS5-WS2023-VZ',6,NULL,true,15,true,'2023-08-24 14:26:12.751416','auto',NULL,NULL,NULL,180.00,NULL,NULL,true);
|
||||
|
||||
INSERT INTO lehre.tbl_lehrveranstaltung (lehrveranstaltung_id, kurzbz,bezeichnung,studiengang_kz,semester,sprache,ects,semesterstunden,anmerkung,lehre,lehreverzeichnis,aktiv,planfaktor,planlektoren,planpersonalkosten,plankostenprolektor,updateamum,updatevon,insertamum,insertvon,ext_id,sort,zeugnis,koordinator,projektarbeit,lehrform_kurzbz,bezeichnung_english,orgform_kurzbz,incoming,lehrmodus_kurzbz,lehrtyp_kurzbz,oe_kurzbz,raumtyp_kurzbz,anzahlsemester,semesterwochen,lvnr,farbe,old_lehrfach_id,semester_alternativ,sws,lvs,alvs,lvps,las,benotung,lvinfo,lehrauftrag,lehrveranstaltung_template_id) VALUES
|
||||
(511, 'MOD1.1','Sport',5,1,'German',8.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'MOD','Communication','VZ',5,'regulaer','modul','stg5',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
|
||||
(512, 'MOD1.2','Mathematik',5,1,'German',8.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'MOD','Mathematics','VZ',5,'regulaer','modul','stg5',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
|
||||
(513, 'MOD1.3','Grundlagen',5,1,'German',8.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'MOD','Basics','VZ',5,'regulaer','modul','stg5',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
|
||||
(514, 'MOD1.4','Sprachen',5,1,'English',6.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'MOD','Communication','VZ',5,'regulaer','modul','stg5',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
|
||||
(521, 'MOD2.1','Labor',5,2,'German',10.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'MOD','Laboratory','VZ',5,'regulaer','modul','stg5',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
|
||||
(522, 'MOD2.2','Mathematik',5,2,'German',10.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'MOD','Mathematics','VZ',5,'regulaer','modul','stg5',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
|
||||
(523, 'MOD2.3','Sprachen',5,2,'English',10.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'MOD','Communication','VZ',5,'regulaer','modul','stg5',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
|
||||
(531, 'MOD3.1','Mathematik',5,3,'German',15.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'MOD','Mathematics','VZ',5,'regulaer','modul','stg5',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
|
||||
(532, 'MOD3.2','Elektrische Signale',5,3,'German',15.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'MOD','Electric Signals','VZ',5,'regulaer','modul','stg5',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
|
||||
(541, 'MOD4.1','Messtechnik',5,4,'German',15.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'MOD','Measurement Technology','VZ',5,'regulaer','modul','stg5',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
|
||||
(542, 'MOD4.2','Forschung',5,4,'German',15.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'MOD','Research','VZ',5,'regulaer','modul','stg5',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
|
||||
(551, 'MOD5.1','Communication',5,5,'English',15.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'MOD','Communication','VZ',5,'regulaer','modul','stg5',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
|
||||
(552, 'MOD5.2','Regelungstechnik',5,5,'German',15.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'MOD','Control Engineering','VZ',5,'regulaer','modul','stg5',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
|
||||
(561, 'MOD6.1','Bachelorarbeit',5,6,'German',30.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'MOD','Bachelor Thesis','VZ',5,'regulaer','modul','stg5',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL)
|
||||
;
|
||||
|
||||
INSERT INTO lehre.tbl_lehrveranstaltung (lehrveranstaltung_id, kurzbz,bezeichnung,studiengang_kz,semester,sprache,ects,semesterstunden,anmerkung,lehre,lehreverzeichnis,aktiv,planfaktor,planlektoren,planpersonalkosten,plankostenprolektor,updateamum,updatevon,insertamum,insertvon,ext_id,sort,zeugnis,koordinator,projektarbeit,lehrform_kurzbz,bezeichnung_english,orgform_kurzbz,incoming,lehrmodus_kurzbz,lehrtyp_kurzbz,oe_kurzbz,raumtyp_kurzbz,anzahlsemester,semesterwochen,lvnr,farbe,old_lehrfach_id,semester_alternativ,sws,lvs,alvs,lvps,las,benotung,lvinfo,lehrauftrag,lehrveranstaltung_template_id) VALUES
|
||||
(5111, 'LS','Leistungssport',5,1,'German',8.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'VO','Communication','VZ',5,'regulaer','lv','kfSprachen',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,true,true,true,NULL),
|
||||
(5121, 'MAT','Mathematik 1',5,1,'German',8.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'ILV','Mathematics','VZ',5,'regulaer','lv','kfMath',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,true,true,true,NULL),
|
||||
(5131, 'GL','Grundlagen der Programmierung',5,1,'German',8.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'ILV','Development Basics','VZ',5,'regulaer','lv','kfTech',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,true,true,true,NULL),
|
||||
(5141, 'ENG','Englisch 1',5,1,'English',6.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'VO','Communication','VZ',5,'regulaer','lv','kfSprachen',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,true,true,true,NULL),
|
||||
(5211, 'EL','Elektronik Labor',5,2,'German',10.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'LAB','Laboratory','VZ',5,'regulaer','lv','kfTech',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,true,true,true,NULL),
|
||||
(5221, 'MAT','Mathematik 2',5,2,'German',10.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'ILV','Mathematics','VZ',5,'regulaer','lv','kfMath',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,true,true,true,NULL),
|
||||
(5231, 'ENG','Communcation Englisch',5,2,'English',10.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'VO','Communication','VZ',5,'regulaer','lv','kfSprachen',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,true,true,true,NULL),
|
||||
(5311, 'MAT','Mathematik 3',5,3,'German',15.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'ILV','Mathematics','VZ',5,'regulaer','lv','kfMath',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,true,true,true,NULL),
|
||||
(5321, 'ES','Elektrische Signale',5,3,'German',15.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'ILV','Electric Signals','VZ',5,'regulaer','lv','kfTech',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,true,true,true,NULL),
|
||||
(5411, 'MT','Messtechnik',5,4,'German',15.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'ILV','Measurement Technology','VZ',5,'regulaer','lv','kfTech',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,true,true,true,NULL),
|
||||
(5421, 'BAN','Bewegungsanalyse',5,4,'German',15.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'ILV','Movement Analytics','VZ',5,'regulaer','lv','kfTech',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,true,true,true,NULL),
|
||||
(5511, 'ENG','Communication',5,5,'English',15.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'VO','Communication','VZ',5,'regulaer','lv','kfSprachen',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,true,true,true,NULL),
|
||||
(5521, 'RT','Regelungstechnik',5,5,'German',15.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'ILV','Control Engineering','VZ',5,'regulaer','lv','kfTech',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,true,true,true,NULL),
|
||||
(5611, 'BA','Bachelorarbeit',5,6,'German',30.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','ma0646','2023-08-24 14:28:39','ma0646',NULL,NULL,true,NULL,false,'SE','Bachelor Thesis','VZ',5,'regulaer','lv','stg5',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,true,true,true,NULL)
|
||||
;
|
||||
|
||||
INSERT INTO lehre.tbl_studienplan_lehrveranstaltung (studienplan_lehrveranstaltung_id, studienplan_id,lehrveranstaltung_id,semester,studienplan_lehrveranstaltung_id_parent,pflicht,koordinator,insertamum,insertvon,updateamum,updatevon,sort,ext_id,curriculum,export,genehmigung) VALUES
|
||||
-- MODULES in the Studienplan
|
||||
(501, 5011,511,1,NULL,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
|
||||
(502, 5011,512,1,NULL,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
|
||||
(503, 5011,513,1,NULL,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
|
||||
(504, 5011,514,1,NULL,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
|
||||
(505, 5011,521,2,NULL,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
|
||||
(506, 5011,522,2,NULL,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
|
||||
(507, 5011,523,2,NULL,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
|
||||
(508, 5011,531,3,NULL,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
|
||||
(509, 5011,532,3,NULL,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
|
||||
(510, 5011,541,4,NULL,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
|
||||
(511, 5011,542,4,NULL,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
|
||||
(512, 5011,551,5,NULL,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
|
||||
(513, 5011,552,5,NULL,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
|
||||
(514, 5011,561,6,NULL,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
|
||||
|
||||
-- LVS zu Modulen
|
||||
(515, 5011,5111,1,501,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
|
||||
(516, 5011,5121,1,502,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
|
||||
(517, 5011,5131,1,503,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
|
||||
(518, 5011,5141,1,504,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
|
||||
(519, 5011,5211,2,505,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
|
||||
(520, 5011,5221,2,519,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
|
||||
(521, 5011,5231,2,507,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
|
||||
(522, 5011,5311,3,508,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
|
||||
(523, 5011,5321,3,509,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
|
||||
(524, 5011,5411,4,510,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
|
||||
(525, 5011,5421,4,511,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
|
||||
(526, 5011,5511,5,512,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
|
||||
(527, 5011,5521,5,513,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true),
|
||||
(528, 5011,5611,6,514,true,NULL,'2023-08-24 14:29:03.506117','ma0646',NULL,NULL,NULL,NULL,true,true,true)
|
||||
;
|
||||
|
||||
INSERT INTO lehre.tbl_studienplan_semester (studienplan_id, studiensemester_kurzbz, semester) VALUES
|
||||
(5011, 'WS2023', 1),
|
||||
(5011, 'SS2024', 2),
|
||||
(5011, 'WS2024', 1),
|
||||
(5011, 'WS2024', 3),
|
||||
(5011, 'SS2025', 2),
|
||||
(5011, 'SS2025', 4),
|
||||
(5011, 'WS2025', 1),
|
||||
(5011, 'WS2025', 3),
|
||||
(5011, 'WS2025', 5),
|
||||
(5011, 'SS2026', 2),
|
||||
(5011, 'SS2026', 4),
|
||||
(5011, 'SS2026', 6);
|
||||
|
||||
|
||||
INSERT INTO lehre.tbl_lehreinheit (lehreinheit_id, lehrveranstaltung_id, studiensemester_kurzbz, lehrfach_id_old, lehrform_kurzbz, stundenblockung, wochenrythmus, start_kw, raumtyp, raumtypalternativ, sprache, lehre, anmerkung, unr, lvnr, updateamum, updatevon, insertamum, insertvon, ext_id, lehrfach_id, gewicht)
|
||||
VALUES(51001, 5221, COALESCE(CurrentSemester(), LastActiveSemester()), NULL, 'VO', 2, 1, NULL, 'Dummy', 'Dummy', 'German', true, NULL, 5221, NULL, '2023-08-24 14:54:43.000', 'ma0646', '2023-08-24 14:54:43.000', 'ma0646', NULL, 5221, 1);
|
||||
|
||||
INSERT INTO lehre.tbl_lehreinheitgruppe
|
||||
(lehreinheit_id, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id)
|
||||
VALUES(51001, 5, 1, 'A', NULL, NULL, NULL, NULL, '2023-08-24 14:58:26.000', 'ma0646', NULL);
|
||||
|
||||
INSERT INTO lehre.tbl_lehreinheitgruppe
|
||||
(lehreinheit_id, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id)
|
||||
VALUES(51001, 5, 1, 'B', NULL, NULL, NULL, NULL, '2023-08-24 14:58:26.000', 'ma0646', NULL);
|
||||
|
||||
INSERT INTO lehre.tbl_lehreinheitmitarbeiter
|
||||
(lehreinheit_id, mitarbeiter_uid, lehrfunktion_kurzbz, planstunden, stundensatz, faktor, anmerkung, bismelden, updateamum, updatevon, insertamum, insertvon, ext_id, semesterstunden, standort_id, vertrag_id)
|
||||
VALUES(51001, 'demolektor1', 'Lektor', 0, NULL, 1.00, NULL, true, NULL, NULL, '2023-08-24 14:58:40.000', 'ma0646', NULL, 0.00, NULL, NULL);
|
||||
|
||||
INSERT INTO lehre.tbl_lehreinheit (lehreinheit_id, lehrveranstaltung_id, studiensemester_kurzbz, lehrfach_id_old, lehrform_kurzbz, stundenblockung, wochenrythmus, start_kw, raumtyp, raumtypalternativ, sprache, lehre, anmerkung, unr, lvnr, updateamum, updatevon, insertamum, insertvon, ext_id, lehrfach_id, gewicht)
|
||||
VALUES(51002, 5121, COALESCE(CurrentSemester(), LastActiveSemester()), NULL, 'VO', 2, 1, NULL, 'Dummy', 'Dummy', 'German', true, NULL, 5121, NULL, '2023-08-24 14:54:43.000', 'ma0646', '2023-08-24 14:54:43.000', 'ma0646', NULL, 5121, 1);
|
||||
|
||||
INSERT INTO lehre.tbl_lehreinheitgruppe
|
||||
(lehreinheit_id, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id)
|
||||
VALUES(51002, 5, 1, 'B', NULL, NULL, NULL, NULL, '2023-08-24 14:58:26.000', 'ma0646', NULL);
|
||||
|
||||
INSERT INTO lehre.tbl_lehreinheitmitarbeiter
|
||||
(lehreinheit_id, mitarbeiter_uid, lehrfunktion_kurzbz, planstunden, stundensatz, faktor, anmerkung, bismelden, updateamum, updatevon, insertamum, insertvon, ext_id, semesterstunden, standort_id, vertrag_id)
|
||||
VALUES(51002, 'demolektor2', 'Lektor', 0, NULL, 1.00, NULL, true, NULL, NULL, '2023-08-24 14:58:40.000', 'ma0646', NULL, 0.00, NULL, NULL);
|
||||
|
||||
INSERT INTO lehre.tbl_lehreinheit (lehreinheit_id, lehrveranstaltung_id, studiensemester_kurzbz, lehrfach_id_old, lehrform_kurzbz, stundenblockung, wochenrythmus, start_kw, raumtyp, raumtypalternativ, sprache, lehre, anmerkung, unr, lvnr, updateamum, updatevon, insertamum, insertvon, ext_id, lehrfach_id, gewicht)
|
||||
VALUES(51003, 5141, COALESCE(CurrentSemester(), LastActiveSemester()), NULL, 'VO', 2, 1, NULL, 'Dummy', 'Dummy', 'German', true, NULL, 5141, NULL, '2023-08-24 14:54:43.000', 'ma0646', '2023-08-24 14:54:43.000', 'ma0646', NULL, 5141, 1);
|
||||
|
||||
INSERT INTO lehre.tbl_lehreinheitgruppe
|
||||
(lehreinheit_id, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id)
|
||||
VALUES(51003, 5, 1, 'A', NULL, NULL, NULL, NULL, '2023-08-24 14:58:26.000', 'ma0646', NULL);
|
||||
|
||||
INSERT INTO lehre.tbl_lehreinheitmitarbeiter
|
||||
(lehreinheit_id, mitarbeiter_uid, lehrfunktion_kurzbz, planstunden, stundensatz, faktor, anmerkung, bismelden, updateamum, updatevon, insertamum, insertvon, ext_id, semesterstunden, standort_id, vertrag_id)
|
||||
VALUES(51003, 'demolektor3', 'Lektor', 0, NULL, 1.00, NULL, true, NULL, NULL, '2023-08-24 14:58:40.000', 'ma0646', NULL, 0.00, NULL, NULL);
|
||||
|
||||
INSERT INTO lehre.tbl_lehreinheit (lehreinheit_id, lehrveranstaltung_id, studiensemester_kurzbz, lehrfach_id_old, lehrform_kurzbz, stundenblockung, wochenrythmus, start_kw, raumtyp, raumtypalternativ, sprache, lehre, anmerkung, unr, lvnr, updateamum, updatevon, insertamum, insertvon, ext_id, lehrfach_id, gewicht)
|
||||
VALUES(51004, 5211, COALESCE(CurrentSemester(), LastActiveSemester()), NULL, 'VO', 2, 1, NULL, 'Dummy', 'Dummy', 'German', true, NULL, 5211, NULL, '2023-08-24 14:54:43.000', 'ma0646', '2023-08-24 14:54:43.000', 'ma0646', NULL, 5211, 1);
|
||||
|
||||
INSERT INTO lehre.tbl_lehreinheitgruppe
|
||||
(lehreinheit_id, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id)
|
||||
VALUES(51004, 5, 1, 'A', NULL, NULL, NULL, NULL, '2023-08-24 14:58:26.000', 'ma0646', NULL);
|
||||
|
||||
INSERT INTO lehre.tbl_lehreinheitmitarbeiter
|
||||
(lehreinheit_id, mitarbeiter_uid, lehrfunktion_kurzbz, planstunden, stundensatz, faktor, anmerkung, bismelden, updateamum, updatevon, insertamum, insertvon, ext_id, semesterstunden, standort_id, vertrag_id)
|
||||
VALUES(51004, 'demolektor4', 'Lektor', 0, NULL, 1.00, NULL, true, NULL, NULL, '2023-08-24 14:58:40.000', 'ma0646', NULL, 0.00, NULL, NULL);
|
||||
|
||||
INSERT INTO lehre.tbl_lehreinheit (lehreinheit_id, lehrveranstaltung_id, studiensemester_kurzbz, lehrfach_id_old, lehrform_kurzbz, stundenblockung, wochenrythmus, start_kw, raumtyp, raumtypalternativ, sprache, lehre, anmerkung, unr, lvnr, updateamum, updatevon, insertamum, insertvon, ext_id, lehrfach_id, gewicht)
|
||||
VALUES(51005, 5311, COALESCE(CurrentSemester(), LastActiveSemester()), NULL, 'VO', 2, 1, NULL, 'Dummy', 'Dummy', 'German', true, NULL, 5311, NULL, '2023-08-24 14:54:43.000', 'ma0646', '2023-08-24 14:54:43.000', 'ma0646', NULL, 5311, 1);
|
||||
|
||||
INSERT INTO lehre.tbl_lehreinheitgruppe
|
||||
(lehreinheit_id, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id)
|
||||
VALUES(51005, 5, 1, 'A', NULL, NULL, NULL, NULL, '2023-08-24 14:58:26.000', 'ma0646', NULL);
|
||||
|
||||
INSERT INTO lehre.tbl_lehreinheitmitarbeiter
|
||||
(lehreinheit_id, mitarbeiter_uid, lehrfunktion_kurzbz, planstunden, stundensatz, faktor, anmerkung, bismelden, updateamum, updatevon, insertamum, insertvon, ext_id, semesterstunden, standort_id, vertrag_id)
|
||||
VALUES(51005, 'demolektor4', 'Lektor', 0, NULL, 1.00, NULL, true, NULL, NULL, '2023-08-24 14:58:40.000', 'ma0646', NULL, 0.00, NULL, NULL);
|
||||
|
||||
INSERT INTO lehre.tbl_lehreinheit (lehreinheit_id, lehrveranstaltung_id, studiensemester_kurzbz, lehrfach_id_old, lehrform_kurzbz, stundenblockung, wochenrythmus, start_kw, raumtyp, raumtypalternativ, sprache, lehre, anmerkung, unr, lvnr, updateamum, updatevon, insertamum, insertvon, ext_id, lehrfach_id, gewicht)
|
||||
VALUES(51006, 5321, COALESCE(CurrentSemester(), LastActiveSemester()), NULL, 'VO', 2, 1, NULL, 'Dummy', 'Dummy', 'German', true, NULL, 5321, NULL, '2023-08-24 14:54:43.000', 'ma0646', '2023-08-24 14:54:43.000', 'ma0646', NULL, 5321, 1);
|
||||
|
||||
INSERT INTO lehre.tbl_lehreinheitgruppe
|
||||
(lehreinheit_id, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id)
|
||||
VALUES(51006, 5, 1, 'C', NULL, NULL, NULL, NULL, '2023-08-24 14:58:26.000', 'ma0646', NULL);
|
||||
|
||||
INSERT INTO lehre.tbl_lehreinheitmitarbeiter
|
||||
(lehreinheit_id, mitarbeiter_uid, lehrfunktion_kurzbz, planstunden, stundensatz, faktor, anmerkung, bismelden, updateamum, updatevon, insertamum, insertvon, ext_id, semesterstunden, standort_id, vertrag_id)
|
||||
VALUES(51006, 'demolektor1', 'Lektor', 0, NULL, 1.00, NULL, true, NULL, NULL, '2023-08-24 14:58:40.000', 'ma0646', NULL, 0.00, NULL, NULL);
|
||||
|
||||
-- Stundenplan Eintraege
|
||||
INSERT INTO lehre.tbl_stundenplandev
|
||||
(stundenplandev_id, lehreinheit_id, unr, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, mitarbeiter_uid, ort_kurzbz, datum, stunde, titel, anmerkung, fix, updateamum, updatevon, insertamum, insertvon)
|
||||
VALUES(510011, 51001, 51001, 5, 1, 'A', '', NULL, 'demolektor1', 'EG04', '2025-07-31', 7, NULL, NULL, false, '2025-07-31 13:50:49.419', 'demoadmin', '2025-07-31 13:50:49.419', NULL);
|
||||
|
||||
INSERT INTO lehre.tbl_stundenplan
|
||||
(stundenplan_id, lehreinheit_id, unr, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, mitarbeiter_uid, ort_kurzbz, datum, stunde, titel, anmerkung, fix, updateamum, updatevon, insertamum, insertvon)
|
||||
VALUES(510011, 51001, 51001, 5, 1, 'A', '', NULL, 'demolektor1', 'EG04', '2025-07-31', 7, NULL, NULL, false, '2025-07-31 13:50:49.419', 'demoadmin', '2025-07-31 13:50:49.419', NULL);
|
||||
|
||||
INSERT INTO lehre.tbl_stundenplandev
|
||||
(stundenplandev_id, lehreinheit_id, unr, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, mitarbeiter_uid, ort_kurzbz, datum, stunde, titel, anmerkung, fix, updateamum, updatevon, insertamum, insertvon)
|
||||
VALUES(510021, 31002, 31002, 5, 1, 'B', '', NULL, 'demolektor1', 'EG04', '2025-08-01', 7, NULL, NULL, false, '2025-07-31 13:50:49.419', 'demoadmin', '2025-07-31 13:50:49.419', NULL);
|
||||
|
||||
INSERT INTO lehre.tbl_stundenplan
|
||||
(stundenplan_id, lehreinheit_id, unr, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, mitarbeiter_uid, ort_kurzbz, datum, stunde, titel, anmerkung, fix, updateamum, updatevon, insertamum, insertvon)
|
||||
VALUES(510021, 31002, 31002, 5, 1, 'B', '', NULL, 'demolektor1', 'EG04', '2025-08-01', 7, NULL, NULL, false, '2025-07-31 13:50:49.419', 'demoadmin', '2025-07-31 13:50:49.419', NULL);
|
||||
|
||||
INSERT INTO public.tbl_studentlehrverband
|
||||
(student_uid, studiensemester_kurzbz, studiengang_kz, semester, verband, gruppe, updateamum, updatevon, insertamum, insertvon, ext_id)
|
||||
VALUES('s125b101', LastActiveSemester(), 5, 2, ' ', ' ', NULL, NULL, '2026-06-11 14:33:58.403', 'auto', NULL);
|
||||
|
||||
INSERT INTO public.tbl_gruppe (gruppe_kurzbz, studiengang_kz, semester, bezeichnung, beschreibung, sichtbar, lehre, aktiv, sort, mailgrp, generiert, updateamum, updatevon, insertamum, insertvon, ext_id, orgform_kurzbz, content_visible, gesperrt, direktinskription, zutrittssystem, aufnahmegruppe) VALUES
|
||||
('GRP_51003', 5, 2, 'BS5 2 ENG', null, false, false, true, NULL, false, false, now(), 'demoadmin', now(), 'demoadmin', NULL, NULL, true, false, true, false, false),
|
||||
('GRP_51004', 5, 2, 'BS5 2 EL', null, false, false, true, NULL, false, false, now(), 'demoadmin', now(), 'demoadmin', NULL, NULL, true, false, true, false, false)
|
||||
;
|
||||
|
||||
INSERT INTO lehre.tbl_lehreinheitgruppe
|
||||
(lehreinheit_id, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id)
|
||||
VALUES(51003, 5, 2, NULL, NULL, 'GRP_51003', NULL, NULL, '2023-08-24 14:58:26.000', 'ma0646', NULL);
|
||||
|
||||
INSERT INTO lehre.tbl_lehreinheitgruppe
|
||||
(lehreinheit_id, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id)
|
||||
VALUES(51004, 5, 2, NULL, NULL, 'GRP_51004', NULL, NULL, '2023-08-24 14:58:26.000', 'ma0646', NULL);
|
||||
|
||||
INSERT INTO public.tbl_benutzergruppe (uid, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id, studiensemester_kurzbz) VALUES
|
||||
('s125b101', 'GRP_51003', now(), 'demoadmin', now(), 'demoadmin', NULL, NULL),
|
||||
('s125b101', 'GRP_51004', now(), 'demoadmin', now(), 'demoadmin', NULL, NULL)
|
||||
;
|
||||
|
||||
-- ==== benotungstool_noten ====
|
||||
-- Demo data for the Benotungstool / Gesamtnoteneingabe test suite.
|
||||
--
|
||||
-- campus.vw_student_lehrveranstaltung, the source of getStudentsByLv(), links students to
|
||||
-- Lehreinheiten through tbl_lehreinheitgruppe.gruppe_kurzbz = tbl_benutzergruppe.gruppe_kurzbz AND
|
||||
-- a matching Studiensemester.
|
||||
--
|
||||
-- RESULT: demolektor1 -> LV 5221 (12 students), demolektor2 -> LV 5121 (6, the foreign LV for the
|
||||
-- access tests).
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Test semester: the running one, else the last started. Same rule as the suite.
|
||||
CREATE TEMP TABLE sem AS
|
||||
SELECT studiensemester_kurzbz AS kurzbz
|
||||
FROM public.tbl_studiensemester
|
||||
WHERE start <= now()
|
||||
ORDER BY (ende >= now()) DESC, start DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- The two LV fixtures. Own Lehreinheiten (511xx) so the tempus fixtures 51001-51006 stay untouched.
|
||||
-- members_up_to = highest person_id that joins this group.
|
||||
CREATE TEMP TABLE lv AS
|
||||
SELECT v.*, l.semester
|
||||
FROM (VALUES
|
||||
(true, 5221, 51101, 'GRP_CYNOTEN_H', 'Cypress Noten Haupt', 'demolektor1', 512),
|
||||
(false, 5121, 51102, 'GRP_CYNOTEN_F', 'Cypress Noten Fremd', 'demolektor2', 506)
|
||||
) AS v(main, lv_id, le_id, grp, grp_text, lektor, members_up_to)
|
||||
JOIN lehre.tbl_lehrveranstaltung l ON l.lehrveranstaltung_id = v.lv_id;
|
||||
|
||||
-- The 12 students. person_id is the anchor for every derived key.
|
||||
CREATE TEMP TABLE stud AS
|
||||
SELECT id AS person_id, 5000 + id AS prestudent_id, 's525b' || id AS uid
|
||||
FROM generate_series(501, 512) AS g(id);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM sem) THEN
|
||||
RAISE EXCEPTION 'No Studiensemester found, the base dump is missing.';
|
||||
END IF;
|
||||
IF (SELECT count(*) FROM lv) <> 2 THEN
|
||||
RAISE EXCEPTION 'LV 5221/5121 missing, apply group studiengang_5.';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Termin3 is absent from the base dump; without it every Termin3 insert hits a foreign key error.
|
||||
INSERT INTO lehre.tbl_pruefungstyp (pruefungstyp_kurzbz, beschreibung)
|
||||
VALUES ('Termin3', '3. Termin')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ------------------------------------------------------------------------------------- students
|
||||
INSERT INTO public.tbl_person (person_id, vorname, nachname, gebdatum, geschlecht, aktiv)
|
||||
SELECT person_id, 'Noten', 'Student ' || (person_id - 500), '2000-01-15', 'm', true
|
||||
FROM stud
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO public.tbl_prestudent (prestudent_id, person_id, studiengang_kz)
|
||||
SELECT prestudent_id, person_id, 5
|
||||
FROM stud
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO public.tbl_prestudentstatus
|
||||
(prestudent_id, status_kurzbz, studiensemester_kurzbz, ausbildungssemester)
|
||||
SELECT s.prestudent_id, 'Student', sem.kurzbz, lv.semester
|
||||
FROM stud s, sem, lv
|
||||
WHERE lv.main
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO public.tbl_benutzer (uid, person_id, aktiv)
|
||||
SELECT uid, person_id, true
|
||||
FROM stud
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- matrikelnr is UNIQUE; scheme of the existing seeders is '251000' + studiengang_kz + person_id
|
||||
INSERT INTO public.tbl_student
|
||||
(student_uid, matrikelnr, prestudent_id, studiengang_kz, semester, verband, gruppe)
|
||||
SELECT s.uid, '2510005' || s.person_id, s.prestudent_id, 5, lv.semester, '', ''
|
||||
FROM stud s, lv
|
||||
WHERE lv.main
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Lehrverband assignment; (5, <semester>, '', '') is created by group studiengang_5.
|
||||
INSERT INTO public.tbl_studentlehrverband
|
||||
(student_uid, studiensemester_kurzbz, studiengang_kz, semester, verband, gruppe)
|
||||
SELECT s.uid, sem.kurzbz, 5, lv.semester, '', ''
|
||||
FROM stud s, sem, lv
|
||||
WHERE lv.main
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ------------------------------------------------------------------- Lehreinheiten and Lektoren
|
||||
INSERT INTO lehre.tbl_lehreinheit
|
||||
(lehreinheit_id, lehrveranstaltung_id, studiensemester_kurzbz, lehrform_kurzbz,
|
||||
wochenrythmus, raumtyp, raumtypalternativ, sprache, lehrfach_id)
|
||||
SELECT lv.le_id, lv.lv_id, sem.kurzbz, 'VO', 1, 'Dummy', 'Dummy', 'German', lv.lv_id
|
||||
FROM lv, sem
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Fix the semester if the Lehreinheit is left over from a run in a different one.
|
||||
UPDATE lehre.tbl_lehreinheit le
|
||||
SET studiensemester_kurzbz = sem.kurzbz
|
||||
FROM lv, sem
|
||||
WHERE le.lehreinheit_id = lv.le_id
|
||||
AND le.studiensemester_kurzbz IS DISTINCT FROM sem.kurzbz;
|
||||
|
||||
INSERT INTO lehre.tbl_lehreinheitmitarbeiter (lehreinheit_id, mitarbeiter_uid, lehrfunktion_kurzbz)
|
||||
SELECT le_id, lektor, 'Lektor'
|
||||
FROM lv
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- --------------------------------------------------------------------------------------- groups
|
||||
INSERT INTO public.tbl_gruppe (gruppe_kurzbz, studiengang_kz, semester, bezeichnung, aktiv)
|
||||
SELECT grp, 5, semester, grp_text, true
|
||||
FROM lv
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- The row the existing seeders lack: attach the group to the Lehreinheit with gruppe_kurzbz set.
|
||||
-- The primary key is a serial, so NOT EXISTS instead of ON CONFLICT.
|
||||
INSERT INTO lehre.tbl_lehreinheitgruppe (lehreinheit_id, studiengang_kz, semester, gruppe_kurzbz)
|
||||
SELECT lv.le_id, 5, lv.semester, lv.grp
|
||||
FROM lv
|
||||
WHERE NOT EXISTS (SELECT 1 FROM lehre.tbl_lehreinheitgruppe g
|
||||
WHERE g.lehreinheit_id = lv.le_id AND g.gruppe_kurzbz = lv.grp);
|
||||
|
||||
-- Membership WITH a semester. The primary key is (uid, gruppe_kurzbz) and excludes the semester,
|
||||
-- so DO UPDATE repairs a row from an earlier run instead of duplicating it.
|
||||
INSERT INTO public.tbl_benutzergruppe (uid, gruppe_kurzbz, studiensemester_kurzbz)
|
||||
SELECT s.uid, lv.grp, sem.kurzbz
|
||||
FROM stud s, lv, sem
|
||||
WHERE s.person_id <= lv.members_up_to
|
||||
ON CONFLICT (uid, gruppe_kurzbz) DO UPDATE
|
||||
SET studiensemester_kurzbz = EXCLUDED.studiensemester_kurzbz;
|
||||
|
||||
-- -------------------------------------------------------------------------------- Notenschluessel
|
||||
INSERT INTO lehre.tbl_notenschluessel (notenschluessel_kurzbz, bezeichnung)
|
||||
VALUES ('CYNOTEN', 'Cypress Testnotenschluessel')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- getNote() takes the row with the largest punkte value <= the points given. The 0 threshold makes
|
||||
-- sure every value maps to a grade. Serial primary key, so NOT EXISTS.
|
||||
INSERT INTO lehre.tbl_notenschluesselaufteilung (notenschluessel_kurzbz, note, punkte)
|
||||
SELECT 'CYNOTEN', a.note, a.punkte
|
||||
FROM (VALUES (5, 0.0), (4, 51.0), (3, 64.0), (2, 77.0), (1, 90.0)) AS a(note, punkte)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM lehre.tbl_notenschluesselaufteilung x
|
||||
WHERE x.notenschluessel_kurzbz = 'CYNOTEN' AND x.punkte = a.punkte);
|
||||
|
||||
-- Set the semester explicitly: getKurzbzForLv() builds "... AND studiensemester_kurzbz = ? OR
|
||||
-- studiensemester_kurzbz IS NULL" without parentheses, so a NULL-semester assignment would apply
|
||||
-- to every Lehrveranstaltung through AND/OR precedence.
|
||||
INSERT INTO lehre.tbl_notenschluesselzuordnung
|
||||
(notenschluessel_kurzbz, lehrveranstaltung_id, studiensemester_kurzbz)
|
||||
SELECT 'CYNOTEN', lv.lv_id, sem.kurzbz
|
||||
FROM lv, sem
|
||||
WHERE lv.main
|
||||
AND NOT EXISTS (SELECT 1 FROM lehre.tbl_notenschluesselzuordnung z
|
||||
WHERE z.notenschluessel_kurzbz = 'CYNOTEN'
|
||||
AND z.lehrveranstaltung_id = lv.lv_id
|
||||
AND z.studiensemester_kurzbz = sem.kurzbz);
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- Fires if the view stays empty anyway, which is the bug this seeder exists to fix.
|
||||
DO $$
|
||||
DECLARE v_count integer;
|
||||
BEGIN
|
||||
SELECT count(*) INTO v_count
|
||||
FROM campus.vw_student_lehrveranstaltung v, sem
|
||||
WHERE v.lehrveranstaltung_id = 5221 AND v.studiensemester_kurzbz = sem.kurzbz;
|
||||
|
||||
IF v_count = 0 THEN
|
||||
RAISE EXCEPTION 'No students in LV 5221 (%), fixture unusable.', (SELECT kurzbz FROM sem);
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE 'OK: % students in LV 5221 (%)', v_count, (SELECT kurzbz FROM sem);
|
||||
END $$;
|
||||
|
||||
SELECT (SELECT kurzbz FROM sem) AS semester,
|
||||
(SELECT count(*) FROM campus.vw_student_lehrveranstaltung v, sem
|
||||
WHERE v.lehrveranstaltung_id = 5221 AND v.studiensemester_kurzbz = sem.kurzbz) AS lv5221,
|
||||
(SELECT count(*) FROM campus.vw_student_lehrveranstaltung v, sem
|
||||
WHERE v.lehrveranstaltung_id = 5121 AND v.studiensemester_kurzbz = sem.kurzbz) AS lv5121,
|
||||
(SELECT note FROM lehre.tbl_note WHERE bezeichnung = 'entschuldigt') AS entschuldigt,
|
||||
(SELECT note FROM lehre.tbl_note WHERE bezeichnung = 'Noch nicht eingetragen') AS noch_nicht,
|
||||
(SELECT count(*) FROM lehre.tbl_notenschluesselaufteilung
|
||||
WHERE notenschluessel_kurzbz = 'CYNOTEN') AS notenschluessel;
|
||||
|
||||
-- ==== schema_grants ====
|
||||
-- Full access to ALL application schemas for all DB roles.
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_grantee text;
|
||||
v_target text;
|
||||
v_schema text;
|
||||
v_grantees text[] := ARRAY['web', 'vilesci', 'wawi', 'admin', 'PUBLIC'];
|
||||
v_done integer := 0;
|
||||
BEGIN
|
||||
FOREACH v_grantee IN ARRAY v_grantees
|
||||
LOOP
|
||||
IF v_grantee = 'PUBLIC' THEN
|
||||
v_target := 'PUBLIC';
|
||||
ELSE
|
||||
CONTINUE WHEN NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = v_grantee);
|
||||
v_target := quote_ident(v_grantee);
|
||||
END IF;
|
||||
|
||||
FOR v_schema IN
|
||||
SELECT nspname
|
||||
FROM pg_namespace
|
||||
WHERE nspname NOT LIKE 'pg\_%' AND nspname <> 'information_schema'
|
||||
ORDER BY nspname
|
||||
LOOP
|
||||
EXECUTE format('GRANT ALL ON SCHEMA %I TO %s', v_schema, v_target);
|
||||
EXECUTE format('GRANT ALL ON ALL TABLES IN SCHEMA %I TO %s', v_schema, v_target);
|
||||
EXECUTE format('GRANT ALL ON ALL SEQUENCES IN SCHEMA %I TO %s', v_schema, v_target);
|
||||
EXECUTE format('GRANT ALL ON ALL FUNCTIONS IN SCHEMA %I TO %s', v_schema, v_target);
|
||||
|
||||
EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT ALL ON TABLES TO %s', v_schema, v_target);
|
||||
EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT ALL ON SEQUENCES TO %s', v_schema, v_target);
|
||||
EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT ALL ON FUNCTIONS TO %s', v_schema, v_target);
|
||||
|
||||
v_done := v_done + 1;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE 'Grants auf % Schema/Rollen-Kombinationen vergeben.', v_done;
|
||||
END $$;
|
||||
|
||||
-- ==== benotungstool_berechtigungen ====
|
||||
-- Benotungstool permissions for the demo accounts. Without them every endpoint answers 403.
|
||||
--
|
||||
-- demolektor1 teacher; no oe_kurzbz - assertLvAccess scopes him by the Lehreinheiten he teaches
|
||||
-- demoassistenz programme assistant for stg3 and stg5 (test courses 5221 and 5121 live in stg5)
|
||||
--
|
||||
-- Idempotent.
|
||||
|
||||
BEGIN;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM public.tbl_benutzer WHERE uid = 'demolektor1') THEN
|
||||
RAISE EXCEPTION 'Benutzer demolektor1 fehlt, die Basisdaten sind unvollstaendig.';
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM public.tbl_benutzer WHERE uid = 'demoassistenz') THEN
|
||||
RAISE EXCEPTION 'Benutzer demoassistenz fehlt, die Basisdaten sind unvollstaendig.';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- The Lektor: may open the Benotungstool, but sees only the LVs that this Lektor teaches.
|
||||
INSERT INTO system.tbl_benutzerrolle (uid, berechtigung_kurzbz, art, oe_kurzbz, insertvon, insertamum)
|
||||
SELECT 'demolektor1', 'lehre/benotungstool', 'suid', NULL, 'seeder', now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM system.tbl_benutzerrolle
|
||||
WHERE uid = 'demolektor1'
|
||||
AND berechtigung_kurzbz = 'lehre/benotungstool'
|
||||
AND oe_kurzbz IS NULL
|
||||
);
|
||||
|
||||
-- The Assistenz: may grade the LVs of its Studiengaenge.
|
||||
INSERT INTO system.tbl_benutzerrolle (uid, berechtigung_kurzbz, art, oe_kurzbz, insertvon, insertamum)
|
||||
SELECT 'demoassistenz', 'lehre/benotungstool_assistenz', 'suid', oe.oe_kurzbz, 'seeder', now()
|
||||
FROM (VALUES ('stg3'), ('stg5')) AS oe(oe_kurzbz)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM system.tbl_benutzerrolle b
|
||||
WHERE b.uid = 'demoassistenz'
|
||||
AND b.berechtigung_kurzbz = 'lehre/benotungstool_assistenz'
|
||||
AND b.oe_kurzbz = oe.oe_kurzbz
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- Undo:
|
||||
-- DELETE FROM system.tbl_benutzerrolle
|
||||
-- WHERE insertvon = 'seeder' AND berechtigung_kurzbz LIKE 'lehre/benotungstool%';
|
||||
|
||||
-- ==== benotungstool_fixture_erweitert ====
|
||||
-- Extensions of the Benotungstool fixture. Apply after group benotungstool_noten.
|
||||
--
|
||||
-- LE 51103 a second Lehreinheit of LV 5221 with two Lektoren and two students of its own. The write
|
||||
-- paths replace a foreign Lehreinheit, and the grader rules need a Lehreinheit with more
|
||||
-- than one Lektor. demoadmin sorts before demolektor1, so "the caller" and "the first
|
||||
-- Lektor" give different results.
|
||||
-- LE 51104 demolektor1 in LV 5221 in the last Sommersemester with a passed grade entry deadline.
|
||||
-- The deadline tests need a semester in which the user teaches.
|
||||
-- Vorlagen demo texts for 'Notenfreigabe' and 'Sancho_Mail_Template'. Without them every release
|
||||
-- mail has an empty body. The dbupdate script creates only the tbl_vorlage row.
|
||||
--
|
||||
-- The new students sort after 'Student 1'..'Student 12', so the first twelve students of the suite
|
||||
-- stay the same. Idempotent.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- group benotungstool_noten creates temp tables with the same names when both groups run in one session
|
||||
DROP TABLE IF EXISTS pg_temp.sem, pg_temp.sem_frist, pg_temp.lv, pg_temp.stud;
|
||||
|
||||
-- Test semester: the running one, else the last started. Same rule as the suite and group benotungstool_noten.
|
||||
CREATE TEMP TABLE sem AS
|
||||
SELECT studiensemester_kurzbz AS kurzbz
|
||||
FROM public.tbl_studiensemester
|
||||
WHERE start <= now()
|
||||
ORDER BY (ende >= now()) DESC, start DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- The last Sommersemester after its deadline. NOTENEINTRAGUNGSFRIST_SS defaults to 15 November.
|
||||
CREATE TEMP TABLE sem_frist AS
|
||||
SELECT studiensemester_kurzbz AS kurzbz
|
||||
FROM public.tbl_studiensemester
|
||||
WHERE studiensemester_kurzbz ~ '^SS[0-9]{4}$'
|
||||
AND make_date(substring(studiensemester_kurzbz FROM 3 FOR 4)::int, 11, 15) < current_date
|
||||
ORDER BY start DESC
|
||||
LIMIT 1;
|
||||
|
||||
CREATE TEMP TABLE lv AS
|
||||
SELECT lehrveranstaltung_id AS lv_id, semester
|
||||
FROM lehre.tbl_lehrveranstaltung
|
||||
WHERE lehrveranstaltung_id = 5221;
|
||||
|
||||
CREATE TEMP TABLE stud AS
|
||||
SELECT id AS person_id, 5000 + id AS prestudent_id, 's525b' || id AS uid, vorname
|
||||
FROM (VALUES (513, 'Eins'), (514, 'Zwei')) AS g(id, vorname);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM sem) THEN
|
||||
RAISE EXCEPTION 'No Studiensemester found, the base dump is missing.';
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM sem_frist) THEN
|
||||
RAISE EXCEPTION 'No Sommersemester with a passed grade entry deadline.';
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM lv) THEN
|
||||
RAISE EXCEPTION 'LV 5221 missing, apply group studiengang_5.';
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM lehre.tbl_lehreinheit WHERE lehreinheit_id = 51101) THEN
|
||||
RAISE EXCEPTION 'Lehreinheit 51101 missing, apply group benotungstool_noten first.';
|
||||
END IF;
|
||||
IF (SELECT count(*) FROM public.tbl_mitarbeiter WHERE mitarbeiter_uid IN ('demoadmin', 'demolektor1')) <> 2 THEN
|
||||
RAISE EXCEPTION 'demoadmin or demolektor1 missing, apply BS002_mitarbeiter.sql.';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ------------------------------------------------------------------------------------- students
|
||||
INSERT INTO public.tbl_person (person_id, vorname, nachname, gebdatum, geschlecht, aktiv)
|
||||
SELECT person_id, vorname, 'Zweitgruppe', '2000-01-15', 'm', true
|
||||
FROM stud
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO public.tbl_prestudent (prestudent_id, person_id, studiengang_kz)
|
||||
SELECT prestudent_id, person_id, 5
|
||||
FROM stud
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO public.tbl_prestudentstatus
|
||||
(prestudent_id, status_kurzbz, studiensemester_kurzbz, ausbildungssemester)
|
||||
SELECT s.prestudent_id, 'Student', sem.kurzbz, lv.semester
|
||||
FROM stud s, sem, lv
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO public.tbl_benutzer (uid, person_id, aktiv)
|
||||
SELECT uid, person_id, true
|
||||
FROM stud
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO public.tbl_student
|
||||
(student_uid, matrikelnr, prestudent_id, studiengang_kz, semester, verband, gruppe)
|
||||
SELECT s.uid, '2510005' || s.person_id, s.prestudent_id, 5, lv.semester, '', ''
|
||||
FROM stud s, lv
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO public.tbl_studentlehrverband
|
||||
(student_uid, studiensemester_kurzbz, studiengang_kz, semester, verband, gruppe)
|
||||
SELECT s.uid, sem.kurzbz, 5, lv.semester, '', ''
|
||||
FROM stud s, sem, lv
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ------------------------------------------------------------------------------ Lehreinheit 51103
|
||||
INSERT INTO lehre.tbl_lehreinheit
|
||||
(lehreinheit_id, lehrveranstaltung_id, studiensemester_kurzbz, lehrform_kurzbz,
|
||||
wochenrythmus, raumtyp, raumtypalternativ, sprache, lehrfach_id)
|
||||
SELECT 51103, lv.lv_id, sem.kurzbz, 'VO', 1, 'Dummy', 'Dummy', 'German', lv.lv_id
|
||||
FROM lv, sem
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
UPDATE lehre.tbl_lehreinheit le
|
||||
SET studiensemester_kurzbz = sem.kurzbz
|
||||
FROM sem
|
||||
WHERE le.lehreinheit_id = 51103
|
||||
AND le.studiensemester_kurzbz IS DISTINCT FROM sem.kurzbz;
|
||||
|
||||
INSERT INTO lehre.tbl_lehreinheitmitarbeiter (lehreinheit_id, mitarbeiter_uid, lehrfunktion_kurzbz)
|
||||
SELECT 51103, l.uid, 'Lektor'
|
||||
FROM (VALUES ('demoadmin'), ('demolektor1')) AS l(uid)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO public.tbl_gruppe (gruppe_kurzbz, studiengang_kz, semester, bezeichnung, aktiv)
|
||||
SELECT 'GRP_CYNOTEN_Z', 5, semester, 'Cypress Noten Zweitgruppe', true
|
||||
FROM lv
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO lehre.tbl_lehreinheitgruppe (lehreinheit_id, studiengang_kz, semester, gruppe_kurzbz)
|
||||
SELECT 51103, 5, lv.semester, 'GRP_CYNOTEN_Z'
|
||||
FROM lv
|
||||
WHERE NOT EXISTS (SELECT 1 FROM lehre.tbl_lehreinheitgruppe g
|
||||
WHERE g.lehreinheit_id = 51103 AND g.gruppe_kurzbz = 'GRP_CYNOTEN_Z');
|
||||
|
||||
-- the students of 51103 are members of this group only, so each has exactly one Lehreinheit
|
||||
INSERT INTO public.tbl_benutzergruppe (uid, gruppe_kurzbz, studiensemester_kurzbz)
|
||||
SELECT s.uid, 'GRP_CYNOTEN_Z', sem.kurzbz
|
||||
FROM stud s, sem
|
||||
ON CONFLICT (uid, gruppe_kurzbz) DO UPDATE
|
||||
SET studiensemester_kurzbz = EXCLUDED.studiensemester_kurzbz;
|
||||
|
||||
-- ------------------------------------------------------------------------------ Lehreinheit 51104
|
||||
-- getLvForLektorInSemester needs no group, only the Lehreinheit and its Lektor
|
||||
INSERT INTO lehre.tbl_lehreinheit
|
||||
(lehreinheit_id, lehrveranstaltung_id, studiensemester_kurzbz, lehrform_kurzbz,
|
||||
wochenrythmus, raumtyp, raumtypalternativ, sprache, lehrfach_id)
|
||||
SELECT 51104, lv.lv_id, sf.kurzbz, 'VO', 1, 'Dummy', 'Dummy', 'German', lv.lv_id
|
||||
FROM lv, sem_frist sf
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
UPDATE lehre.tbl_lehreinheit le
|
||||
SET studiensemester_kurzbz = sf.kurzbz
|
||||
FROM sem_frist sf
|
||||
WHERE le.lehreinheit_id = 51104
|
||||
AND le.studiensemester_kurzbz IS DISTINCT FROM sf.kurzbz;
|
||||
|
||||
INSERT INTO lehre.tbl_lehreinheitmitarbeiter (lehreinheit_id, mitarbeiter_uid, lehrfunktion_kurzbz)
|
||||
VALUES (51104, 'demolektor1', 'Lektor')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ---------------------------------------------------------------------------------------- Vorlagen
|
||||
INSERT INTO public.tbl_vorlage (vorlage_kurzbz, bezeichnung, anmerkung, mimetype)
|
||||
VALUES ('Notenfreigabe', 'Notenfreigabe', NULL, 'text/html'),
|
||||
('Sancho_Mail_Template', 'Sancho Mail Template', 'Demo layout of the test instance', 'text/html')
|
||||
ON CONFLICT (vorlage_kurzbz) DO NOTHING;
|
||||
|
||||
-- Placeholders of Noten::sendFreigabeEmail: lektor, lvaname, neuenotencount, studlist, adressen.
|
||||
-- An existing active text stays. Studiengang 0 and oe 'etw' are the root, as for the other Vorlagen.
|
||||
INSERT INTO public.tbl_vorlagestudiengang
|
||||
(vorlage_kurzbz, studiengang_kz, version, text, oe_kurzbz, aktiv, insertamum, insertvon)
|
||||
SELECT v.kurzbz, 0, 1, v.text, 'etw', true, now(), 'seeder'
|
||||
FROM (VALUES
|
||||
('Notenfreigabe',
|
||||
'<p>{lektor} hat {neuenotencount} Note(n) freigegeben: {lvaname}</p>{studlist}<p>Empfänger: {adressen}</p>'),
|
||||
('Sancho_Mail_Template', '<html><body>{content}</body></html>')
|
||||
) AS v(kurzbz, text)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.tbl_vorlagestudiengang x
|
||||
WHERE x.vorlage_kurzbz = v.kurzbz AND x.aktiv);
|
||||
|
||||
COMMIT;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF (SELECT count(*) FROM campus.vw_student_lehrveranstaltung v, sem
|
||||
WHERE v.lehreinheit_id = 51103 AND v.studiensemester_kurzbz = sem.kurzbz) = 0 THEN
|
||||
RAISE EXCEPTION 'No students in Lehreinheit 51103 (%), fixture unusable.', (SELECT kurzbz FROM sem);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
SELECT (SELECT kurzbz FROM sem) AS semester,
|
||||
(SELECT kurzbz FROM sem_frist) AS semester_frist,
|
||||
(SELECT count(*) FROM campus.vw_student_lehrveranstaltung v, sem
|
||||
WHERE v.lehreinheit_id = 51103 AND v.studiensemester_kurzbz = sem.kurzbz) AS le51103_studenten,
|
||||
(SELECT count(*) FROM lehre.tbl_lehreinheitmitarbeiter
|
||||
WHERE lehreinheit_id = 51103) AS le51103_lektoren,
|
||||
(SELECT count(*) FROM public.tbl_vorlagestudiengang
|
||||
WHERE vorlage_kurzbz IN ('Notenfreigabe', 'Sancho_Mail_Template') AND aktiv) AS vorlagen;
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copy to tests/cypress/.env (not in Git) and fill in.
|
||||
# Only the keys that all suites share. The noten suite keeps its keys in tests/cypress/suites/.env.
|
||||
|
||||
# The instance under test. There is no default: an empty value stops the run.
|
||||
BASE_URL=
|
||||
|
||||
# A login for Cypress.env("adminusername") / ("adminpassword"). The noten suite does not use it.
|
||||
USER_NAME=
|
||||
USER_PASSWORD=
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Access control: the scope of `assertLvAccess`.
|
||||
*
|
||||
* The Lektor tests use an LV of the semester that NOTEN_LEKTOR_USER does not teach (readForeignLv).
|
||||
*/
|
||||
|
||||
import { apiGet, notenApi, lektorAuth, loginAsLektor } from "../../../../support/api/notenApi";
|
||||
import { expectAuthError, expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
|
||||
import {
|
||||
antrittDate,
|
||||
loadNotenContext,
|
||||
readAuthUid,
|
||||
readLvGesamtnoteViaDb,
|
||||
requireDbReset,
|
||||
resetNotenState,
|
||||
seedBaseline,
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
import { requireConfig, skipIf } from "../../../../support/helpers/notenConfig";
|
||||
import {
|
||||
assistenzAuth,
|
||||
assistenzContext,
|
||||
assistenzLvWithLehreinheiten,
|
||||
requireAssistenz,
|
||||
} from "../../../../support/helpers/notenAssistenz";
|
||||
|
||||
describe("Noten API - Zugriffsschutz", () => {
|
||||
let ctx;
|
||||
|
||||
before(() => {
|
||||
loadNotenContext().then((loaded) => {
|
||||
ctx = loaded;
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => loginAsLektor());
|
||||
|
||||
it("lehnt eine Anfrage ohne Anmeldung ab", () => {
|
||||
cy.clearAllCookies(); // otherwise the previous login is still active
|
||||
|
||||
apiGet("getCisConfig", undefined, null).then((response) => expectAuthError(response));
|
||||
|
||||
apiGet("getCisConfig", undefined, { username: "no-such-user", password: "no-such-password" }).then((response) =>
|
||||
expectAuthError(response),
|
||||
);
|
||||
});
|
||||
|
||||
describe("der konfigurierte API-Benutzer", () => {
|
||||
let lvWithoutStudents = null;
|
||||
|
||||
before(() => {
|
||||
readAuthUid()
|
||||
.then((uid) => cy.task("noten:db:readLvWithoutStudents", { uid }))
|
||||
.then((found) => {
|
||||
lvWithoutStudents = found;
|
||||
});
|
||||
});
|
||||
|
||||
it("liest die Studierenden der Test-LV", () => {
|
||||
notenApi.getStudentenNoten(ctx.lvId, ctx.semKurzbz).then((response) => {
|
||||
const data = expectNotenSuccess(response, "getStudentenNoten auf der eigenen LV");
|
||||
expect(data.students, "Liste der Studierenden").to.be.an("array").and.not.be.empty;
|
||||
});
|
||||
});
|
||||
|
||||
it("liefert für eine LV ohne Studierende eine leere Liste", function () {
|
||||
skipIf(this, !lvWithoutStudents, "Übersprungen: NOTEN_LEKTOR_USER unterrichtet keine LV ohne Studierende.");
|
||||
|
||||
notenApi.getStudentenNoten(lvWithoutStudents.lvId, lvWithoutStudents.semKurzbz).then((response) => {
|
||||
const data = expectNotenSuccess(response, "getStudentenNoten für eine LV ohne Studierende");
|
||||
expect(data.students, "Liste der Studierenden").to.be.an("array").that.is.empty;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scope der Lehrperson", () => {
|
||||
let foreignLvId = null;
|
||||
|
||||
before(() => {
|
||||
readAuthUid()
|
||||
.then((uid) => cy.task("noten:db:readForeignLv", { semKurzbz: ctx.semKurzbz, uid }))
|
||||
.then((lvId) => {
|
||||
foreignLvId = lvId;
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_LEKTOR_NUR_EIGENE_LV", true);
|
||||
skipIf(
|
||||
this,
|
||||
!foreignLvId,
|
||||
"Übersprungen: das Semester hat keine LV, die NOTEN_LEKTOR_USER nicht unterrichtet.",
|
||||
);
|
||||
});
|
||||
|
||||
// assertLvAccess denies through terminateWithError -> 500 + phrase, not a 401.
|
||||
it("verweigert einer Lehrperson eine fremde LV", () => {
|
||||
notenApi
|
||||
.getStudentenNoten(foreignLvId, ctx.semKurzbz)
|
||||
.then((response) => expectNotenError(response, "keineBerechtigungNoten"));
|
||||
});
|
||||
|
||||
it("verweigert einer Lehrperson die Note in einer fremden LV", () => {
|
||||
notenApi
|
||||
.saveLvNote(foreignLvId, ctx.semKurzbz, ctx.students[0].uid, ctx.notenScale[0])
|
||||
.then((response) => expectNotenError(response, "keineBerechtigungNoten"));
|
||||
});
|
||||
|
||||
it("verweigert einer Lehrperson die Lehreinheiten einer fremden LV", () => {
|
||||
notenApi
|
||||
.getLehreinheitenForLv(foreignLvId, ctx.semKurzbz)
|
||||
.then((response) => expectNotenError(response, "keineBerechtigungNoten"));
|
||||
});
|
||||
});
|
||||
|
||||
// getLvForStudiengang is NOT tested here: for an admin isBerechtigt('admin') applies, and every
|
||||
// call succeeds. It needs a non-admin login.
|
||||
describe("getBenotungstoolContext als Assistenz", () => {
|
||||
beforeEach(function () {
|
||||
requireAssistenz(this);
|
||||
cy.clearAllCookies();
|
||||
});
|
||||
|
||||
it("liefert die berechtigten Studiengänge", () => {
|
||||
assistenzContext(ctx.semKurzbz).then((result) => {
|
||||
expect(result, "ein Semester mit Studiengängen der Assistenz").to.not.be.null;
|
||||
expect(result.data.isAssistenz, "isAssistenz").to.be.true;
|
||||
expect(result.data.studiengaenge, "studiengaenge").to.be.an("array").and.not.be.empty;
|
||||
});
|
||||
});
|
||||
|
||||
it("ignoriert eine lv_id, die nicht nur aus Ziffern besteht", () => {
|
||||
apiGet(
|
||||
"getBenotungstoolContext",
|
||||
{ sem_kurzbz: ctx.semKurzbz, lv_id: ctx.semKurzbz },
|
||||
assistenzAuth(),
|
||||
).then((response) => {
|
||||
const data = expectNotenSuccess(response, "getBenotungstoolContext mit dem Semester als lv_id");
|
||||
expect(data.preselectStudiengang_kz, "keine Vorauswahl").to.be.null;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// An empty or non-numeric lv_id would extend the access check to all LVs.
|
||||
describe("Parameter der Zugriffsprüfung", () => {
|
||||
const s0 = () => ctx.students[0];
|
||||
|
||||
// saveFreigabe is left out: it checks the password first and sends a mail.
|
||||
const endpoints = {
|
||||
getStudentenNoten: (lvId, semKurzbz) => notenApi.getStudentenNoten(lvId, semKurzbz),
|
||||
getLehreinheitenForLv: (lvId, semKurzbz) => notenApi.getLehreinheitenForLv(lvId, semKurzbz),
|
||||
getNoteByPunkte: (lvId, semKurzbz) => notenApi.getNoteByPunkte(lvId, semKurzbz, 50),
|
||||
saveLvNote: (lvId, semKurzbz) => notenApi.saveLvNote(lvId, semKurzbz, s0().uid, ctx.noten.negativ),
|
||||
importLvNoten: (lvId, semKurzbz) => notenApi.importLvNoten(lvId, semKurzbz, []),
|
||||
savePruefung: (lvId, semKurzbz) =>
|
||||
notenApi.savePruefung(lvId, semKurzbz, s0().uid, {
|
||||
note: ctx.noten.negativ,
|
||||
datum: antrittDate(ctx, 1),
|
||||
}),
|
||||
// createPruefungen checks the students before the access check
|
||||
createPruefungen: (lvId, semKurzbz) =>
|
||||
notenApi.createPruefungen(lvId, semKurzbz, [{ uid: s0().uid }], { datum: antrittDate(ctx, 1) }),
|
||||
importPruefungen: (lvId, semKurzbz) => notenApi.importPruefungen(lvId, semKurzbz, []),
|
||||
};
|
||||
|
||||
Object.entries(endpoints).forEach(([name, call]) => {
|
||||
[null, 0, "", "abc", "1abc", -1].forEach((lvId) => {
|
||||
it(`${name} lehnt lv_id=${JSON.stringify(lvId)} ab`, () => {
|
||||
call(lvId, ctx.semKurzbz).then((response) => expectNotenError(response, "wrongParameters"));
|
||||
});
|
||||
});
|
||||
|
||||
[null, "", " "].forEach((semKurzbz) => {
|
||||
it(`${name} lehnt sem_kurzbz=${JSON.stringify(semKurzbz)} ab`, () => {
|
||||
call(ctx.lvId, semKurzbz).then((response) => expectNotenError(response, "wrongParameters"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ohne LV-Filter", () => {
|
||||
let student = null;
|
||||
|
||||
before(() => {
|
||||
requireDbReset();
|
||||
|
||||
const search = (i) =>
|
||||
i >= ctx.students.length
|
||||
? cy.wrap(null, { log: false })
|
||||
: cy
|
||||
.task("noten:db:countOtherLvNoten", {
|
||||
lvId: ctx.lvId,
|
||||
semKurzbz: ctx.semKurzbz,
|
||||
studentUid: ctx.students[i].uid,
|
||||
})
|
||||
.then((count) => (count === 0 ? ctx.students[i] : search(i + 1)));
|
||||
|
||||
search(0).then((found) => {
|
||||
student = found;
|
||||
});
|
||||
});
|
||||
|
||||
it("ändert bei lv_id=null keine LV-Note", function () {
|
||||
skipIf(this, !student, "Übersprungen: jeder Studierende hat LV-Noten in anderen LVs.");
|
||||
|
||||
const g2 = ctx.notenScale.find((n) => String(n) !== String(ctx.noten.negativ));
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student);
|
||||
|
||||
notenApi
|
||||
.saveLvNote(null, ctx.semKurzbz, student.uid, g2)
|
||||
.then((response) => expectNotenError(response, "wrongParameters"));
|
||||
|
||||
readLvGesamtnoteViaDb(ctx, student).then((row) => {
|
||||
expect(String(row.note), "die LV-Note bleibt").to.eq(String(ctx.noten.negativ));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// The endpoint returns all Lehreinheiten of the LV, for every role with access to the LV.
|
||||
describe("getLehreinheitenForLv", () => {
|
||||
const lehreinheitenOfLv = (lvId) =>
|
||||
cy
|
||||
.task("noten:db:readLehreinheitenOfLv", { lvId, semKurzbz: ctx.semKurzbz })
|
||||
.then((ids) => ids.map(String).sort());
|
||||
|
||||
// The query returns one row per group, so a Lehreinheit can appear more than once
|
||||
const idsOf = (rows) => [...new Set(rows.map((r) => String(r.lehreinheit_id)))].sort();
|
||||
|
||||
before(() => requireDbReset());
|
||||
|
||||
it("liefert dieselben Lehreinheiten wie die Datenbank", () => {
|
||||
notenApi.getLehreinheitenForLv(ctx.lvId, ctx.semKurzbz).then((response) => {
|
||||
const rows = expectNotenSuccess(response, "getLehreinheitenForLv");
|
||||
expect(rows, "Lehreinheiten").to.be.an("array").and.not.be.empty;
|
||||
expect(rows[0], "Felder einer Lehreinheit").to.include.all.keys(
|
||||
"lehreinheit_id",
|
||||
"lehrveranstaltung_id",
|
||||
"lehrform_kurzbz",
|
||||
"direktinskription",
|
||||
"semester",
|
||||
"verband",
|
||||
"gruppe",
|
||||
"gruppe_kurzbz",
|
||||
"kurzbz",
|
||||
"kurzbzlang",
|
||||
"termincount",
|
||||
"studentcount",
|
||||
);
|
||||
|
||||
lehreinheitenOfLv(ctx.lvId).then((ids) => {
|
||||
expect(idsOf(rows), "lehreinheit_id").to.deep.eq(ids);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("liefert einer Assistenz die Lehreinheiten einer LV ihres Studiengangs", function () {
|
||||
requireAssistenz(this);
|
||||
cy.clearAllCookies();
|
||||
|
||||
assistenzLvWithLehreinheiten(ctx.semKurzbz).then((target) => {
|
||||
expect(target, "eine LV der Assistenz mit Lehreinheiten").to.not.be.null;
|
||||
|
||||
apiGet(
|
||||
"getLehreinheitenForLv",
|
||||
{ lv_id: target.lvId, sem_kurzbz: target.semKurzbz },
|
||||
assistenzAuth(),
|
||||
).then((response) => {
|
||||
const rows = expectNotenSuccess(response, "getLehreinheitenForLv als Assistenz");
|
||||
expect(idsOf(rows), "lehreinheit_id").to.deep.eq(target.lehreinheiten);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("ersetzt den alten Endpunkt Lehre/getLeForLv", () => {
|
||||
cy.request({
|
||||
method: "GET",
|
||||
url: "/index.ci.php/api/frontend/v1/Lehre/getLeForLv",
|
||||
auth: lektorAuth(),
|
||||
failOnStatusCode: false,
|
||||
}).then((response) => {
|
||||
expect(response.status, "Status von Lehre/getLeForLv").to.not.eq(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,422 @@
|
||||
/**
|
||||
* Pruefungsordnung §1: the Antritte.
|
||||
*
|
||||
* The server checks the rules in this order: A (maximum) -> B (date order) -> C (occurrence limit).
|
||||
* Each test builds a state in which only one rule can apply; otherwise an earlier rule answers first.
|
||||
*
|
||||
* The NOTE decides what counts: "entschuldigt" and "Noch nicht eingetragen" never count.
|
||||
* Antritt 1 is a real Pruefung row (givenBaseline seeds it).
|
||||
*/
|
||||
|
||||
import { notenApi, loginAsLektor } from "../../../../support/api/notenApi";
|
||||
import { expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
|
||||
import {
|
||||
requireKommissionellerAntritt,
|
||||
requireConfig,
|
||||
requireRepeat,
|
||||
skipIf,
|
||||
} from "../../../../support/helpers/notenConfig";
|
||||
import {
|
||||
antrittDate,
|
||||
baselineDate,
|
||||
loadNotenContext,
|
||||
requireDbReset,
|
||||
seedPruefung,
|
||||
seedZeugnisnote,
|
||||
shiftDate,
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
import {
|
||||
addPruefung,
|
||||
pruefungenOf,
|
||||
antritteOf,
|
||||
editPruefung,
|
||||
givenBaseline,
|
||||
readStateViaApi,
|
||||
verlaufOf,
|
||||
} from "../../../../support/helpers/notenScenario";
|
||||
|
||||
describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
|
||||
let ctx;
|
||||
|
||||
before(() => {
|
||||
requireDbReset();
|
||||
loadNotenContext().then((loaded) => {
|
||||
ctx = loaded;
|
||||
|
||||
cy.log(
|
||||
`LV ${ctx.lvId} / ${ctx.semKurzbz} | maxAntritte=${ctx.maxAntritte} | ` +
|
||||
`entschuldigt=${ctx.noten.entschuldigt}`,
|
||||
);
|
||||
|
||||
expect(ctx.maxAntritte, "Platz für mindestens eine Wiederholung nach Antritt 1").to.be.greaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => loginAsLektor());
|
||||
|
||||
/** Skips if the last Antritt is not the kommissionelle Pruefung. */
|
||||
const skipUnlessLastKommissionell = (test) =>
|
||||
skipIf(
|
||||
test,
|
||||
ctx.cisConfig.CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT !== ctx.maxAntritte,
|
||||
"Übersprungen: der letzte Antritt ist nicht kommissionell.",
|
||||
);
|
||||
|
||||
/** Adds counting Antritte up to maxAntritte. The baseline already has Antritt 1. */
|
||||
const fillToCap = (student, firstIndex = 1) => {
|
||||
for (let i = 0; i < ctx.maxAntritte - 1; i += 1) {
|
||||
addPruefung(ctx, student, {
|
||||
note: ctx.noten.negativ,
|
||||
datum: antrittDate(ctx, firstIndex + i),
|
||||
}).then((response) => {
|
||||
expectNotenSuccess(response, `Antritt ${i + 2} von ${ctx.maxAntritte}`);
|
||||
});
|
||||
}
|
||||
return antrittDate(ctx, firstIndex + ctx.maxAntritte - 1);
|
||||
};
|
||||
|
||||
// The configuration names the special Noten by Bezeichnung. The API returns the resolved id
|
||||
// (14 on the demo data). If the lookup fails, every rule uses the wrong Note without an error.
|
||||
it("löst die Sondernoten über ihre Bezeichnung aus tbl_note auf", () => {
|
||||
notenApi.getCisConfig().then((response) => {
|
||||
const config = expectNotenSuccess(response, "getCisConfig");
|
||||
|
||||
expect(String(config.NOTE_ENTSCHULDIGT)).to.eq(String(ctx.noten.entschuldigt));
|
||||
expect(config.NOTEN_OHNE_ANTRITT.map(String)).to.include.members([
|
||||
String(ctx.noten.entschuldigt),
|
||||
String(ctx.noten.nochNichtEingetragen),
|
||||
]);
|
||||
expect(
|
||||
Object.keys(config.NOTEN_OCCURRENCE_LIMIT_MAP).map(String),
|
||||
"the occurrence limit must be keyed on the resolved PK",
|
||||
).to.include(String(ctx.noten.entschuldigt));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Regel A - die Höchstzahl der Prüfungsantritte", () => {
|
||||
it("lehnt einen Antritt ab, sobald das konfigurierte Maximum erreicht ist", function () {
|
||||
// without CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF the chain ends earlier, see the kommPruefNichtErlaubt test
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF", true);
|
||||
|
||||
const student = ctx.students[0];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
const nextDate = fillToCap(student);
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.notenScale[0], datum: nextDate }).then((response) => {
|
||||
expectNotenError(response, "maxAntritteReached");
|
||||
});
|
||||
|
||||
// and the rejected Antritt wrote nothing
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const dates = pruefungenOf(data, student.uid).map((p) => String(p.datum).slice(0, 10));
|
||||
expect(dates, "keine Zeile trägt das abgelehnte Datum").to.not.include(nextDate);
|
||||
|
||||
expect(antritteOf(data, student.uid), "genau die möglichen zählenden Zeilen").to.have.length(
|
||||
ctx.maxAntritte,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Regel B - die Antritte folgen der Zeitordnung", () => {
|
||||
beforeEach(function () {
|
||||
requireRepeat(this, ctx);
|
||||
});
|
||||
|
||||
// "Noch nicht eingetragen" takes a date without counting, so rule A does not answer first
|
||||
const givenOpenPruefungOn = (student, datum) => {
|
||||
givenBaseline(ctx, student);
|
||||
addPruefung(ctx, student, { note: ctx.noten.nochNichtEingetragen, datum }).then((response) => {
|
||||
expectNotenSuccess(response, "eine offene Prüfung ohne Antritt");
|
||||
});
|
||||
};
|
||||
|
||||
it("lehnt einen neuen Antritt vor einem bestehenden ab", () => {
|
||||
const student = ctx.students[1];
|
||||
|
||||
givenOpenPruefungOn(student, antrittDate(ctx, 2));
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.notenScale[0], datum: antrittDate(ctx, 1) }).then((response) => {
|
||||
expectNotenError(response, "pruefungDatumBeforeExisting");
|
||||
});
|
||||
});
|
||||
|
||||
it("nimmt einen neuen Antritt nach allen bestehenden an", () => {
|
||||
const student = ctx.students[1];
|
||||
|
||||
givenOpenPruefungOn(student, antrittDate(ctx, 2));
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.notenScale[0], datum: antrittDate(ctx, 3) }).then((response) => {
|
||||
expectNotenSuccess(response, "eine Prüfung streng nach der vorhandenen");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("'entschuldigt' gilt nur einmal", () => {
|
||||
beforeEach(function () {
|
||||
requireRepeat(this, ctx);
|
||||
});
|
||||
|
||||
it("lehnt einen zweiten entschuldigten Antritt ab", () => {
|
||||
const student = ctx.students[2];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.noten.entschuldigt, datum: antrittDate(ctx, 1) }).then((response) =>
|
||||
expectNotenSuccess(response, "erste entschuldigte Prüfung"),
|
||||
);
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.noten.entschuldigt, datum: antrittDate(ctx, 2) }).then((response) =>
|
||||
expectNotenError(response, "noteOccuranceLimitReached"),
|
||||
);
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const entschuldigt = pruefungenOf(data, student.uid).filter(
|
||||
(p) => String(p.note) === String(ctx.noten.entschuldigt),
|
||||
);
|
||||
expect(entschuldigt, "nur eine entschuldigte Prüfung ist erlaubt").to.have.length(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("'entschuldigt' verbraucht keinen Antritt", () => {
|
||||
beforeEach(function () {
|
||||
requireRepeat(this, ctx);
|
||||
});
|
||||
|
||||
it("nimmt nach einem entschuldigten Antritt weiter eine echte Note an", () => {
|
||||
const student = ctx.students[0];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.noten.entschuldigt, datum: antrittDate(ctx, 1) }).then((response) =>
|
||||
expectNotenSuccess(response, "entschuldigte Prüfung"),
|
||||
);
|
||||
|
||||
// entschuldigt uses no Antritt, so this Note is still within the limit
|
||||
addPruefung(ctx, student, { note: ctx.notenScale[0], datum: antrittDate(ctx, 2) }).then((response) =>
|
||||
expectNotenSuccess(
|
||||
response,
|
||||
"eine Note nach einer entschuldigten Prüfung (entschuldigt verbraucht keinen Antritt)",
|
||||
),
|
||||
);
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
expect(
|
||||
verlaufOf(data, student.uid).antrittCount,
|
||||
"Antritt 1 and the new Note; entschuldigt uses no Antritt",
|
||||
).to.eq(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("die kommissionelle Prüfung ist der letzte Antritt", () => {
|
||||
// §17 Abs 1: the second repeat is kommissionell. The server derives the Pruefungstyp from the
|
||||
// position and writes it to `pruefungstyp_kurzbz`, because the StV still reads this column.
|
||||
it("schreibt den letzten Antritt als kommissionelle Prüfung", function () {
|
||||
requireKommissionellerAntritt(this, ctx);
|
||||
skipUnlessLastKommissionell(this);
|
||||
|
||||
const student = ctx.students[3];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
// fill up to the second-to-last Antritt
|
||||
for (let i = 1; i < ctx.maxAntritte - 1; i += 1) {
|
||||
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, i) }).then((response) =>
|
||||
expectNotenSuccess(response, `Antritt ${i + 1}`),
|
||||
);
|
||||
}
|
||||
|
||||
addPruefung(ctx, student, {
|
||||
note: ctx.notenScale[0],
|
||||
datum: antrittDate(ctx, ctx.maxAntritte),
|
||||
}).then((response) => {
|
||||
const { pruefung } = expectNotenSuccess(response, "letzter Antritt")[student.uid];
|
||||
expect(pruefung.pruefungstyp_kurzbz, "der letzte Antritt ist kommissionell").to.eq(
|
||||
ctx.cisConfig.PRUEFUNG_TYP_KOMMISSIONELL,
|
||||
);
|
||||
});
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const verlauf = verlaufOf(data, student.uid);
|
||||
expect(verlauf.antrittCount, "die kommissionelle zählt als Antritt").to.eq(ctx.maxAntritte);
|
||||
expect(verlauf.canAdd, "danach ist kein Antritt mehr möglich").to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
it("verweigert den letzten Antritt, wenn das Tool ihn nicht anlegen darf", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF", false);
|
||||
skipUnlessLastKommissionell(this);
|
||||
|
||||
const student = ctx.students[4];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
// fill up to the second-to-last Antritt
|
||||
for (let i = 1; i < ctx.maxAntritte - 1; i += 1) {
|
||||
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, i) }).then((response) =>
|
||||
expectNotenSuccess(response, `Antritt ${i + 1}`),
|
||||
);
|
||||
}
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const verlauf = verlaufOf(data, student.uid);
|
||||
expect(verlauf.kommPruefLocked, "der Verlauf nennt den Grund").to.be.true;
|
||||
expect(verlauf.canAdd, "und lässt keinen Antritt mehr zu").to.be.false;
|
||||
});
|
||||
|
||||
addPruefung(ctx, student, {
|
||||
note: ctx.notenScale[0],
|
||||
datum: antrittDate(ctx, ctx.maxAntritte),
|
||||
}).then((response) => expectNotenError(response, "kommPruefNichtErlaubt"));
|
||||
});
|
||||
|
||||
// A kommissionelle Pruefung closes the chain also without a counting Note. Otherwise a new
|
||||
// Antritt would be possible after an ungraded kommissionelle Pruefung.
|
||||
it("sperrt weitere Antritte auch bei einer kommPruef ohne zählende Note", () => {
|
||||
const student = ctx.students[1];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
seedPruefung(ctx, student, {
|
||||
note: ctx.noten.nochNichtEingetragen,
|
||||
datum: antrittDate(ctx, 1),
|
||||
type: "kommPruef",
|
||||
});
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const verlauf = verlaufOf(data, student.uid);
|
||||
expect(verlauf.terminal, "die Kette ist geschlossen").to.be.true;
|
||||
expect(verlauf.canAdd, "kein weiterer Antritt möglich").to.be.false;
|
||||
});
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.notenScale[0], datum: antrittDate(ctx, 2) }).then((response) =>
|
||||
expectNotenError(response, "maxAntritteReached"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("'Noch nicht eingetragen' verbraucht keinen Antritt", () => {
|
||||
beforeEach(function () {
|
||||
requireRepeat(this, ctx);
|
||||
});
|
||||
|
||||
it("zählt einen offenen Termin nicht als Antritt", () => {
|
||||
const student = ctx.students[3];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
addPruefung(ctx, student, { note: ctx.noten.nochNichtEingetragen, datum: antrittDate(ctx, 1) }).then(
|
||||
(response) => expectNotenSuccess(response, "offener Termin"),
|
||||
);
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const open = pruefungenOf(data, student.uid).find(
|
||||
(p) => String(p.note) === String(ctx.noten.nochNichtEingetragen),
|
||||
);
|
||||
expect(open, "der offene Termin").to.exist;
|
||||
expect(open.is_antritt, "der offene Termin verbraucht keinen Antritt").to.be.false;
|
||||
|
||||
const verlauf = verlaufOf(data, student.uid);
|
||||
expect(verlauf.antrittCount, "nur Antritt 1").to.eq(1);
|
||||
expect(verlauf.canAdd, "ein weiterer Antritt bleibt möglich").to.be.true;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Anrechnung - gar keine Prüfungen", () => {
|
||||
it("lehnt einen Antritt bei angerechneter Zeugnisnote ab", function () {
|
||||
const angerechnet = (ctx.cisConfig.NOTEN_ANRECHNUNG || [])[0];
|
||||
skipIf(this, angerechnet === undefined, "Übersprungen: NOTEN_ANRECHNUNG löst keine Note auf.");
|
||||
|
||||
const student = ctx.students[4];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
seedZeugnisnote(ctx, student, angerechnet);
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const verlauf = verlaufOf(data, student.uid);
|
||||
expect(verlauf.angerechnet, "der Verlauf nennt die Anrechnung").to.be.true;
|
||||
expect(verlauf.canAdd, "und blockiert weitere Antritte").to.be.false;
|
||||
});
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, 1) }).then((response) =>
|
||||
expectNotenError(response, "c4angerechnetKeinePruefung"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Schutz beim Bearbeiten", () => {
|
||||
beforeEach(function () {
|
||||
requireRepeat(this, ctx);
|
||||
});
|
||||
|
||||
/** Antritt 1 < entschuldigt < a real Note. Returns the entschuldigt row, which has a neighbor on both sides. */
|
||||
const givenThreePruefungen = (student) => {
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.noten.entschuldigt, datum: antrittDate(ctx, 1) });
|
||||
addPruefung(ctx, student, { note: ctx.notenScale[0], datum: antrittDate(ctx, 2) });
|
||||
|
||||
return readStateViaApi(ctx).then((data) => {
|
||||
const entschuldigt = pruefungenOf(data, student.uid).find(
|
||||
(p) => String(p.note) === String(ctx.noten.entschuldigt),
|
||||
);
|
||||
expect(entschuldigt, "entschuldigte Prüfung zum Ändern").to.exist;
|
||||
return entschuldigt;
|
||||
});
|
||||
};
|
||||
|
||||
it("lehnt eine Notenänderung nach einem späteren Antritt ab", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETERER_PRUEFUNG", true);
|
||||
|
||||
const student = ctx.students[1];
|
||||
|
||||
givenThreePruefungen(student).then((entschuldigt) => {
|
||||
editPruefung(ctx, student, {
|
||||
pruefungId: entschuldigt.pruefung_id,
|
||||
note: ctx.notenScale[1], // another Note -> locked
|
||||
datum: antrittDate(ctx, 1),
|
||||
}).then((response) => expectNotenError(response, "pruefungNoteLocked"));
|
||||
});
|
||||
});
|
||||
|
||||
it("erlaubt weiter eine reine Datumskorrektur zwischen den Nachbarantritten", () => {
|
||||
const student = ctx.students[1];
|
||||
|
||||
givenThreePruefungen(student).then((entschuldigt) => {
|
||||
// same Note -> the lock does not apply; the date stays strictly between baseline and Antritt 2
|
||||
editPruefung(ctx, student, {
|
||||
pruefungId: entschuldigt.pruefung_id,
|
||||
note: entschuldigt.note,
|
||||
datum: shiftDate(antrittDate(ctx, 1), 3),
|
||||
})
|
||||
.then((response) => expectNotenSuccess(response, "reine Datumskorrektur innerhalb der Grenzen"))
|
||||
.then(() => readStateViaApi(ctx))
|
||||
.then((data) => {
|
||||
const moved = pruefungenOf(data, student.uid).find(
|
||||
(p) => p.pruefung_id === entschuldigt.pruefung_id,
|
||||
);
|
||||
expect(moved, "die angesprochene Zeile bleibt").to.exist;
|
||||
expect(String(moved.datum).slice(0, 10), "und genau sie ist verschoben").to.eq(
|
||||
shiftDate(antrittDate(ctx, 1), 3),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("lehnt ein Datum am oder vor dem vorherigen Antritt ab", () => {
|
||||
const student = ctx.students[2];
|
||||
|
||||
givenThreePruefungen(student).then((entschuldigt) => {
|
||||
editPruefung(ctx, student, {
|
||||
pruefungId: entschuldigt.pruefung_id,
|
||||
note: entschuldigt.note,
|
||||
datum: baselineDate(ctx), // == the Antritt-1 date -> not strictly after
|
||||
}).then((response) => expectNotenError(response, "pruefungDatumOutOfRange"));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* When a Note closes the Antritt chain ("bestanden").
|
||||
*
|
||||
* negative -> a repeat is allowed
|
||||
* positive, not final -> a repeat only with CIS_GESAMTNOTE_NOTENVERBESSERUNG
|
||||
* final -> the chain is closed, always
|
||||
*
|
||||
* tbl_note.positiv alone is not the rule: 'entschuldigt' and 'Teilgenommen' have it too. Only the
|
||||
* Note of a Pruefung that uses an Antritt decides.
|
||||
*/
|
||||
|
||||
import { notenApi, loginAsLektor } from "../../../../support/api/notenApi";
|
||||
import {
|
||||
expectBulkRowAccepted,
|
||||
expectBulkRowError,
|
||||
expectNotenError,
|
||||
expectNotenSuccess,
|
||||
} from "../../../../support/helpers/notenErrors";
|
||||
import {
|
||||
antrittDate,
|
||||
loadNotenContext,
|
||||
readLvGesamtnoteViaDb,
|
||||
requireDbReset,
|
||||
resetNotenState,
|
||||
seedBaseline,
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
import {
|
||||
addPruefung,
|
||||
pruefungenOf,
|
||||
editPruefung,
|
||||
givenBaseline,
|
||||
readStateViaApi,
|
||||
verlaufOf,
|
||||
} from "../../../../support/helpers/notenScenario";
|
||||
import { requireConfig, requireRepeat, skipIf } from "../../../../support/helpers/notenConfig";
|
||||
|
||||
describe("Noten API - wann eine Note die Kette schliesst", () => {
|
||||
let ctx;
|
||||
|
||||
before(() => {
|
||||
loadNotenContext().then((loaded) => {
|
||||
ctx = loaded;
|
||||
cy.log(`NOTEN_ABSCHLIESSEND = ${JSON.stringify(ctx.cisConfig.NOTEN_ABSCHLIESSEND)}`);
|
||||
cy.log(`NOTENVERBESSERUNG = ${ctx.cisConfig.CIS_GESAMTNOTE_NOTENVERBESSERUNG}`);
|
||||
cy.log(`VERBESSERUNG_BESSERE_GEWINNT = ${ctx.cisConfig.CIS_GESAMTNOTE_VERBESSERUNG_BESSERE_GEWINNT}`);
|
||||
cy.log(`noten.negativ=${ctx.noten.negativ} positiv=${ctx.noten.positiv} bestnote=${ctx.noten.bestnote}`);
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => loginAsLektor());
|
||||
|
||||
beforeEach(() => requireDbReset());
|
||||
|
||||
it("lässt die Kette nach einer negativen Note offen", function () {
|
||||
requireRepeat(this, ctx);
|
||||
|
||||
const student = ctx.students[0];
|
||||
|
||||
givenBaseline(ctx, student, { note: ctx.noten.negativ });
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, 1) })
|
||||
.then((response) => expectNotenSuccess(response, "Wiederholung nach negativer Note"))
|
||||
.then(() => readStateViaApi(ctx))
|
||||
.then((data) => {
|
||||
const verlauf = verlaufOf(data, student.uid);
|
||||
expect(verlauf.bestanden, "eine negative Note ist kein Abschluss").to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
it("schliesst die Kette nach einer abschliessenden Note", function () {
|
||||
skipIf(this, ctx.noten.bestnote === null, "Übersprungen: keine Note aus NOTEN_ABSCHLIESSEND in der 1-5-Skala.");
|
||||
|
||||
const student = ctx.students[1];
|
||||
|
||||
givenBaseline(ctx, student, { note: ctx.noten.bestnote });
|
||||
|
||||
readStateViaApi(ctx)
|
||||
.then((data) => {
|
||||
const verlauf = verlaufOf(data, student.uid);
|
||||
expect(verlauf.bestanden, "die Bestnote schliesst ab").to.be.true;
|
||||
expect(verlauf.canAdd, "kein weiterer Termin nach der Bestnote").to.be.false;
|
||||
})
|
||||
.then(() => addPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, 1) }))
|
||||
.then((response) => expectNotenError(response, "pruefungNachBestandenerNote"));
|
||||
});
|
||||
|
||||
// The bestnote ALWAYS closes the chain, also with CIS_GESAMTNOTE_NOTENVERBESSERUNG.
|
||||
it("schliesst die Kette nach der Bestnote auch bei erlaubter Verbesserung", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENVERBESSERUNG", true);
|
||||
skipIf(this, ctx.noten.bestnote === null, "Übersprungen: keine Note aus NOTEN_ABSCHLIESSEND in der 1-5-Skala.");
|
||||
|
||||
const student = ctx.students[2];
|
||||
|
||||
givenBaseline(ctx, student, { note: ctx.noten.bestnote });
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, 1) }).then((response) =>
|
||||
expectNotenError(response, "pruefungNachBestandenerNote"),
|
||||
);
|
||||
});
|
||||
|
||||
describe("eine positive Note ausserhalb NOTEN_ABSCHLIESSEND", () => {
|
||||
beforeEach(function () {
|
||||
skipIf(
|
||||
this,
|
||||
ctx.noten.positiv === null,
|
||||
"Übersprungen: keine positive Note ausserhalb NOTEN_ABSCHLIESSEND.",
|
||||
);
|
||||
});
|
||||
|
||||
it("schliesst die Kette ohne Notenverbesserung", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENVERBESSERUNG", false);
|
||||
|
||||
const student = ctx.students[3];
|
||||
|
||||
givenBaseline(ctx, student, { note: ctx.noten.positiv });
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.noten.positiv, datum: antrittDate(ctx, 1) }).then((response) =>
|
||||
expectNotenError(response, "pruefungNachBestandenerNote"),
|
||||
);
|
||||
});
|
||||
|
||||
it("lässt mit Notenverbesserung einen weiteren Antritt zu", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENVERBESSERUNG", true);
|
||||
|
||||
const student = ctx.students[3];
|
||||
|
||||
givenBaseline(ctx, student, { note: ctx.noten.positiv });
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.noten.positiv, datum: antrittDate(ctx, 1) }).then((response) =>
|
||||
expectNotenSuccess(response, "Verbesserung nach positiver Note"),
|
||||
);
|
||||
});
|
||||
|
||||
// With VERBESSERUNG_BESSERE_GEWINNT a worse repeat does not lower the LV-Note.
|
||||
it("behält bei einer schlechteren Verbesserung die bessere LV-Note", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENVERBESSERUNG", true);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_VERBESSERUNG_BESSERE_GEWINNT", true);
|
||||
|
||||
const student = ctx.students[4];
|
||||
|
||||
givenBaseline(ctx, student, { note: ctx.noten.positiv });
|
||||
|
||||
// the repeat is worse than Antritt 1
|
||||
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, 1) })
|
||||
.then((response) => expectNotenSuccess(response, "schlechtere Verbesserung"))
|
||||
.then(() => readLvGesamtnoteViaDb(ctx, student))
|
||||
.then((row) => {
|
||||
expect(String(row.note), "die LV-Note behält die bessere Note").to.eq(String(ctx.noten.positiv));
|
||||
});
|
||||
|
||||
// the Pruefung itself keeps the Note that the student got
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const pruefungen = pruefungenOf(data, student.uid);
|
||||
const latest = pruefungen[pruefungen.length - 1];
|
||||
expect(String(latest.note), "der Termin trägt die echte Note").to.eq(String(ctx.noten.negativ));
|
||||
});
|
||||
});
|
||||
|
||||
it("schreibt bei einer schlechteren Verbesserung die letzte Note", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENVERBESSERUNG", true);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_VERBESSERUNG_BESSERE_GEWINNT", false);
|
||||
|
||||
const student = ctx.students[4];
|
||||
|
||||
givenBaseline(ctx, student, { note: ctx.noten.positiv });
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, 1) })
|
||||
.then((response) => expectNotenSuccess(response, "schlechtere Verbesserung"))
|
||||
.then(() => readLvGesamtnoteViaDb(ctx, student))
|
||||
.then((row) => {
|
||||
expect(String(row.note), "die LV-Note folgt dem letzten Antritt").to.eq(String(ctx.noten.negativ));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Only one Pruefung without a Note per LV. A Pruefung with a Note after it stays allowed.
|
||||
describe("nur ein Termin ohne Note", () => {
|
||||
beforeEach(function () {
|
||||
requireRepeat(this, ctx);
|
||||
});
|
||||
|
||||
const open = () => ctx.noten.nochNichtEingetragen;
|
||||
|
||||
const openPruefungen = (data, student) =>
|
||||
pruefungenOf(data, student.uid).filter((p) => String(p.note) === String(open()));
|
||||
|
||||
it("lehnt einen zweiten Termin ohne Note ab", () => {
|
||||
const student = ctx.students[0];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
addPruefung(ctx, student, { note: open(), datum: antrittDate(ctx, 1) }).then((response) =>
|
||||
expectNotenSuccess(response, "erster offener Termin"),
|
||||
);
|
||||
addPruefung(ctx, student, { note: open(), datum: antrittDate(ctx, 2) }).then((response) =>
|
||||
expectNotenError(response, "pruefungOhneErgebnis"),
|
||||
);
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
expect(openPruefungen(data, student), "offene Termine").to.have.length(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("lehnt im Sammeldialog je Zeile einen zweiten offenen Termin ab", () => {
|
||||
const students = [ctx.students[0], ctx.students[1]];
|
||||
const bulkRows = students.map((s) => ({ uid: s.uid, lehreinheit_id: s.lehreinheit_id }));
|
||||
|
||||
resetNotenState(ctx);
|
||||
students.forEach((s) => seedBaseline(ctx, s));
|
||||
|
||||
notenApi
|
||||
.createPruefungen(ctx.lvId, ctx.semKurzbz, bulkRows, { datum: antrittDate(ctx, 1) })
|
||||
.then((response) => {
|
||||
const data = expectNotenSuccess(response, "erster Sammeltermin ohne Note");
|
||||
students.forEach((s) => expectBulkRowAccepted(data, s.uid));
|
||||
});
|
||||
|
||||
notenApi
|
||||
.createPruefungen(ctx.lvId, ctx.semKurzbz, bulkRows, { datum: antrittDate(ctx, 2) })
|
||||
.then((response) => {
|
||||
const data = expectNotenSuccess(response, "zweiter Sammeltermin ohne Note");
|
||||
students.forEach((s) => expectBulkRowError(data, s.uid, "pruefungOhneErgebnis"));
|
||||
});
|
||||
});
|
||||
|
||||
it("lehnt eine Änderung auf 'Noch nicht eingetragen' neben einem offenen Termin ab", () => {
|
||||
const student = ctx.students[2];
|
||||
let latest;
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
addPruefung(ctx, student, { note: open(), datum: antrittDate(ctx, 1) }).then((response) =>
|
||||
expectNotenSuccess(response, "offener Termin"),
|
||||
);
|
||||
|
||||
// decision of the product owner: a Pruefung with a Note after the open one stays allowed
|
||||
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, 2) }).then((response) => {
|
||||
latest = expectNotenSuccess(response, "Termin mit Note nach dem offenen Termin")[student.uid].pruefung;
|
||||
});
|
||||
|
||||
cy.then(() =>
|
||||
editPruefung(ctx, student, {
|
||||
pruefungId: latest.pruefung_id,
|
||||
note: open(),
|
||||
datum: antrittDate(ctx, 2),
|
||||
}),
|
||||
).then((response) => expectNotenError(response, "pruefungOhneErgebnis"));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* The bulk and import endpoints. The same rules as for a single save, but each row reports its own
|
||||
* result: HTTP 200, and an error message in data[uid].
|
||||
*/
|
||||
|
||||
import { notenApi, loginAsLektor } from "../../../../support/api/notenApi";
|
||||
import {
|
||||
expectBulkRowAccepted,
|
||||
expectBulkRowError,
|
||||
expectNotenError,
|
||||
expectNotenSuccess,
|
||||
} from "../../../../support/helpers/notenErrors";
|
||||
import {
|
||||
requireConfig,
|
||||
requireNotenMode,
|
||||
requirePunkteMode,
|
||||
requireRepeat,
|
||||
} from "../../../../support/helpers/notenConfig";
|
||||
import {
|
||||
antrittDate,
|
||||
loadNotenContext,
|
||||
readLvGesamtnoteViaDb,
|
||||
requireDbReset,
|
||||
resetNotenState,
|
||||
seedBaseline,
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
import { addPruefung, pruefungenOf, readStateViaApi } from "../../../../support/helpers/notenScenario";
|
||||
|
||||
describe("Noten API - Sammelpfade", () => {
|
||||
let ctx;
|
||||
|
||||
before(() => {
|
||||
requireDbReset();
|
||||
loadNotenContext().then((loaded) => {
|
||||
ctx = loaded;
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => loginAsLektor());
|
||||
|
||||
/** Adds Antritte up to maxAntritte. The baseline has Antritt 1. */
|
||||
const bringToCap = (student) => {
|
||||
for (let i = 0; i < ctx.maxAntritte - 1; i += 1) {
|
||||
addPruefung(ctx, student, {
|
||||
note: ctx.noten.negativ,
|
||||
datum: antrittDate(ctx, i + 1),
|
||||
}).then((response) => {
|
||||
expectNotenSuccess(response, `${student.uid} bis zur Grenze füllen (Antritt ${i + 2})`);
|
||||
});
|
||||
}
|
||||
return antrittDate(ctx, ctx.maxAntritte + 1);
|
||||
};
|
||||
|
||||
describe("createPruefungen", () => {
|
||||
beforeEach(function () {
|
||||
requireRepeat(this, ctx);
|
||||
// without CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF the limit answers "kommPruefNichtErlaubt"
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF", true);
|
||||
});
|
||||
|
||||
it("lehnt nur die Zeile gegen eine §1-Regel ab und nimmt die übrigen an", () => {
|
||||
const atCap = ctx.students[0];
|
||||
const fresh = ctx.students[1];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, atCap);
|
||||
seedBaseline(ctx, fresh);
|
||||
|
||||
const datum = bringToCap(atCap);
|
||||
|
||||
notenApi
|
||||
.createPruefungen(
|
||||
ctx.lvId,
|
||||
ctx.semKurzbz,
|
||||
[
|
||||
{ uid: atCap.uid, lehreinheit_id: atCap.lehreinheit_id },
|
||||
{ uid: fresh.uid, lehreinheit_id: fresh.lehreinheit_id },
|
||||
],
|
||||
{ datum },
|
||||
)
|
||||
.then((response) => {
|
||||
// the request as a whole succeeds - errors are per row
|
||||
const data = expectNotenSuccess(response, "createPruefungen");
|
||||
|
||||
expectBulkRowError(data, atCap.uid, "maxAntritteReached");
|
||||
expectBulkRowAccepted(data, fresh.uid);
|
||||
});
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const created = pruefungenOf(data, fresh.uid);
|
||||
expect(
|
||||
created.map((p) => String(p.datum).slice(0, 10)),
|
||||
"the accepted row was really created",
|
||||
).to.include(datum);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("importPruefungen", () => {
|
||||
beforeEach(function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNGSIMPORT", true);
|
||||
requireDbReset();
|
||||
requireRepeat(this, ctx);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF", true);
|
||||
});
|
||||
|
||||
it("wendet die §1-Regeln je Zeile an", function () {
|
||||
// in the Punkte mode the endpoint derives the Note from the Punkte; the Punkte test below
|
||||
// covers the rule check
|
||||
requireNotenMode(this, ctx);
|
||||
|
||||
const atCap = ctx.students[0];
|
||||
const fresh = ctx.students[1];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, atCap);
|
||||
seedBaseline(ctx, fresh);
|
||||
|
||||
const datum = bringToCap(atCap);
|
||||
|
||||
notenApi
|
||||
.importPruefungen(ctx.lvId, ctx.semKurzbz, [
|
||||
{
|
||||
uid: atCap.uid,
|
||||
note: ctx.notenScale[0],
|
||||
punkte: null,
|
||||
datum,
|
||||
lehreinheit_id: atCap.lehreinheit_id,
|
||||
},
|
||||
{
|
||||
uid: fresh.uid,
|
||||
note: ctx.notenScale[0],
|
||||
punkte: null,
|
||||
datum,
|
||||
lehreinheit_id: fresh.lehreinheit_id,
|
||||
},
|
||||
])
|
||||
.then((response) => {
|
||||
const data = expectNotenSuccess(response, "importPruefungen");
|
||||
expectBulkRowError(data, atCap.uid, "maxAntritteReached");
|
||||
expectBulkRowAccepted(data, fresh.uid);
|
||||
});
|
||||
});
|
||||
|
||||
// Punkte mode: the Note comes from the Notenschluessel; the row sends only Punkte
|
||||
describe("Punktemodus", () => {
|
||||
beforeEach(function () {
|
||||
requirePunkteMode(this, ctx);
|
||||
});
|
||||
|
||||
it("wendet die Regeln je Zeile auf die abgeleitete Note an", () => {
|
||||
const atCap = ctx.students[0];
|
||||
const fresh = ctx.students[1];
|
||||
const punkte = 70;
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, atCap);
|
||||
seedBaseline(ctx, fresh);
|
||||
|
||||
const datum = bringToCap(atCap);
|
||||
const bulkRow = (student) => ({
|
||||
uid: student.uid,
|
||||
note: null,
|
||||
punkte,
|
||||
datum,
|
||||
lehreinheit_id: student.lehreinheit_id,
|
||||
});
|
||||
|
||||
notenApi
|
||||
.importPruefungen(ctx.lvId, ctx.semKurzbz, [bulkRow(atCap), bulkRow(fresh)])
|
||||
.then((response) => {
|
||||
const data = expectNotenSuccess(response, "importPruefungen mit Punkten");
|
||||
expectBulkRowError(data, atCap.uid, "maxAntritteReached");
|
||||
expectBulkRowAccepted(data, fresh.uid);
|
||||
});
|
||||
|
||||
notenApi.getNoteByPunkte(ctx.lvId, ctx.semKurzbz, punkte).then((punkteResponse) => {
|
||||
const expectedNote = punkteResponse.body.data;
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const newPruefung = pruefungenOf(data, fresh.uid).find(
|
||||
(p) => String(p.datum).slice(0, 10) === datum,
|
||||
);
|
||||
expect(newPruefung, "der neue Termin").to.exist;
|
||||
expect(String(newPruefung.note), "die aus den Punkten abgeleitete Note").to.eq(
|
||||
String(expectedNote),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("importLvNoten", () => {
|
||||
beforeEach(function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENIMPORT", true);
|
||||
});
|
||||
|
||||
it("schreibt für jede Zeile eine LV-Note", function () {
|
||||
// in the Punkte mode the Note comes from the Notenschluessel, see the Punktemodus block
|
||||
requireNotenMode(this, ctx);
|
||||
|
||||
const [a, b] = ctx.students;
|
||||
|
||||
resetNotenState(ctx);
|
||||
|
||||
notenApi
|
||||
.importLvNoten(ctx.lvId, ctx.semKurzbz, [
|
||||
{ uid: a.uid, note: ctx.notenScale[0], punkte: null },
|
||||
{ uid: b.uid, note: ctx.notenScale[1], punkte: null },
|
||||
])
|
||||
.then((response) => {
|
||||
// by uid: each row has the LV-Note or an error
|
||||
const data = expectNotenSuccess(response, "importLvNoten");
|
||||
expectBulkRowAccepted(data, a.uid);
|
||||
expectBulkRowAccepted(data, b.uid);
|
||||
});
|
||||
|
||||
readLvGesamtnoteViaDb(ctx, a).then((row) => {
|
||||
expect(row, `Zeile von ${a.uid}`).to.not.be.null;
|
||||
expect(String(row.note)).to.eq(String(ctx.notenScale[0]));
|
||||
});
|
||||
|
||||
readLvGesamtnoteViaDb(ctx, b).then((row) => {
|
||||
expect(row, `Zeile von ${b.uid}`).to.not.be.null;
|
||||
expect(String(row.note)).to.eq(String(ctx.notenScale[1]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Punktemodus", () => {
|
||||
beforeEach(function () {
|
||||
requirePunkteMode(this, ctx);
|
||||
});
|
||||
|
||||
it("überspringt eine Zeile ohne Punkte und schreibt die übrigen", () => {
|
||||
const withoutPunkte = ctx.students[0];
|
||||
const withPunkte = ctx.students[1];
|
||||
|
||||
resetNotenState(ctx);
|
||||
|
||||
notenApi
|
||||
.importLvNoten(ctx.lvId, ctx.semKurzbz, [
|
||||
{ uid: withoutPunkte.uid, note: null, punkte: null },
|
||||
{ uid: withPunkte.uid, note: null, punkte: 100 },
|
||||
])
|
||||
.then((response) => {
|
||||
// a broken row must not stop the whole import
|
||||
const data = expectNotenSuccess(response, "Notenimport mit Luecke");
|
||||
expectBulkRowError(data, withoutPunkte.uid, "c4punkteKeineNoteErmittelt");
|
||||
expectBulkRowAccepted(data, withPunkte.uid);
|
||||
});
|
||||
|
||||
readLvGesamtnoteViaDb(ctx, withoutPunkte).then((row) => {
|
||||
expect(row, "die übersprungene Zeile bleibt ungeschrieben").to.be.null;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// the switch hides the button; the server rejects a direct call too
|
||||
describe("Importschalter", () => {
|
||||
it("lehnt den Notenimport ab, wenn der Schalter aus ist", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENIMPORT", false);
|
||||
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
|
||||
notenApi
|
||||
.importLvNoten(ctx.lvId, ctx.semKurzbz, [{ uid: student.uid, note: ctx.notenScale[0], punkte: null }])
|
||||
.then((response) => expectNotenError(response, "importAusgeschaltet"));
|
||||
|
||||
readLvGesamtnoteViaDb(ctx, student).then((row) => {
|
||||
expect(row, "der abgelehnte Import schreibt keine LV-Note").to.be.null;
|
||||
});
|
||||
});
|
||||
|
||||
it("lehnt den Prüfungsimport ab, wenn der Schalter aus ist", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNGSIMPORT", false);
|
||||
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student);
|
||||
|
||||
notenApi
|
||||
.importPruefungen(ctx.lvId, ctx.semKurzbz, [
|
||||
{
|
||||
uid: student.uid,
|
||||
lehreinheit_id: student.lehreinheit_id,
|
||||
datum: antrittDate(ctx, 1),
|
||||
note: ctx.noten.negativ,
|
||||
punkte: null,
|
||||
},
|
||||
])
|
||||
.then((response) => expectNotenError(response, "importAusgeschaltet"));
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
expect(pruefungenOf(data, student.uid), "nur Antritt 1 der Baseline").to.have.length(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Two requests at the same time for the same student in the same LV.
|
||||
*
|
||||
* Noten::beginStudentTransaction takes a transaction-wide advisory lock. The second request waits and reads
|
||||
* the state after the first commit. One run proves little for a race condition, so each test repeats it.
|
||||
*/
|
||||
|
||||
import { loginAsLektor } from "../../../../support/api/notenApi";
|
||||
import { expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
|
||||
import { requireConfig, requireRepeat } from "../../../../support/helpers/notenConfig";
|
||||
import {
|
||||
antrittDate,
|
||||
baselineDate,
|
||||
loadNotenContext,
|
||||
requireDbReset,
|
||||
resetNotenState,
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
import { pruefungenOf, givenBaseline, readStateViaApi } from "../../../../support/helpers/notenScenario";
|
||||
|
||||
const ROUNDS = 5;
|
||||
|
||||
const concurrently = (path, bodies) => cy.task("noten:http:postParallel", { path, bodies });
|
||||
|
||||
describe("Noten API - gleichzeitige Schreibvorgänge", () => {
|
||||
let ctx;
|
||||
|
||||
before(() => {
|
||||
requireDbReset();
|
||||
loadNotenContext().then((loaded) => {
|
||||
ctx = loaded;
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => loginAsLektor());
|
||||
|
||||
it("legt bei zwei gleichzeitigen Terminen am selben Tag nur einen an", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNG_GLEICHER_TAG", false);
|
||||
requireRepeat(this, ctx);
|
||||
|
||||
const student = ctx.students[0];
|
||||
const datum = antrittDate(ctx, 1);
|
||||
|
||||
// 'entschuldigt' uses no Antritt. So after the first commit only the date rule rejects the
|
||||
// second Pruefung, whatever maxAntritte and the kommPruef rule say.
|
||||
const body = {
|
||||
student_uid: student.uid,
|
||||
note: ctx.noten.entschuldigt,
|
||||
punkte: null,
|
||||
datum,
|
||||
lv_id: ctx.lvId,
|
||||
lehreinheit_id: student.lehreinheit_id,
|
||||
sem_kurzbz: ctx.semKurzbz,
|
||||
pruefung_id: null,
|
||||
};
|
||||
|
||||
Cypress._.times(ROUNDS, (i) => {
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
concurrently("savePruefung", [body, body]).then((responses) => {
|
||||
const [accepted, rejected] = [...responses].sort((x, y) => x.status - y.status);
|
||||
expectNotenSuccess(accepted, `Durchlauf ${i + 1}: erster Request`);
|
||||
expectNotenError(rejected, "pruefungDatumBeforeExisting");
|
||||
});
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const onDay = pruefungenOf(data, student.uid).filter((p) => String(p.datum).slice(0, 10) === datum);
|
||||
expect(onDay, `Durchlauf ${i + 1}: Termine am ${datum}`).to.have.length(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("schreibt bei zwei gleichzeitigen Übernahmen nur einen Antritt 1", function () {
|
||||
// without ERSTANTRITT_BEI_UEBERNAHME the proposal writes no Antritt 1, so there is no race
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true);
|
||||
|
||||
const student = ctx.students[1];
|
||||
const body = (note) => ({
|
||||
lv_id: ctx.lvId,
|
||||
sem_kurzbz: ctx.semKurzbz,
|
||||
student_uid: student.uid,
|
||||
note,
|
||||
punkte: null,
|
||||
datum: baselineDate(ctx),
|
||||
});
|
||||
|
||||
Cypress._.times(ROUNDS, (i) => {
|
||||
resetNotenState(ctx);
|
||||
|
||||
concurrently("saveLvNote", [body(ctx.notenScale[0]), body(ctx.notenScale[1])]).then((responses) => {
|
||||
responses.forEach((response, n) =>
|
||||
expectNotenSuccess(response, `Durchlauf ${i + 1}, Request ${n + 1}`),
|
||||
);
|
||||
});
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
expect(pruefungenOf(data, student.uid), `Durchlauf ${i + 1}: Antritte`).to.have.length(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* A successful Freigabe sends the Freigabe mail, unless CIS_GESAMTNOTE_FREIGABEMAIL is false.
|
||||
* The dev instances send every mail to a debug mailbox (MAIL_DEBUG), so the tests run with the mail on.
|
||||
* The suite cannot see the mail; it checks the templates and the response.
|
||||
*/
|
||||
|
||||
import { freigabePassword, notenApi, loginAsLektor } from "../../../../support/api/notenApi";
|
||||
import {
|
||||
expectBulkRowAccepted,
|
||||
expectBulkRowError,
|
||||
expectNotenError,
|
||||
expectNotenSuccess,
|
||||
} from "../../../../support/helpers/notenErrors";
|
||||
import { requireConfig, requireNotenMode } from "../../../../support/helpers/notenConfig";
|
||||
import {
|
||||
antrittDate,
|
||||
baselineBenotungsdatum,
|
||||
loadNotenContext,
|
||||
readLvGesamtnoteViaDb,
|
||||
requireDbReset,
|
||||
resetNotenState,
|
||||
seedBaseline,
|
||||
seedPruefung,
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
import {
|
||||
addPruefung,
|
||||
pruefungenOf,
|
||||
givenBaseline,
|
||||
studentOf,
|
||||
readStateViaApi,
|
||||
verlaufOf,
|
||||
} from "../../../../support/helpers/notenScenario";
|
||||
|
||||
describe("Noten API - Notenfreigabe", () => {
|
||||
let ctx;
|
||||
|
||||
before(() => {
|
||||
loadNotenContext().then((loaded) => {
|
||||
ctx = loaded;
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => loginAsLektor());
|
||||
|
||||
describe("Passwortschutz", () => {
|
||||
it("lehnt eine Freigabe mit falschem Passwort ab und ändert nichts", function () {
|
||||
// without CIS_GESAMTNOTE_FREIGABE_PASSWORT every password passes
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_FREIGABE_PASSWORT", true);
|
||||
requireDbReset();
|
||||
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, {
|
||||
note: ctx.notenScale[0],
|
||||
freigegeben: false,
|
||||
erstantritt: false,
|
||||
benotungsdatum: baselineBenotungsdatum(ctx),
|
||||
});
|
||||
|
||||
notenApi
|
||||
.saveFreigabe(ctx.lvId, ctx.semKurzbz, "definitely-not-the-password", [student.uid])
|
||||
.then((response) => {
|
||||
expectNotenError(response, "wrongPassword");
|
||||
});
|
||||
|
||||
readLvGesamtnoteViaDb(ctx, student).then((row) => {
|
||||
expect(row, "die Zeile bleibt").to.not.be.null;
|
||||
expect(row.freigabedatum, "eine abgelehnte Freigabe setzt kein freigabedatum").to.be.null;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("der normale Ablauf", () => {
|
||||
beforeEach(() => {
|
||||
requireDbReset();
|
||||
});
|
||||
|
||||
it("setzt das freigabedatum auf einer geänderten Note", () => {
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, {
|
||||
note: ctx.notenScale[0],
|
||||
freigegeben: false,
|
||||
erstantritt: false,
|
||||
benotungsdatum: baselineBenotungsdatum(ctx),
|
||||
});
|
||||
|
||||
notenApi.saveFreigabe(ctx.lvId, ctx.semKurzbz, freigabePassword(), [student.uid]).then((response) => {
|
||||
const entry = expectNotenSuccess(response, "saveFreigabe")[student.uid];
|
||||
expect(entry, `${student.uid} wird als freigegeben gemeldet`).to.exist;
|
||||
expect(entry.lvgesamtnote.freigabedatum, "freigabedatum ist gesetzt").to.exist;
|
||||
expect(entry.verlauf, "der Verlauf kommt mit der Antwort zurück").to.exist;
|
||||
expect(entry.verlauf.pruefungen, "inklusive der Termine").to.be.an("array");
|
||||
});
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const lvNote = studentOf(data, student.uid);
|
||||
expect(lvNote.freigabedatum, "die Note ist jetzt freigegeben").to.exist;
|
||||
});
|
||||
|
||||
// the Freigabe used to stop before it wrote
|
||||
readLvGesamtnoteViaDb(ctx, student).then((row) => {
|
||||
expect(row.freigabedatum, "C1: die Freigabe schreibt freigabedatum").to.not.be.null;
|
||||
});
|
||||
});
|
||||
|
||||
it("legt mit der Freigabe den ersten Antritt an", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true);
|
||||
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, { note: ctx.noten.negativ, freigegeben: false, erstantritt: false });
|
||||
|
||||
notenApi.saveFreigabe(ctx.lvId, ctx.semKurzbz, freigabePassword(), [student.uid]).then((response) => {
|
||||
const entry = expectNotenSuccess(response, "saveFreigabe")[student.uid];
|
||||
// without the verlauf in the response, the table shows the new Pruefung only after a reload
|
||||
expect(entry.verlauf.pruefungen, "der erste Antritt kommt mit der Antwort zurück").to.have.length(1);
|
||||
});
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const pruefungen = pruefungenOf(data, student.uid);
|
||||
expect(pruefungen, "genau ein Termin").to.have.length(1);
|
||||
expect(pruefungen[0].antritt_nr, "als Antritt 1").to.eq(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("legt ohne CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME keinen Termin an", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", false);
|
||||
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, { note: ctx.noten.negativ, freigegeben: false, erstantritt: false });
|
||||
|
||||
notenApi.saveFreigabe(ctx.lvId, ctx.semKurzbz, freigabePassword(), [student.uid]).then((response) => {
|
||||
const entry = expectNotenSuccess(response, "saveFreigabe")[student.uid];
|
||||
expect(entry.lvgesamtnote.freigabedatum, "die Freigabe gilt trotzdem").to.exist;
|
||||
});
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
expect(pruefungenOf(data, student.uid), "kein Termin").to.have.length(0);
|
||||
expect(verlaufOf(data, student.uid).antrittCount, "die LV-Note zählt als Antritt 1").to.eq(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("gibt ohne Passwort frei, wenn die Konfiguration keines verlangt", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_FREIGABE_PASSWORT", false);
|
||||
|
||||
const student = ctx.students[1];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, { note: ctx.noten.negativ, freigegeben: false, erstantritt: false });
|
||||
|
||||
notenApi.saveFreigabe(ctx.lvId, ctx.semKurzbz, "", [student.uid]).then((response) => {
|
||||
const data = expectNotenSuccess(response, "Freigabe ohne Passwort");
|
||||
expect(Object.keys(data), "freigegebene Zeilen").to.include(student.uid);
|
||||
});
|
||||
|
||||
readLvGesamtnoteViaDb(ctx, student).then((row) => {
|
||||
expect(row.freigabedatum, "freigabedatum").to.not.be.null;
|
||||
});
|
||||
});
|
||||
|
||||
// A single repeat is not Antritt 1. The Freigabe does not overwrite its Note.
|
||||
it("behält die Note einer einzelnen Wiederholung", () => {
|
||||
const student = ctx.students[1];
|
||||
const g2 = ctx.notenScale.find((n) => String(n) !== String(ctx.noten.negativ));
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, { note: ctx.noten.negativ, freigegeben: false, erstantritt: false });
|
||||
seedPruefung(ctx, student, { note: g2, datum: antrittDate(ctx, 1), type: "Termin2" });
|
||||
|
||||
notenApi
|
||||
.saveFreigabe(ctx.lvId, ctx.semKurzbz, freigabePassword(), [student.uid])
|
||||
.then((response) => expectNotenSuccess(response, "saveFreigabe"));
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const pruefungen = pruefungenOf(data, student.uid);
|
||||
expect(pruefungen, "die Freigabe legt keine Prüfung an").to.have.length(1);
|
||||
expect(String(pruefungen[0].note), "die Wiederholung behält ihre Note").to.eq(String(g2));
|
||||
});
|
||||
});
|
||||
|
||||
// The person who enters a Pruefung owns its date. The Freigabe makes the Note binding and
|
||||
// keeps the date.
|
||||
it("behält das Datum einer bestehenden Prüfung", () => {
|
||||
const student = ctx.students[2];
|
||||
const pruefungsdatum = antrittDate(ctx, 2);
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, {
|
||||
note: ctx.noten.negativ,
|
||||
freigegeben: false,
|
||||
erstantritt: false,
|
||||
benotungsdatum: baselineBenotungsdatum(ctx),
|
||||
});
|
||||
// Antritt 1 with its own date; without it, the Freigabe would create Antritt 1 on the benotungsdatum
|
||||
seedPruefung(ctx, student, { note: ctx.noten.negativ, datum: pruefungsdatum, type: "Termin1" });
|
||||
|
||||
notenApi
|
||||
.saveFreigabe(ctx.lvId, ctx.semKurzbz, freigabePassword(), [student.uid])
|
||||
.then((response) => expectNotenSuccess(response, "saveFreigabe"));
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const pruefungen = pruefungenOf(data, student.uid);
|
||||
expect(pruefungen, "der Termin bleibt der einzige").to.have.length(1);
|
||||
expect(String(pruefungen[0].datum).slice(0, 10), "mit seinem eigenen Datum").to.eq(pruefungsdatum);
|
||||
});
|
||||
});
|
||||
|
||||
it("ändert nur Zeilen mit einem benotungsdatum nach dem freigabedatum", () => {
|
||||
const changed = ctx.students[0];
|
||||
const alreadyFreigegeben = ctx.students[1];
|
||||
|
||||
resetNotenState(ctx);
|
||||
|
||||
// changed: benotungsdatum after freigabedatum -> must be re-freigegeben
|
||||
seedBaseline(ctx, changed, {
|
||||
note: ctx.notenScale[0],
|
||||
freigegeben: true,
|
||||
freigabedatum: `${ctx.semKurzbz.slice(2, 6)}-01-05 08:00:00`,
|
||||
benotungsdatum: `${ctx.semKurzbz.slice(2, 6)}-01-10 08:00:00`,
|
||||
});
|
||||
|
||||
// already freigegeben and untouched since -> must be left alone
|
||||
seedBaseline(ctx, alreadyFreigegeben, {
|
||||
note: ctx.notenScale[0],
|
||||
freigegeben: true,
|
||||
freigabedatum: `${ctx.semKurzbz.slice(2, 6)}-01-10 08:00:00`,
|
||||
benotungsdatum: `${ctx.semKurzbz.slice(2, 6)}-01-10 08:00:00`,
|
||||
});
|
||||
|
||||
notenApi
|
||||
.saveFreigabe(ctx.lvId, ctx.semKurzbz, freigabePassword(), [changed.uid, alreadyFreigegeben.uid])
|
||||
.then((response) => {
|
||||
const data = expectNotenSuccess(response, "selektive Freigabe");
|
||||
const uids = Object.keys(data);
|
||||
|
||||
expect(uids, "die geänderte Note ist freigegeben").to.include(changed.uid);
|
||||
expect(
|
||||
uids,
|
||||
"an unchanged, already freigegebene note must not be freigegeben again",
|
||||
).to.not.include(alreadyFreigegeben.uid);
|
||||
});
|
||||
|
||||
readLvGesamtnoteViaDb(ctx, alreadyFreigegeben).then((row) => {
|
||||
expect(
|
||||
new Date(row.freigabedatum).toISOString().slice(0, 10),
|
||||
"the untouched row keeps its original freigabedatum",
|
||||
).to.eq(`${ctx.semKurzbz.slice(2, 6)}-01-10`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// The mail goes to a debug mailbox, and the suite does not read it. So it checks what the mail needs:
|
||||
// both templates have a text, and a Freigabe with the mail on returns a full response.
|
||||
describe("Freigabemail", () => {
|
||||
beforeEach(function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_FREIGABEMAIL", true);
|
||||
requireDbReset();
|
||||
});
|
||||
|
||||
it("findet die Vorlage der Freigabemail und das Sancho-Layout", () => {
|
||||
const vorlage = ctx.cisConfig.CIS_GESAMTNOTE_FREIGABEMAIL_VORLAGE;
|
||||
|
||||
cy.task("noten:db:readVorlagen", { kurzbz: [vorlage, "Sancho_Mail_Template"] }).then((rows) => {
|
||||
const text = (kurzbz) => (rows.find((r) => r.vorlage_kurzbz === kurzbz) || {}).text || "";
|
||||
|
||||
// without a text the mail is empty, and nobody notices
|
||||
expect(text(vorlage), `Text der Vorlage ${vorlage}`).to.not.be.empty;
|
||||
expect(text(vorlage), "die Vorlage setzt die Notenliste ein").to.include("{studlist}");
|
||||
expect(text("Sancho_Mail_Template"), "das Layout setzt den Inhalt ein").to.include("{content}");
|
||||
});
|
||||
});
|
||||
|
||||
it("gibt mit eingeschalteter Mail frei", () => {
|
||||
const student = ctx.students[3];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, { note: ctx.noten.negativ, freigegeben: false, erstantritt: false });
|
||||
|
||||
notenApi.saveFreigabe(ctx.lvId, ctx.semKurzbz, freigabePassword(), [student.uid]).then((response) => {
|
||||
const entry = expectNotenSuccess(response, "Freigabe mit Mail")[student.uid];
|
||||
expect(entry, `${student.uid} ist freigegeben`).to.exist;
|
||||
});
|
||||
|
||||
readLvGesamtnoteViaDb(ctx, student).then((row) => {
|
||||
expect(row.freigabedatum, "freigabedatum").to.not.be.null;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// CIS_GESAMTNOTE_FREIGABE_FINAL: no endpoint changes a freigegeben Note. The tests seed the
|
||||
// Freigabe, so they need no password.
|
||||
describe("endgültige Freigabe", () => {
|
||||
beforeEach(function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_FREIGABE_FINAL", true);
|
||||
requireDbReset();
|
||||
});
|
||||
|
||||
const otherNote = () => ctx.notenScale.find((n) => String(n) !== String(ctx.noten.negativ));
|
||||
|
||||
it("lehnt eine Übernahme nach der Freigabe ab", () => {
|
||||
const student = ctx.students[0];
|
||||
|
||||
// freigegeben on purpose: with FINAL the baseline seeds an open Note by default
|
||||
givenBaseline(ctx, student, { freigegeben: true });
|
||||
|
||||
notenApi
|
||||
.saveLvNote(ctx.lvId, ctx.semKurzbz, student.uid, otherNote())
|
||||
.then((response) => expectNotenError(response, "freigabeEndgueltig"));
|
||||
|
||||
readLvGesamtnoteViaDb(ctx, student).then((row) => {
|
||||
expect(String(row.note), "die LV-Note bleibt").to.eq(String(ctx.noten.negativ));
|
||||
});
|
||||
});
|
||||
|
||||
it("lehnt einen neuen Termin nach der Freigabe ab", () => {
|
||||
const student = ctx.students[1];
|
||||
|
||||
givenBaseline(ctx, student, { freigegeben: true });
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, 1) }).then((response) =>
|
||||
expectNotenError(response, "freigabeEndgueltig"),
|
||||
);
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
expect(pruefungenOf(data, student.uid), "nur Antritt 1").to.have.length(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("lehnt im Import nur die freigegebene Zeile ab", function () {
|
||||
// Punkte mode: the Note comes from the Notenschluessel; the row sends only Punkte
|
||||
requireNotenMode(this, ctx);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENIMPORT", true);
|
||||
|
||||
const [freigegeben, open] = ctx.students;
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, freigegeben, { freigegeben: true });
|
||||
seedBaseline(ctx, open, { freigegeben: false, erstantritt: false });
|
||||
|
||||
notenApi
|
||||
.importLvNoten(ctx.lvId, ctx.semKurzbz, [
|
||||
{ uid: freigegeben.uid, note: otherNote(), punkte: null },
|
||||
{ uid: open.uid, note: otherNote(), punkte: null },
|
||||
])
|
||||
.then((response) => {
|
||||
const data = expectNotenSuccess(response, "importLvNoten");
|
||||
expectBulkRowError(data, freigegeben.uid, "freigabeEndgueltig");
|
||||
expectBulkRowAccepted(data, open.uid);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Pruefungsordnung §1: the Noteneintragungsfrist.
|
||||
*
|
||||
* Two Fristen answer two questions: CIS_GESAMTNOTE_FRIST_EINGABE limits the day of the entry,
|
||||
* CIS_GESAMTNOTE_FRIST_PRUEFUNGSDATUM limits the date of the Pruefung. Every write endpoint checks the
|
||||
* entry Frist right after the access check, so a rejected request writes nothing. The message names
|
||||
* the Frist, so the test also checks how the server derives it from NOTENEINTRAGUNGSFRIST_SS/WS.
|
||||
*
|
||||
* Seeder group benotungstool_fixture_erweitert creates a Sommersemester after its Frist in which the Lektor teaches.
|
||||
*/
|
||||
|
||||
import { freigabePassword, notenApi, loginAsLektor } from "../../../../support/api/notenApi";
|
||||
import { expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
|
||||
import {
|
||||
antrittDate,
|
||||
expectedFristString,
|
||||
fristHasPassed,
|
||||
loadNotenContext,
|
||||
requireDbReset,
|
||||
teachingSemesterWithExpiredFrist,
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
import { addPruefung, givenBaseline } from "../../../../support/helpers/notenScenario";
|
||||
import { requireConfig, requireRepeat } from "../../../../support/helpers/notenConfig";
|
||||
|
||||
describe("Noten API - Noteneintragungsfrist (Prüfungsordnung §1)", () => {
|
||||
let ctx;
|
||||
// a semester and LV in which the Lektor teaches and the Frist has passed
|
||||
const expired = { SS: null, WS: null };
|
||||
|
||||
const fristSS = () => ctx.cisConfig.NOTENEINTRAGUNGSFRIST_SS;
|
||||
const fristWS = () => ctx.cisConfig.NOTENEINTRAGUNGSFRIST_WS;
|
||||
|
||||
before(() => {
|
||||
loadNotenContext().then((loaded) => {
|
||||
ctx = loaded;
|
||||
cy.log(
|
||||
`FRIST_EINGABE=${ctx.cisConfig.CIS_GESAMTNOTE_FRIST_EINGABE} ` +
|
||||
`FRIST_PRUEFUNGSDATUM=${ctx.cisConfig.CIS_GESAMTNOTE_FRIST_PRUEFUNGSDATUM} ` +
|
||||
`AUSNAHME_GILT=${ctx.cisConfig.CIS_GESAMTNOTE_FRIST_AUSNAHME_GILT}`,
|
||||
);
|
||||
|
||||
["SS", "WS"].forEach((type) =>
|
||||
teachingSemesterWithExpiredFrist(type, fristSS(), fristWS()).then((pair) => {
|
||||
expired[type] = pair;
|
||||
cy.log(`${type} mit abgelaufener Frist: ${JSON.stringify(pair)}`);
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => loginAsLektor());
|
||||
|
||||
// A missing semester is a fixture error, not a skipped test
|
||||
const pairFor = (type) => {
|
||||
expect(expired[type], `ein ${type} mit abgelaufener Frist, in dem der Testbenutzer unterrichtet`).to.not.be
|
||||
.null;
|
||||
return expired[type];
|
||||
};
|
||||
|
||||
// §7 and §11: the Pruefung must TAKE PLACE before the Frist. That is a different question from
|
||||
// "may the Lektor still enter it now?"
|
||||
describe("Prüfungsdatum nach der Frist", () => {
|
||||
beforeEach(() => requireDbReset());
|
||||
|
||||
// the Frist of the current semester is before this date
|
||||
const afterFrist = () => `${Number(ctx.semKurzbz.slice(2, 6)) + 1}-12-31`;
|
||||
|
||||
it("lehnt einen Termin ab, der nach der Frist liegt", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_FRIST_PRUEFUNGSDATUM", true);
|
||||
|
||||
const student = ctx.students[0];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
addPruefung(ctx, student, { note: ctx.notenScale[0], datum: afterFrist() }).then((response) =>
|
||||
expectNotenError(response, "pruefungsdatumNachFrist"),
|
||||
);
|
||||
});
|
||||
|
||||
it("nimmt einen Termin nach der Frist an, wenn das Prüfungsdatum an keine Frist gebunden ist", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_FRIST_PRUEFUNGSDATUM", false);
|
||||
requireRepeat(this, ctx);
|
||||
|
||||
const student = ctx.students[0];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: afterFrist() }).then((response) =>
|
||||
expectNotenSuccess(response, "Termin nach der Frist"),
|
||||
);
|
||||
});
|
||||
|
||||
it("nimmt einen Termin vor der Frist an", function () {
|
||||
requireRepeat(this, ctx);
|
||||
|
||||
const student = ctx.students[1];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
addPruefung(ctx, student, { note: ctx.notenScale[0], datum: antrittDate(ctx, 1) }).then((response) =>
|
||||
expectNotenSuccess(response, "Termin innerhalb der Frist"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Eingabe nach der Frist", () => {
|
||||
const s0 = () => ctx.students[0];
|
||||
|
||||
// The student is not in the LV of that earlier semester. Without the Frist check the server would
|
||||
// reject the student instead. Either way the request writes nothing.
|
||||
const writePaths = {
|
||||
savePruefung: (pair) =>
|
||||
notenApi.savePruefung(pair.lvId, pair.semKurzbz, s0().uid, {
|
||||
pruefung_id: null,
|
||||
lehreinheit_id: s0().lehreinheit_id,
|
||||
datum: antrittDate(ctx, 1),
|
||||
note: ctx.notenScale[0],
|
||||
punkte: null,
|
||||
}),
|
||||
saveLvNote: (pair) => notenApi.saveLvNote(pair.lvId, pair.semKurzbz, s0().uid, ctx.notenScale[0]),
|
||||
importLvNoten: (pair) =>
|
||||
notenApi.importLvNoten(pair.lvId, pair.semKurzbz, [
|
||||
{ uid: s0().uid, note: ctx.notenScale[0], punkte: null },
|
||||
]),
|
||||
createPruefungen: (pair) =>
|
||||
notenApi.createPruefungen(
|
||||
pair.lvId,
|
||||
pair.semKurzbz,
|
||||
[{ uid: s0().uid, lehreinheit_id: s0().lehreinheit_id }],
|
||||
{
|
||||
datum: antrittDate(ctx, 1),
|
||||
},
|
||||
),
|
||||
importPruefungen: (pair) =>
|
||||
notenApi.importPruefungen(pair.lvId, pair.semKurzbz, [
|
||||
{
|
||||
uid: s0().uid,
|
||||
note: ctx.notenScale[0],
|
||||
punkte: null,
|
||||
datum: antrittDate(ctx, 1),
|
||||
lehreinheit_id: s0().lehreinheit_id,
|
||||
},
|
||||
]),
|
||||
saveFreigabe: (pair) => notenApi.saveFreigabe(pair.lvId, pair.semKurzbz, freigabePassword(), [s0().uid]),
|
||||
};
|
||||
|
||||
const reportsFrist = (response) =>
|
||||
(response.body?.errors ?? []).some((e) => e.code === "noteneintragungsfristVorbei");
|
||||
|
||||
Object.entries(writePaths).forEach(([writePath, call]) => {
|
||||
["SS", "WS"].forEach((type) => {
|
||||
it(`${writePath} lehnt eine Eingabe im ${type} nach der Frist ab`, function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_FRIST_EINGABE", true);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_FRIST_AUSNAHME_GILT", false);
|
||||
|
||||
const pair = pairFor(type);
|
||||
expect(
|
||||
fristHasPassed(pair.semKurzbz, fristSS(), fristWS()),
|
||||
`die Frist von ${pair.semKurzbz} ist vorbei`,
|
||||
).to.be.true;
|
||||
|
||||
call(pair).then((response) => {
|
||||
expectNotenError(response, "noteneintragungsfristVorbei");
|
||||
|
||||
const errorMessage = response.body.errors.map((e) => e.message).join(" | ");
|
||||
expect(errorMessage, `die Frist, die der Server für ${pair.semKurzbz} ableitet`).to.include(
|
||||
expectedFristString(pair.semKurzbz, fristSS(), fristWS()),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// the exception covers the day of the ENTRY, not the date of the Pruefung
|
||||
it(`${writePath} lässt eine Ausnahmerolle nach der Frist eintragen`, function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_FRIST_EINGABE", true);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_FRIST_AUSNAHME_GILT", true);
|
||||
|
||||
call(pairFor("SS")).then((response) => {
|
||||
expect(reportsFrist(response), "die Eingabefrist greift nicht").to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
it(`${writePath} nimmt ohne Eingabefrist eine späte Eingabe an`, function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_FRIST_EINGABE", false);
|
||||
|
||||
call(pairFor("SS")).then((response) => {
|
||||
expect(reportsFrist(response), "die Eingabefrist greift nicht").to.be.false;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* From which Antritt the Pruefung is kommissionell.
|
||||
*
|
||||
* The number comes from CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT, not from this test. Checks that
|
||||
* the built chain follows it: role -> legacy type -> the `kommissionell` flag.
|
||||
*/
|
||||
|
||||
import { loginAsLektor } from "../../../../support/api/notenApi";
|
||||
import { expectNotenSuccess } from "../../../../support/helpers/notenErrors";
|
||||
import { skipIf } from "../../../../support/helpers/notenConfig";
|
||||
import { antrittDate, loadNotenContext, requireDbReset } from "../../../../support/helpers/notenTestData";
|
||||
import { addPruefung, pruefungenOf, givenBaseline, readStateViaApi } from "../../../../support/helpers/notenScenario";
|
||||
|
||||
describe("Noten API - ab welchem Antritt kommissionell", () => {
|
||||
let ctx;
|
||||
let fromAntritt; // null means no Antritt is kommissionell
|
||||
|
||||
before(() => {
|
||||
loadNotenContext().then((loaded) => {
|
||||
ctx = loaded;
|
||||
fromAntritt = ctx.cisConfig.CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT ?? null;
|
||||
cy.log(`KOMMISSIONELL_AB_ANTRITT = ${fromAntritt} maxAntritte = ${ctx.maxAntritte}`);
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => loginAsLektor());
|
||||
|
||||
beforeEach(() => requireDbReset());
|
||||
|
||||
it("liefert eine Antrittsnummer, die zur Kette passt", function () {
|
||||
skipIf(this, fromAntritt === null, "Übersprungen: kein Antritt ist kommissionell.");
|
||||
|
||||
expect(fromAntritt, "die Nummer ist eine Zahl ab 1").to.be.a("number").and.to.be.at.least(1);
|
||||
expect(fromAntritt, "sie liegt innerhalb der Kette").to.be.at.most(ctx.maxAntritte);
|
||||
});
|
||||
|
||||
it("baut die Kette nach der konfigurierten Nummer", function () {
|
||||
skipIf(
|
||||
this,
|
||||
ctx.cisConfig.CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF === false,
|
||||
"Übersprungen: das Werkzeug darf den letzten Antritt nicht anlegen.",
|
||||
);
|
||||
|
||||
const student = ctx.students[0];
|
||||
expect(student, "ein Student der LV").to.exist;
|
||||
|
||||
// the baseline is Antritt 1; a negative Note keeps the chain open
|
||||
givenBaseline(ctx, student, { note: ctx.noten.negativ });
|
||||
|
||||
for (let nr = 2; nr <= ctx.maxAntritte; nr += 1) {
|
||||
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, nr - 1) }).then((response) =>
|
||||
expectNotenSuccess(response, `Antritt ${nr}`),
|
||||
);
|
||||
}
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const pruefungen = pruefungenOf(data, student.uid);
|
||||
expect(pruefungen.length, "die Kette ist vollständig").to.eq(ctx.maxAntritte);
|
||||
|
||||
pruefungen.forEach((termin, index) => {
|
||||
const nr = index + 1;
|
||||
const expected = fromAntritt !== null && nr >= fromAntritt;
|
||||
|
||||
expect(termin.kommissionell, `Antritt ${nr} kommissionell?`).to.eq(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* saveLvNote: the proposal writes the LV-Note, and the Freigabe state follows.
|
||||
*
|
||||
* Two timestamps decide the state: benotungsdatum after freigabedatum = changed. The case "changed
|
||||
* after the Freigabe" starts from a baseline that is already freigegeben, so this spec needs no LDAP
|
||||
* password and sends no Freigabe mail.
|
||||
*/
|
||||
|
||||
import { notenApi, loginAsLektor } from "../../../../support/api/notenApi";
|
||||
import { expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
|
||||
import { requireConfig, requireRepeat } from "../../../../support/helpers/notenConfig";
|
||||
import {
|
||||
antrittDate,
|
||||
baselineBenotungsdatum,
|
||||
baselineDate,
|
||||
loadNotenContext,
|
||||
readLvGesamtnoteViaDb,
|
||||
requireDbReset,
|
||||
resetNotenState,
|
||||
seedBaseline,
|
||||
seedPruefung,
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
import {
|
||||
addPruefung,
|
||||
pruefungenOf,
|
||||
studentOf,
|
||||
readStateViaApi,
|
||||
verlaufOf,
|
||||
} from "../../../../support/helpers/notenScenario";
|
||||
|
||||
describe("Noten API - LV-Note", () => {
|
||||
let ctx;
|
||||
|
||||
before(() => {
|
||||
requireDbReset();
|
||||
loadNotenContext().then((loaded) => {
|
||||
ctx = loaded;
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => loginAsLektor());
|
||||
|
||||
describe("ein Notenvorschlag für einen Studierenden ohne Note", () => {
|
||||
it("gibt die gespeicherte Note mit benotungsdatum und ohne freigabedatum zurück", () => {
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
|
||||
notenApi.saveLvNote(ctx.lvId, ctx.semKurzbz, student.uid, ctx.notenScale[0]).then((response) => {
|
||||
const row = expectNotenSuccess(response, "saveLvNote")[student.uid].lvgesamtnote;
|
||||
expect(String(row.note), "gespeicherte Note").to.eq(String(ctx.notenScale[0]));
|
||||
expect(row.benotungsdatum, "benotungsdatum wird beim Speichern gesetzt").to.exist;
|
||||
expect(row.freigabedatum, "eine neue LV-Note ist nicht freigegeben").to.be.oneOf([null, undefined, ""]);
|
||||
});
|
||||
});
|
||||
|
||||
// getStudentenNoten uses the unfiltered getter, so it also reads Noten that are not freigegeben.
|
||||
// Otherwise the LV-Note and the Freigabe state would be empty after a reload.
|
||||
it("meldet die offene Note über getStudentenNoten zurück", () => {
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
notenApi.saveLvNote(ctx.lvId, ctx.semKurzbz, student.uid, ctx.notenScale[0]);
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const row = studentOf(data, student.uid);
|
||||
expect(row, `Zeile von ${student.uid}`).to.exist;
|
||||
expect(
|
||||
String(row.lv_note),
|
||||
"a saved LV-Note that is not freigegeben must be readable again - if this is " +
|
||||
"null, getLvGesamtNoten's `freigabedatum < NOW()` filter dropped the row",
|
||||
).to.eq(String(ctx.notenScale[0]));
|
||||
expect(row.freigabedatum, "noch nicht freigegeben").to.be.oneOf([null, undefined, ""]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// `erstantritt: false` is the old data form: a freigegeben LV-Note without a Pruefung row. Only then may
|
||||
// the proposal change the LV-Note directly; with a Pruefung the server rejects it (validateLvNote,
|
||||
// see overwrite.cy.js). The date comes from the dialog of the proposal. The server uses it as the
|
||||
// date of Antritt 1, so the same rules apply as for a Pruefung date.
|
||||
describe("das gewählte Benotungsdatum", () => {
|
||||
// The LV-Note IS Antritt 1. Without this row the next Pruefung would get "Termin2", and the
|
||||
// legacy Pruefungstyp of the whole chain would move by one.
|
||||
it("schreibt Antritt 1 mit dem gewählten Tag", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true);
|
||||
|
||||
const student = ctx.students[1];
|
||||
const datum = baselineDate(ctx);
|
||||
|
||||
resetNotenState(ctx);
|
||||
|
||||
notenApi
|
||||
.saveLvNote(ctx.lvId, ctx.semKurzbz, student.uid, ctx.notenScale[0], null, datum)
|
||||
.then((response) => {
|
||||
const { verlauf } = expectNotenSuccess(response, "saveLvNote")[student.uid];
|
||||
expect(verlauf, "die Antwort trägt den Verlauf").to.exist;
|
||||
expect(verlauf.antrittCount, "die LV-Note ist Antritt 1").to.eq(1);
|
||||
expect(verlauf.hasRepeat, "Antritt 1 ist keine Wiederholung").to.be.false;
|
||||
})
|
||||
.then(() => readStateViaApi(ctx))
|
||||
.then((data) => {
|
||||
const pruefungen = pruefungenOf(data, student.uid);
|
||||
expect(pruefungen, "genau ein Antritt").to.have.length(1);
|
||||
expect(String(pruefungen[0].datum).slice(0, 10), "mit dem gewählten Datum").to.eq(datum);
|
||||
expect(pruefungen[0].antritt_nr, "als Antritt 1").to.eq(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("macht die nächste Prüfung zu Antritt 2 statt wieder zu Antritt 1", function () {
|
||||
requireRepeat(this, ctx);
|
||||
|
||||
const student = ctx.students[2];
|
||||
|
||||
resetNotenState(ctx);
|
||||
|
||||
notenApi
|
||||
.saveLvNote(ctx.lvId, ctx.semKurzbz, student.uid, ctx.noten.negativ, null, baselineDate(ctx))
|
||||
.then((response) => expectNotenSuccess(response, "übernehmen"));
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.notenScale[1], datum: antrittDate(ctx, 1) })
|
||||
.then((response) => {
|
||||
const { pruefung } = expectNotenSuccess(response, "Wiederholung")[student.uid];
|
||||
expect(pruefung.pruefungstyp_kurzbz, "die Wiederholung ist nicht Antritt 1").to.not.eq("Termin1");
|
||||
})
|
||||
.then(() => readStateViaApi(ctx))
|
||||
.then((data) => {
|
||||
const verlauf = verlaufOf(data, student.uid);
|
||||
expect(verlauf.antrittCount, "die Übernahme und die Prüfung sind zwei Antritte").to.eq(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("schreibt ohne CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME nur die LV-Note", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", false);
|
||||
|
||||
const student = ctx.students[1];
|
||||
|
||||
resetNotenState(ctx);
|
||||
|
||||
notenApi
|
||||
.saveLvNote(ctx.lvId, ctx.semKurzbz, student.uid, ctx.noten.negativ, null, baselineDate(ctx))
|
||||
.then((response) => {
|
||||
const { lvgesamtnote, verlauf } = expectNotenSuccess(response, "saveLvNote")[student.uid];
|
||||
expect(String(lvgesamtnote.note), "die LV-Note").to.eq(String(ctx.noten.negativ));
|
||||
expect(verlauf.antrittCount, "die LV-Note zählt als Antritt 1").to.eq(1);
|
||||
});
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
expect(pruefungenOf(data, student.uid), "kein Termin").to.have.length(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Without ERSTANTRITT_BEI_UEBERNAHME the LV-Note stays the implicit Antritt 1. The first Pruefung
|
||||
// writes that row and becomes Antritt 2 itself.
|
||||
it("legt ohne CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME Antritt 1 mit dem ersten Termin an", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", false);
|
||||
requireRepeat(this, ctx);
|
||||
|
||||
const student = ctx.students[2];
|
||||
|
||||
resetNotenState(ctx);
|
||||
notenApi
|
||||
.saveLvNote(ctx.lvId, ctx.semKurzbz, student.uid, ctx.noten.negativ)
|
||||
.then((response) => expectNotenSuccess(response, "übernehmen ohne Erstantritt"));
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.notenScale[1], datum: antrittDate(ctx, 1) }).then((response) =>
|
||||
expectNotenSuccess(response, "erster Termin"),
|
||||
);
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const pruefungen = pruefungenOf(data, student.uid);
|
||||
expect(pruefungen, "Antritt 1 und der neue Termin").to.have.length(2);
|
||||
expect(String(pruefungen[0].note), "Antritt 1 trägt die LV-Note").to.eq(String(ctx.noten.negativ));
|
||||
expect(
|
||||
pruefungen.map((p) => p.antritt_nr),
|
||||
"Antrittsnummern",
|
||||
).to.deep.eq([1, 2]);
|
||||
expect(verlaufOf(data, student.uid).antrittCount, "zwei Antritte").to.eq(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// One query reads all LV-Noten of the LV. The result for each student stays the same.
|
||||
describe("Lesedaten", () => {
|
||||
it("liefert LV-Note und Zeitstempel je Studierendem", () => {
|
||||
const [freigegeben, open, withoutNote] = ctx.students;
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, freigegeben, { freigegeben: true });
|
||||
seedBaseline(ctx, open, { freigegeben: false, erstantritt: false });
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const f = studentOf(data, freigegeben.uid);
|
||||
expect(f.lv_note, "LV-Note der freigegebenen Zeile").to.exist;
|
||||
expect(f.freigabedatum, "freigabedatum der freigegebenen Zeile").to.exist;
|
||||
expect(f.benotungsdatum, "benotungsdatum der freigegebenen Zeile").to.exist;
|
||||
|
||||
const o = studentOf(data, open.uid);
|
||||
expect(o.lv_note, "LV-Note der offenen Zeile").to.exist;
|
||||
expect(o.benotungsdatum, "benotungsdatum der offenen Zeile").to.exist;
|
||||
expect(o.freigabedatum, "die offene Zeile ist nicht freigegeben").to.be.oneOf([null, undefined, ""]);
|
||||
|
||||
expect((studentOf(data, withoutNote.uid) || {}).lv_note, "keine LV-Note").to.be.oneOf([
|
||||
null,
|
||||
undefined,
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// A single repeat without Antritt 1, as the StV can leave it. The Benotungstool no longer creates
|
||||
// this state: its first Pruefung writes Antritt 1.
|
||||
describe("eine einzelne Wiederholung", () => {
|
||||
const student = () => ctx.students[3];
|
||||
// g2: the Note of the repeat, g3: the new LV-Note
|
||||
const g2 = () =>
|
||||
ctx.noten.positiv ??
|
||||
ctx.noten.bestnote ??
|
||||
ctx.notenScale.find((n) => String(n) !== String(ctx.noten.negativ));
|
||||
const g3 = () => ctx.notenScale.find((n) => String(n) !== String(g2()));
|
||||
|
||||
/** The LV-Note without Antritt 1, and one repeat. Yields the id of the repeat. */
|
||||
const givenWiederholung = () => {
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student(), { erstantritt: false });
|
||||
|
||||
return seedPruefung(ctx, student(), { note: g2(), datum: antrittDate(ctx, 1), type: "Termin2" }).then(
|
||||
(seeded) => seeded.pruefungId,
|
||||
);
|
||||
};
|
||||
|
||||
it("sperrt die Übernahme", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_VORSCHLAG_NACH_WIEDERHOLUNG", false);
|
||||
|
||||
let wiederholungId;
|
||||
givenWiederholung().then((id) => {
|
||||
wiederholungId = id;
|
||||
});
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
expect(verlaufOf(data, student().uid).hasRepeat, "hasRepeat").to.be.true;
|
||||
expect(verlaufOf(data, student().uid).lvNoteLocked, "lvNoteLocked").to.be.true;
|
||||
});
|
||||
|
||||
notenApi
|
||||
.saveLvNote(ctx.lvId, ctx.semKurzbz, student().uid, g3(), null, baselineDate(ctx))
|
||||
.then((response) => expectNotenError(response, "c4notenvorschlagGesperrt"));
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const pruefung = pruefungenOf(data, student().uid).find(
|
||||
(p) => String(p.pruefung_id) === String(wiederholungId),
|
||||
);
|
||||
expect(String(pruefung.note), "die Wiederholung behält ihre Note").to.eq(String(g2()));
|
||||
expect(String(pruefung.datum).slice(0, 10), "die Wiederholung behält ihr Datum").to.eq(
|
||||
antrittDate(ctx, 1),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("übernimmt die Note trotz Wiederholung, wenn der Schalter an ist", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_VORSCHLAG_NACH_WIEDERHOLUNG", true);
|
||||
|
||||
givenWiederholung();
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
expect(verlaufOf(data, student().uid).lvNoteLocked, "lvNoteLocked").to.be.false;
|
||||
});
|
||||
|
||||
notenApi
|
||||
.saveLvNote(ctx.lvId, ctx.semKurzbz, student().uid, g3(), null, baselineDate(ctx))
|
||||
.then((response) => expectNotenSuccess(response, "saveLvNote nach einer Wiederholung"));
|
||||
|
||||
readLvGesamtnoteViaDb(ctx, student()).then((row) => {
|
||||
expect(String(row.note), "die neue LV-Note").to.eq(String(g3()));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("eine bereits freigegebene Note neu benoten", () => {
|
||||
// a final Freigabe forbids exactly this, see freigabe.cy.js
|
||||
beforeEach(function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_FREIGABE_FINAL", false);
|
||||
});
|
||||
|
||||
it("wechselt den Status von freigegeben auf geändert", () => {
|
||||
const student = ctx.students[1];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, {
|
||||
note: ctx.notenScale[0],
|
||||
freigegeben: true,
|
||||
erstantritt: false,
|
||||
benotungsdatum: baselineBenotungsdatum(ctx),
|
||||
});
|
||||
|
||||
// baseline: benotungsdatum == freigabedatum -> freigegeben, not changed
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const lvNote = studentOf(data, student.uid);
|
||||
expect(lvNote.lv_note, "die freigegebene Note ist sichtbar").to.exist;
|
||||
expect(
|
||||
new Date(lvNote.benotungsdatum) > new Date(lvNote.freigabedatum),
|
||||
"baseline must not already look changed",
|
||||
).to.be.false;
|
||||
});
|
||||
|
||||
notenApi.saveLvNote(ctx.lvId, ctx.semKurzbz, student.uid, ctx.notenScale[1]).then((response) => {
|
||||
expectNotenSuccess(response, "eine neue LV-Note nach der Freigabe");
|
||||
});
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const lvNote = studentOf(data, student.uid);
|
||||
|
||||
expect(String(lvNote.lv_note), "die neue LV-Note ist gespeichert").to.eq(String(ctx.notenScale[1]));
|
||||
expect(lvNote.freigabedatum, "das alte freigabedatum bleibt").to.exist;
|
||||
expect(
|
||||
new Date(lvNote.benotungsdatum) > new Date(lvNote.freigabedatum),
|
||||
"benotungsdatum must now be newer than freigabedatum (state: changed)",
|
||||
).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
it("überschreibt die Note, statt eine zweite Zeile anzulegen", () => {
|
||||
const student = ctx.students[1];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, {
|
||||
note: ctx.notenScale[0],
|
||||
freigegeben: true,
|
||||
erstantritt: false,
|
||||
});
|
||||
|
||||
notenApi.saveLvNote(ctx.lvId, ctx.semKurzbz, student.uid, ctx.notenScale[1]);
|
||||
|
||||
readLvGesamtnoteViaDb(ctx, student).then((row) => {
|
||||
expect(row, "die einzige lvgesamtnote-Zeile").to.not.be.null;
|
||||
expect(String(row.note)).to.eq(String(ctx.notenScale[1]));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* The Notenschluessel: Punkte to Note. Read only.
|
||||
*
|
||||
* The test does not rebuild the mapping. It scans the Punkte range and checks the properties of a
|
||||
* valid scale: no gap, monotonic, and exact at every limit.
|
||||
*/
|
||||
|
||||
import { notenApi, loginAsLektor } from "../../../../support/api/notenApi";
|
||||
import { expectBulkRowError, expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
|
||||
import { requirePunkteMode, skipIf } from "../../../../support/helpers/notenConfig";
|
||||
import {
|
||||
antrittDate,
|
||||
loadNotenContext,
|
||||
requireDbReset,
|
||||
resetNotenState,
|
||||
seedBaseline,
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
import { pruefungenOf, givenBaseline, readStateViaApi } from "../../../../support/helpers/notenScenario";
|
||||
|
||||
const MAX_PUNKTE = 100;
|
||||
|
||||
describe("Noten API - Notenschlüssel (getNoteByPunkte)", () => {
|
||||
let ctx;
|
||||
let lvId;
|
||||
|
||||
before(() => {
|
||||
loadNotenContext().then((loaded) => {
|
||||
ctx = loaded;
|
||||
lvId = ctx.lvId;
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => loginAsLektor());
|
||||
|
||||
/** Sweeps 0..MAX_PUNKTE and returns [{ punkte, note }] as the server reports it. */
|
||||
const sweepScale = () => {
|
||||
const observed = [];
|
||||
|
||||
for (let punkte = 0; punkte <= MAX_PUNKTE; punkte += 1) {
|
||||
notenApi.getNoteByPunkte(lvId, ctx.semKurzbz, punkte).then((response) => {
|
||||
const note = expectNotenSuccess(response, `getNoteByPunkte(${punkte})`);
|
||||
observed.push({ punkte, note });
|
||||
});
|
||||
}
|
||||
|
||||
return cy.then(() => observed);
|
||||
};
|
||||
|
||||
it("bildet jeden Punktewert auf eine monotone und exakte Notenskala ab", () => {
|
||||
sweepScale().then((observed) => {
|
||||
const withNote = observed.filter((entry) => entry.note !== null && entry.note !== undefined);
|
||||
|
||||
expect(
|
||||
withNote.length,
|
||||
`LV ${lvId} in ${ctx.semKurzbz} has no Notenschlüssel - getNoteByPunkte returned null ` +
|
||||
`for every value in 0..${MAX_PUNKTE}. Pin NOTEN_LV_ID to an LV that has one.`,
|
||||
).to.be.greaterThan(0);
|
||||
|
||||
// Below the lowest limit the server returns no Note. That block must be at the start:
|
||||
// no Note AFTER a Note is a gap in the scale
|
||||
const firstWithNoteIndex = observed.findIndex((e) => e.note !== null && e.note !== undefined);
|
||||
observed.slice(firstWithNoteIndex).forEach((entry) => {
|
||||
expect(
|
||||
entry.note,
|
||||
`${entry.punkte} Punkte are inside the range with a Note and must give a Note`,
|
||||
).to.not.be.oneOf([null, undefined]);
|
||||
});
|
||||
|
||||
// monotonic: the ids go from 1 (best) to 5, so the number must never go up
|
||||
for (let i = 1; i < withNote.length; i += 1) {
|
||||
expect(
|
||||
Number(withNote[i].note),
|
||||
`the Note at ${withNote[i].punkte} Punkte must not be worse than at ${withNote[i - 1].punkte}`,
|
||||
).to.be.at.most(Number(withNote[i - 1].note));
|
||||
}
|
||||
|
||||
// report each step on its own, so a moved limit shows in the output
|
||||
const boundaries = [];
|
||||
for (let i = 1; i < withNote.length; i += 1) {
|
||||
if (Number(withNote[i].note) !== Number(withNote[i - 1].note)) {
|
||||
boundaries.push({
|
||||
punkte: withNote[i].punkte,
|
||||
note: withNote[i].note,
|
||||
previousNote: withNote[i - 1].note,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
cy.log(`Notenschlüssel of LV ${lvId}: ${boundaries.map((b) => `>=${b.punkte} -> ${b.note}`).join(", ")}`);
|
||||
|
||||
expect(
|
||||
boundaries.length,
|
||||
"a usable Notenschlüssel has at least one Note limit in the scanned range",
|
||||
).to.be.greaterThan(0);
|
||||
|
||||
// re-assert each boundary individually so a failure names the exact threshold
|
||||
boundaries.forEach((boundary) => {
|
||||
notenApi.getNoteByPunkte(lvId, ctx.semKurzbz, boundary.punkte).then((response) => {
|
||||
const note = expectNotenSuccess(response, `Schwelle ${boundary.punkte}`);
|
||||
expect(Number(note), `die Note genau an der Schwelle von ${boundary.punkte} Punkten`).to.eq(
|
||||
Number(boundary.note),
|
||||
);
|
||||
});
|
||||
|
||||
notenApi.getNoteByPunkte(lvId, ctx.semKurzbz, boundary.punkte - 1).then((response) => {
|
||||
const note = expectNotenSuccess(response, `just below ${boundary.punkte}`);
|
||||
expect(
|
||||
Number(note),
|
||||
`one Punkt below the limit of ${boundary.punkte} Punkte must still be the worse Note`,
|
||||
).to.eq(Number(boundary.previousNote));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Punkte without a derivable Note: every endpoint rejects them the same way.
|
||||
describe("Punkte ohne ableitbare Note", () => {
|
||||
let thresholdBelowZero = false;
|
||||
|
||||
before(() => {
|
||||
notenApi.getNoteByPunkte(lvId, ctx.semKurzbz, -1).then((response) => {
|
||||
const note = expectNotenSuccess(response, "getNoteByPunkte(-1)");
|
||||
thresholdBelowZero = note !== null && note !== undefined;
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(function () {
|
||||
requirePunkteMode(this, ctx);
|
||||
skipIf(this, thresholdBelowZero, "Übersprungen: der Notenschlüssel hat eine Schwelle unter 0 Punkten.");
|
||||
requireDbReset();
|
||||
});
|
||||
|
||||
const expectOnlyAntritt1 = (students) =>
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
students.forEach((s) => {
|
||||
expect(pruefungenOf(data, s.uid), `Prüfungen von ${s.uid}`).to.have.length(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("savePruefung lehnt negative Punkte ab", () => {
|
||||
const student = ctx.students[0];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
notenApi
|
||||
.savePruefung(lvId, ctx.semKurzbz, student.uid, {
|
||||
pruefung_id: null,
|
||||
lehreinheit_id: student.lehreinheit_id,
|
||||
datum: antrittDate(ctx, 1),
|
||||
note: ctx.notenScale[0],
|
||||
punkte: -1,
|
||||
})
|
||||
.then((response) => expectNotenError(response, "c4punkteKeineNoteErmittelt"));
|
||||
|
||||
expectOnlyAntritt1([student]);
|
||||
});
|
||||
|
||||
it("createPruefungen lehnt negative Punkte für alle gewählten Studierenden ab", () => {
|
||||
const students = ctx.students.slice(0, 2);
|
||||
|
||||
resetNotenState(ctx);
|
||||
students.forEach((s) => seedBaseline(ctx, s));
|
||||
|
||||
notenApi
|
||||
.createPruefungen(
|
||||
lvId,
|
||||
ctx.semKurzbz,
|
||||
students.map((s) => ({ uid: s.uid, lehreinheit_id: s.lehreinheit_id })),
|
||||
{ datum: antrittDate(ctx, 1), note: null, punkte: -1 },
|
||||
)
|
||||
.then((response) => {
|
||||
const data = expectNotenSuccess(response, "createPruefungen mit negativen Punkten");
|
||||
students.forEach((s) => expectBulkRowError(data, s.uid, "c4punkteKeineNoteErmittelt"));
|
||||
});
|
||||
|
||||
expectOnlyAntritt1(students);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* The overwrite rules of the proposal column.
|
||||
*
|
||||
* Three rules: the editor list offers only Noten of the Lehre; the column locks after a repeat; and a
|
||||
* Zeugnisnote with lkt_ueberschreibbar = false also locks it. Benotungstool.js AND Noten::validateLvNote
|
||||
* both have all three, because the direct API call and the CSV import never reach the client.
|
||||
*/
|
||||
|
||||
import { expectBulkRowError, expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
|
||||
import { requireConfig, requireNotenMode, requireRepeat, skipIf } from "../../../../support/helpers/notenConfig";
|
||||
import {
|
||||
antrittDate,
|
||||
loadNotenContext,
|
||||
readLvGesamtnoteViaDb,
|
||||
requireDbReset,
|
||||
resetNotenState,
|
||||
seedZeugnisnote,
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
import { addPruefung, pruefungenOf, givenBaseline, readStateViaApi } from "../../../../support/helpers/notenScenario";
|
||||
import { notenApi, loginAsLektor } from "../../../../support/api/notenApi";
|
||||
|
||||
describe("Noten API - Regeln zum Überschreiben der LV-Note", () => {
|
||||
let ctx;
|
||||
|
||||
before(() => {
|
||||
requireDbReset();
|
||||
loadNotenContext().then((loaded) => {
|
||||
ctx = loaded;
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => loginAsLektor());
|
||||
|
||||
it("lehnt eine Note ab, die die Auswahlliste nicht anbietet", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_LVNOTE_NUR_LEHRENOTEN", true);
|
||||
// on this instance every active Note can be a lehre Note
|
||||
skipIf(this, ctx.noten.notLehre === null, "Übersprungen: keine Verwaltungsnote in tbl_note.");
|
||||
|
||||
const student = ctx.students[0];
|
||||
|
||||
// without Antritt 1: otherwise the Pruefung rule answers first, and the Note rule stays untested
|
||||
givenBaseline(ctx, student, { erstantritt: false });
|
||||
|
||||
notenApi
|
||||
.saveLvNote(ctx.lvId, ctx.semKurzbz, student.uid, ctx.noten.notLehre)
|
||||
.then((response) => expectNotenError(response, "c4noteNichtInLehre"))
|
||||
.then(() => readLvGesamtnoteViaDb(ctx, student))
|
||||
.then((row) => {
|
||||
expect(String(row.note), "eine Verwaltungsnote wird nicht zur LV-Note").to.eq(
|
||||
String(ctx.noten.negativ),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("lehnt eine Änderung des Notenvorschlags nach einer Prüfung ab", function () {
|
||||
requireRepeat(this, ctx);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_VORSCHLAG_NACH_WIEDERHOLUNG", false);
|
||||
|
||||
const student = ctx.students[1];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.notenScale[1], datum: antrittDate(ctx, 1) }).then((response) => {
|
||||
expectNotenSuccess(response, "eine Prüfung anlegen");
|
||||
});
|
||||
|
||||
// the Note now belongs to the Antritt chain; a direct edit would bypass the Antritt rules
|
||||
notenApi
|
||||
.saveLvNote(ctx.lvId, ctx.semKurzbz, student.uid, ctx.notenScale[0])
|
||||
.then((response) => expectNotenError(response, "c4notenvorschlagGesperrt"))
|
||||
.then(() => readLvGesamtnoteViaDb(ctx, student))
|
||||
.then((row) => {
|
||||
expect(String(row.note), "die Note des Antritts bleibt erhalten").to.eq(String(ctx.notenScale[1]));
|
||||
});
|
||||
});
|
||||
|
||||
it("lehnt das Überschreiben einer gesperrten Zeugnisnote ab", function () {
|
||||
// on this instance the Lektor may overwrite every active Note
|
||||
skipIf(this, ctx.noten.notUeberschreibbar === null, "Übersprungen: keine gesperrte Note in tbl_note.");
|
||||
|
||||
const student = ctx.students[4];
|
||||
|
||||
givenBaseline(ctx, student, { erstantritt: false });
|
||||
seedZeugnisnote(ctx, student, ctx.noten.notUeberschreibbar);
|
||||
|
||||
notenApi
|
||||
.saveLvNote(ctx.lvId, ctx.semKurzbz, student.uid, ctx.notenScale[1])
|
||||
.then((response) => expectNotenError(response, "c4zeugnisnoteGesperrt"))
|
||||
.then(() => readLvGesamtnoteViaDb(ctx, student))
|
||||
.then((row) => {
|
||||
expect(String(row.note), "die LV-Note ändert sich nicht").to.eq(String(ctx.noten.negativ));
|
||||
});
|
||||
});
|
||||
|
||||
// A Pruefung writes the same LV-Note as the proposal and checks the same Note rules.
|
||||
describe("Prüfungspfade", () => {
|
||||
/** An administrative Note that uses an Antritt. Noten without an Antritt stay allowed. */
|
||||
const skipWithoutVerwaltungsnote = (test) => {
|
||||
requireConfig(test, ctx, "CIS_GESAMTNOTE_LVNOTE_NUR_LEHRENOTEN", true);
|
||||
|
||||
const withoutAntritt = (ctx.cisConfig.NOTEN_OHNE_ANTRITT || []).map(String);
|
||||
const usable = ctx.noten.notLehre !== null && !withoutAntritt.includes(String(ctx.noten.notLehre));
|
||||
|
||||
skipIf(test, !usable, "Übersprungen: keine Verwaltungsnote, die einen Antritt verbraucht.");
|
||||
};
|
||||
|
||||
const expectOnlyAntritt1 = (student) =>
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
expect(pruefungenOf(data, student.uid), "nur Antritt 1").to.have.length(1);
|
||||
});
|
||||
|
||||
it("lehnt einen Termin mit einer Verwaltungsnote ab", function () {
|
||||
skipWithoutVerwaltungsnote(this);
|
||||
requireRepeat(this, ctx);
|
||||
|
||||
const student = ctx.students[0];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
addPruefung(ctx, student, { note: ctx.noten.notLehre, datum: antrittDate(ctx, 1) }).then((response) =>
|
||||
expectNotenError(response, "c4noteNichtInLehre"),
|
||||
);
|
||||
|
||||
expectOnlyAntritt1(student);
|
||||
readLvGesamtnoteViaDb(ctx, student).then((row) => {
|
||||
expect(String(row.note), "die LV-Note bleibt").to.eq(String(ctx.noten.negativ));
|
||||
});
|
||||
});
|
||||
|
||||
it("lehnt einen Termin bei gesperrter Zeugnisnote ab", function () {
|
||||
requireRepeat(this, ctx);
|
||||
|
||||
// an Anrechnung Note answers "c4angerechnetKeinePruefung", so the test leaves it out
|
||||
const anrechnung = (ctx.cisConfig.NOTEN_ANRECHNUNG || []).map(String);
|
||||
const locked = ctx.notenOptions.find(
|
||||
(n) => n.lkt_ueberschreibbar === false && !anrechnung.includes(String(n.note)),
|
||||
);
|
||||
skipIf(this, !locked, "Übersprungen: keine gesperrte Note ausser den Anrechnungsnoten.");
|
||||
|
||||
const student = ctx.students[1];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
seedZeugnisnote(ctx, student, locked.note);
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, 1) }).then((response) =>
|
||||
expectNotenError(response, "c4zeugnisnoteGesperrt"),
|
||||
);
|
||||
|
||||
expectOnlyAntritt1(student);
|
||||
});
|
||||
|
||||
it("lehnt im Sammeldialog jede Zeile mit einer Verwaltungsnote ab", function () {
|
||||
skipWithoutVerwaltungsnote(this);
|
||||
|
||||
const students = [ctx.students[0], ctx.students[1]];
|
||||
|
||||
resetNotenState(ctx);
|
||||
|
||||
notenApi
|
||||
.createPruefungen(
|
||||
ctx.lvId,
|
||||
ctx.semKurzbz,
|
||||
students.map((s) => ({ uid: s.uid, lehreinheit_id: s.lehreinheit_id })),
|
||||
{ datum: antrittDate(ctx, 1), note: ctx.noten.notLehre },
|
||||
)
|
||||
.then((response) => {
|
||||
const data = expectNotenSuccess(response, "createPruefungen mit einer Verwaltungsnote");
|
||||
students.forEach((s) => expectBulkRowError(data, s.uid, "c4noteNichtInLehre"));
|
||||
});
|
||||
|
||||
students.forEach((s) =>
|
||||
readLvGesamtnoteViaDb(ctx, s).then((row) => {
|
||||
expect(row, `keine LV-Note für ${s.uid}`).to.be.null;
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// The same flow through the CSV import, the way an Assistenz works. The bulk endpoint returns
|
||||
// HTTP 200 and reports the rejected row in data[uid].error.
|
||||
describe("importLvNoten", () => {
|
||||
beforeEach(function () {
|
||||
// in the Punkte mode the import derives the Note from the Punkte and drops a row without
|
||||
// Punkte before these rules apply
|
||||
requireNotenMode(this, ctx);
|
||||
requireRepeat(this, ctx);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENIMPORT", true);
|
||||
});
|
||||
|
||||
it("lehnt je Zeile eine Änderung des Notenvorschlags nach einer Prüfung ab", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_VORSCHLAG_NACH_WIEDERHOLUNG", false);
|
||||
|
||||
const student = ctx.students[3];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.notenScale[1], datum: antrittDate(ctx, 1) }).then((response) => {
|
||||
expectNotenSuccess(response, "eine Prüfung anlegen");
|
||||
});
|
||||
|
||||
notenApi
|
||||
.importLvNoten(ctx.lvId, ctx.semKurzbz, [{ uid: student.uid, note: ctx.notenScale[0], punkte: null }])
|
||||
.then((response) => {
|
||||
const data = expectNotenSuccess(response, "importLvNoten");
|
||||
expectBulkRowError(data, student.uid, "c4notenvorschlagGesperrt");
|
||||
})
|
||||
.then(() => readLvGesamtnoteViaDb(ctx, student))
|
||||
.then((row) => {
|
||||
expect(String(row.note), "die Note des Antritts überlebt den Import").to.eq(
|
||||
String(ctx.notenScale[1]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,445 @@
|
||||
/**
|
||||
* The write path (savePruefungForStudent) that savePruefung, createPruefungen and
|
||||
* importPruefungen share. The other specs cover the rules.
|
||||
*
|
||||
* Invariant: one action writes exactly one Pruefung. Exception: next to an LV-Note without a Pruefung
|
||||
* row, the first new Pruefung first writes Antritt 1.
|
||||
*/
|
||||
|
||||
import { expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
|
||||
import { requireConfig, requireRepeat } from "../../../../support/helpers/notenConfig";
|
||||
import {
|
||||
antrittDate,
|
||||
baselineDate,
|
||||
loadNotenContext,
|
||||
readLvGesamtnoteViaDb,
|
||||
requireDbReset,
|
||||
resetNotenState,
|
||||
seedPruefung,
|
||||
shiftDate,
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
import {
|
||||
addPruefung,
|
||||
pruefungenOf,
|
||||
editPruefung,
|
||||
givenBaseline,
|
||||
readStateViaApi,
|
||||
verlaufOf,
|
||||
} from "../../../../support/helpers/notenScenario";
|
||||
import { notenApi, loginAsLektor } from "../../../../support/api/notenApi";
|
||||
|
||||
const dayOf = (value) => String(value).slice(0, 10);
|
||||
|
||||
describe("Noten API - Prüfungstermin (Schreibpfad)", () => {
|
||||
let ctx;
|
||||
|
||||
before(() => {
|
||||
requireDbReset();
|
||||
loadNotenContext().then((loaded) => {
|
||||
ctx = loaded;
|
||||
expect(ctx.maxAntritte, "Platz für mindestens eine Wiederholung").to.be.greaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => loginAsLektor());
|
||||
|
||||
it("schreibt je Anlage genau eine Prüfung, ohne Kopie daneben", function () {
|
||||
requireRepeat(this, ctx);
|
||||
|
||||
const student = ctx.students[0];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.notenScale[1], datum: antrittDate(ctx, 1) })
|
||||
.then((response) => {
|
||||
const { pruefung, lvgesamtnote, verlauf } = expectNotenSuccess(response, "Prüfung anlegen")[
|
||||
student.uid
|
||||
];
|
||||
|
||||
expect(pruefung, "die gespeicherte Prüfung").to.exist;
|
||||
expect(String(pruefung.note)).to.eq(String(ctx.notenScale[1]));
|
||||
expect(lvgesamtnote, "die Antwort trägt die LV-Note für die Zeile").to.exist;
|
||||
expect(verlauf, "die Antwort trägt den Verlauf").to.exist;
|
||||
expect(verlauf.pruefungen, "der Verlauf trägt die Antritte").to.be.an("array");
|
||||
})
|
||||
.then(() => readStateViaApi(ctx))
|
||||
.then((data) => {
|
||||
const pruefungen = pruefungenOf(data, student.uid);
|
||||
expect(pruefungen, "Antritt 1 der Vorlage und genau eine neue Zeile").to.have.length(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("leitet Position und Antrittsnummer auf dem Server ab", function () {
|
||||
requireRepeat(this, ctx);
|
||||
|
||||
const student = ctx.students[1];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, 1) })
|
||||
.then(() => readStateViaApi(ctx))
|
||||
.then((data) => {
|
||||
const pruefungen = pruefungenOf(data, student.uid);
|
||||
|
||||
expect(
|
||||
pruefungen.map((p) => p.position),
|
||||
"positions are 1..n in date order",
|
||||
).to.deep.eq([1, 2]);
|
||||
expect(
|
||||
pruefungen.map((p) => p.antritt_nr),
|
||||
"beide sind Antritte",
|
||||
).to.deep.eq([1, 2]);
|
||||
pruefungen.forEach((p) => expect(p.is_antritt, `Antritt an Position ${p.position}`).to.be.true);
|
||||
|
||||
const verlauf = verlaufOf(data, student.uid);
|
||||
expect(verlauf.antrittCount, "beide Antritte gezählt").to.eq(2);
|
||||
expect(verlauf.maxAntritte, "die Grenze kommt vom Server").to.eq(ctx.maxAntritte);
|
||||
// the last Antritt is kommissionell; if the Benotungstool cannot create it, the chain ends here
|
||||
const creatable =
|
||||
ctx.cisConfig.CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF !== false
|
||||
? ctx.maxAntritte
|
||||
: ctx.maxAntritte - 1;
|
||||
expect(verlauf.canAdd, `canAdd bei 2 von ${creatable} möglichen`).to.eq(2 < creatable);
|
||||
});
|
||||
});
|
||||
|
||||
it("hängt je Anlage eine neue Zeile an, statt die vorherige zu überschreiben", function () {
|
||||
requireRepeat(this, ctx);
|
||||
|
||||
// only an explicit pruefung_id updates; an add always inserts a row
|
||||
const student = ctx.students[0];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
let firstId;
|
||||
addPruefung(ctx, student, { note: ctx.noten.entschuldigt, datum: antrittDate(ctx, 1) })
|
||||
.then((response) => {
|
||||
firstId = expectNotenSuccess(response, "erste Anlage")[student.uid].pruefung.pruefung_id;
|
||||
return addPruefung(ctx, student, { note: ctx.notenScale[1], datum: antrittDate(ctx, 2) });
|
||||
})
|
||||
.then((response) => expectNotenSuccess(response, "zweite Anlage"))
|
||||
.then(() => readStateViaApi(ctx))
|
||||
.then((data) => {
|
||||
const pruefungen = pruefungenOf(data, student.uid);
|
||||
|
||||
expect(pruefungen, "Vorlage und zwei neue Zeilen").to.have.length(3);
|
||||
expect(
|
||||
pruefungen.map((p) => p.pruefung_id),
|
||||
"the first add still exists",
|
||||
).to.include(firstId);
|
||||
});
|
||||
});
|
||||
|
||||
it("bearbeitet die Prüfung mit der übergebenen pruefung_id", () => {
|
||||
// an entschuldigt and a real Pruefung side by side: no endpoint builds this, so the test seeds it
|
||||
const student = ctx.students[2];
|
||||
const entschuldigtDate = antrittDate(ctx, 1);
|
||||
const withNoteDate = antrittDate(ctx, 2);
|
||||
const movedTo = shiftDate(entschuldigtDate, 5);
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
let entschuldigtId;
|
||||
seedPruefung(ctx, student, { note: ctx.noten.entschuldigt, datum: entschuldigtDate, type: "Termin2" })
|
||||
.then((seeded) => {
|
||||
entschuldigtId = seeded.pruefungId;
|
||||
return seedPruefung(ctx, student, { note: ctx.notenScale[0], datum: withNoteDate, type: "Termin2" });
|
||||
})
|
||||
// note stays entschuldigt: a later pruefung exists, so only the datum may move
|
||||
.then(() =>
|
||||
editPruefung(ctx, student, {
|
||||
pruefungId: entschuldigtId,
|
||||
note: ctx.noten.entschuldigt,
|
||||
datum: movedTo,
|
||||
}),
|
||||
)
|
||||
.then((response) => expectNotenSuccess(response, "die entschuldigte Prüfung verschieben"))
|
||||
.then(() => readStateViaApi(ctx))
|
||||
.then((data) => {
|
||||
const rows = pruefungenOf(data, student.uid);
|
||||
const entschuldigt = rows.find((p) => p.pruefung_id === entschuldigtId);
|
||||
const withNote = rows.find((p) => dayOf(p.datum) === withNoteDate);
|
||||
|
||||
// the other row first: a silent change of a real Note is the worse error
|
||||
expect(withNote, "der andere Antritt bleibt").to.exist;
|
||||
expect(String(withNote.note), "die andere Prüfung behält ihre Note").to.eq(String(ctx.notenScale[0]));
|
||||
|
||||
expect(entschuldigt, "die entschuldigte Prüfung bleibt").to.exist;
|
||||
expect(dayOf(entschuldigt.datum), "die Zeile mit dieser pruefung_id ist verschoben").to.eq(movedTo);
|
||||
});
|
||||
});
|
||||
|
||||
it("schreibt die LV-Note aus der Note des Antritts", function () {
|
||||
requireRepeat(this, ctx);
|
||||
|
||||
const student = ctx.students[3];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.notenScale[1], datum: antrittDate(ctx, 1) })
|
||||
.then((response) => expectNotenSuccess(response, "eine Prüfung mit Note"))
|
||||
.then(() => readLvGesamtnoteViaDb(ctx, student))
|
||||
.then((row) => {
|
||||
expect(String(row.note), "die LV-Note folgt dem letzten Antritt").to.eq(String(ctx.notenScale[1]));
|
||||
});
|
||||
});
|
||||
|
||||
// An LV-Note without a Pruefung row is Antritt 1: from a proposal without ERSTANTRITT_BEI_UEBERNAHME,
|
||||
// or from the time before the Benotungstool. Without that row the count misses it, and the student
|
||||
// gets one Antritt too many.
|
||||
it("schreibt Antritt 1 aus einer LV-Note ohne Prüfungszeile nach", function () {
|
||||
requireRepeat(this, ctx);
|
||||
|
||||
const student = ctx.students[4];
|
||||
|
||||
givenBaseline(ctx, student, { erstantritt: false });
|
||||
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, 1) }).then((response) =>
|
||||
expectNotenSuccess(response, "erster Termin neben der LV-Note"),
|
||||
);
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const pruefungen = pruefungenOf(data, student.uid);
|
||||
expect(pruefungen, "Antritt 1 und der neue Termin").to.have.length(2);
|
||||
expect(String(pruefungen[0].note), "Antritt 1 trägt die LV-Note").to.eq(String(ctx.noten.negativ));
|
||||
expect(dayOf(pruefungen[0].datum) < antrittDate(ctx, 1), "Antritt 1 liegt vor dem neuen Termin").to.be.true;
|
||||
expect(
|
||||
pruefungen.map((p) => p.antritt_nr),
|
||||
"Antrittsnummern",
|
||||
).to.deep.eq([1, 2]);
|
||||
expect(verlaufOf(data, student.uid).antrittCount, "zwei Antritte").to.eq(2);
|
||||
});
|
||||
});
|
||||
|
||||
// Rule: the LV-Note is the Note of the last Pruefung that counts as an Antritt. Without such a
|
||||
// Pruefung the implicit Antritt 1 stays, otherwise the LV-Note is "Noch nicht eingetragen".
|
||||
// The LV-Note is never "entschuldigt".
|
||||
describe("LV-Note nach einem Termin", () => {
|
||||
const g2 = () =>
|
||||
ctx.noten.positiv ??
|
||||
ctx.noten.bestnote ??
|
||||
ctx.notenScale.find((n) => String(n) !== String(ctx.noten.negativ));
|
||||
|
||||
const expectLvNote = (student, note, message) =>
|
||||
readLvGesamtnoteViaDb(ctx, student).then((row) => {
|
||||
expect(String(row.note), message).to.eq(String(note));
|
||||
return row;
|
||||
});
|
||||
|
||||
const expectAntritte = (student, count) =>
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
expect(verlaufOf(data, student.uid).antrittCount, "antrittCount").to.eq(count);
|
||||
});
|
||||
|
||||
it("behält die LV-Note bei einer Datumskorrektur an Termin 1", function () {
|
||||
requireRepeat(this, ctx);
|
||||
|
||||
const student = ctx.students[0];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
addPruefung(ctx, student, { note: g2(), datum: antrittDate(ctx, 1) }).then((response) =>
|
||||
expectNotenSuccess(response, "Termin 2"),
|
||||
);
|
||||
|
||||
readStateViaApi(ctx)
|
||||
.then((data) => {
|
||||
const t1 = pruefungenOf(data, student.uid)[0];
|
||||
// the Note stays the same, otherwise validateEdit answers pruefungNoteLocked
|
||||
return editPruefung(ctx, student, {
|
||||
pruefungId: t1.pruefung_id,
|
||||
note: t1.note,
|
||||
datum: shiftDate(baselineDate(ctx), 1),
|
||||
});
|
||||
})
|
||||
.then((response) => expectNotenSuccess(response, "Datumskorrektur an Termin 1"));
|
||||
|
||||
expectLvNote(student, g2(), "die LV-Note bleibt beim Ergebnis von Termin 2");
|
||||
});
|
||||
|
||||
it("behält die LV-Note bei einem neuen entschuldigten Termin", function () {
|
||||
requireRepeat(this, ctx);
|
||||
|
||||
const student = ctx.students[1];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
addPruefung(ctx, student, { note: ctx.noten.entschuldigt, datum: antrittDate(ctx, 1) }).then((response) =>
|
||||
expectNotenSuccess(response, "entschuldigter Termin"),
|
||||
);
|
||||
|
||||
expectLvNote(student, ctx.noten.negativ, "die LV-Note bleibt beim Ergebnis von Termin 1");
|
||||
expectAntritte(student, 1);
|
||||
});
|
||||
|
||||
// no requireRepeat: the test needs only Antritt 1. With two Antritte and without
|
||||
// ALLOW_CREATE_KOMMPRUEF it would fail
|
||||
it("setzt 'Noch nicht eingetragen', wenn der erste Termin entschuldigt ist", () => {
|
||||
const student = ctx.students[2];
|
||||
|
||||
resetNotenState(ctx);
|
||||
addPruefung(ctx, student, { note: ctx.noten.entschuldigt, datum: antrittDate(ctx, 1) }).then((response) =>
|
||||
expectNotenSuccess(response, "entschuldigter erster Termin"),
|
||||
);
|
||||
|
||||
expectLvNote(student, ctx.noten.nochNichtEingetragen, "die LV-Note ist nie entschuldigt").then((row) => {
|
||||
expect(row.punkte, "ohne Punkte").to.be.null;
|
||||
});
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const verlauf = verlaufOf(data, student.uid);
|
||||
expect(verlauf.antrittCount, "der entschuldigte Termin verbraucht keinen Antritt").to.eq(0);
|
||||
expect(verlauf.canAdd, "ein weiterer Termin ist möglich").to.be.true;
|
||||
});
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, 2) }).then((response) =>
|
||||
expectNotenSuccess(response, "erster zählender Termin"),
|
||||
);
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const counting = pruefungenOf(data, student.uid).find(
|
||||
(p) => String(p.note) === String(ctx.noten.negativ),
|
||||
);
|
||||
expect(counting.antritt_nr, "der Studierende hat keinen Antritt verloren").to.eq(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("setzt 'Noch nicht eingetragen', wenn der einzige Termin nachträglich entschuldigt wird", () => {
|
||||
const student = ctx.students[3];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
readStateViaApi(ctx)
|
||||
.then((data) => {
|
||||
const t1 = pruefungenOf(data, student.uid)[0];
|
||||
return editPruefung(ctx, student, {
|
||||
pruefungId: t1.pruefung_id,
|
||||
note: ctx.noten.entschuldigt,
|
||||
datum: dayOf(t1.datum),
|
||||
});
|
||||
})
|
||||
.then((response) => expectNotenSuccess(response, "Termin 1 wird entschuldigt"));
|
||||
|
||||
expectLvNote(student, ctx.noten.nochNichtEingetragen, "die LV-Note ist nie entschuldigt");
|
||||
// the old LV-Note does not count as the implicit Antritt 1
|
||||
expectAntritte(student, 0);
|
||||
});
|
||||
|
||||
it("fällt auf Termin 1 zurück, wenn die Wiederholung nachträglich entschuldigt wird", function () {
|
||||
requireRepeat(this, ctx);
|
||||
|
||||
const student = ctx.students[0];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
addPruefung(ctx, student, { note: g2(), datum: antrittDate(ctx, 1) }).then((response) =>
|
||||
expectNotenSuccess(response, "Termin 2"),
|
||||
);
|
||||
|
||||
readStateViaApi(ctx)
|
||||
.then((data) => {
|
||||
const t2 = pruefungenOf(data, student.uid)[1];
|
||||
return editPruefung(ctx, student, {
|
||||
pruefungId: t2.pruefung_id,
|
||||
note: ctx.noten.entschuldigt,
|
||||
datum: dayOf(t2.datum),
|
||||
});
|
||||
})
|
||||
.then((response) => expectNotenSuccess(response, "Termin 2 wird entschuldigt"));
|
||||
|
||||
expectLvNote(student, ctx.noten.negativ, "die LV-Note ist wieder das Ergebnis von Termin 1");
|
||||
expectAntritte(student, 1);
|
||||
});
|
||||
|
||||
it("behält eine LV-Note aus Altdaten als Ergebnis von Antritt 1", function () {
|
||||
requireRepeat(this, ctx);
|
||||
|
||||
const student = ctx.students[1];
|
||||
|
||||
givenBaseline(ctx, student, { erstantritt: false });
|
||||
addPruefung(ctx, student, { note: ctx.noten.entschuldigt, datum: antrittDate(ctx, 1) }).then((response) =>
|
||||
expectNotenSuccess(response, "entschuldigter Termin neben der Altdaten-LV-Note"),
|
||||
);
|
||||
|
||||
expectLvNote(student, ctx.noten.negativ, "die Altdaten-LV-Note bleibt");
|
||||
expectAntritte(student, 1);
|
||||
});
|
||||
|
||||
it("behält das letzte Ergebnis bei einem offenen Termin", function () {
|
||||
requireRepeat(this, ctx);
|
||||
|
||||
const student = ctx.students[2];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
addPruefung(ctx, student, { note: ctx.noten.nochNichtEingetragen, datum: antrittDate(ctx, 1) }).then(
|
||||
(response) => expectNotenSuccess(response, "offener Termin"),
|
||||
);
|
||||
|
||||
expectLvNote(student, ctx.noten.negativ, "die LV-Note bleibt beim Ergebnis von Termin 1");
|
||||
});
|
||||
|
||||
it("lehnt 'entschuldigt' als übernommene LV-Note ab", () => {
|
||||
const student = ctx.students[3];
|
||||
|
||||
givenBaseline(ctx, student, { erstantritt: false });
|
||||
notenApi
|
||||
.saveLvNote(ctx.lvId, ctx.semKurzbz, student.uid, ctx.noten.entschuldigt)
|
||||
.then((response) => expectNotenError(response, "c4noteNichtInLehre"));
|
||||
|
||||
expectLvNote(student, ctx.noten.negativ, "die LV-Note bleibt");
|
||||
});
|
||||
});
|
||||
|
||||
// CIS_GESAMTNOTE_PRUEFUNG_HEBT_FREIGABE_AUF: the Freigabe state depends on benotungsdatum > freigabedatum.
|
||||
describe("Freigabe nach einem neuen Termin", () => {
|
||||
beforeEach(function () {
|
||||
// a final Freigabe forbids the new Pruefung
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_FREIGABE_FINAL", false);
|
||||
requireRepeat(this, ctx);
|
||||
});
|
||||
|
||||
const newPruefungAfterFreigabe = (student) => {
|
||||
givenBaseline(ctx, student, { freigegeben: true });
|
||||
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, 1) }).then((response) =>
|
||||
expectNotenSuccess(response, "Termin nach der Freigabe"),
|
||||
);
|
||||
return readLvGesamtnoteViaDb(ctx, student);
|
||||
};
|
||||
|
||||
it("hebt die Freigabe mit einem neuen Termin auf", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNG_HEBT_FREIGABE_AUF", true);
|
||||
|
||||
newPruefungAfterFreigabe(ctx.students[5]).then((row) => {
|
||||
expect(new Date(row.benotungsdatum) > new Date(row.freigabedatum), "benotungsdatum nach freigabedatum")
|
||||
.to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
it("behält die Freigabe bei einem neuen Termin", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNG_HEBT_FREIGABE_AUF", false);
|
||||
|
||||
newPruefungAfterFreigabe(ctx.students[5]).then((row) => {
|
||||
expect(new Date(row.benotungsdatum) > new Date(row.freigabedatum), "benotungsdatum nach freigabedatum")
|
||||
.to.be.false;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("legt die LV-Note an, wenn ein Studierender noch keine hat", () => {
|
||||
// the bulk endpoint runs through the same core and writes the LV-Note before the Pruefung
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
|
||||
notenApi
|
||||
.createPruefungen(ctx.lvId, ctx.semKurzbz, [{ uid: student.uid, lehreinheit_id: student.lehreinheit_id }], {
|
||||
datum: antrittDate(ctx, 1),
|
||||
})
|
||||
.then((response) => {
|
||||
const data = expectNotenSuccess(response, "createPruefungen ohne LV-Note");
|
||||
expect(data[student.uid], `Ergebniszeile von ${student.uid}`).to.be.an("object");
|
||||
expect(data[student.uid].pruefung, "die Prüfung ist geschrieben").to.exist;
|
||||
})
|
||||
.then(() => readLvGesamtnoteViaDb(ctx, student))
|
||||
.then((row) => {
|
||||
expect(row, "die LV-Note entsteht mit").to.not.be.null;
|
||||
expect(String(row.note), "trägt die Note der Prüfung").to.eq(String(ctx.noten.nochNichtEingetragen));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* The configurable rules for the date of a Pruefung.
|
||||
*
|
||||
* No rule value is hardcoded. Each switch has one test per branch. A test skips itself when the other
|
||||
* branch is configured, and a profile in tests/cypress/profiles/noten.js runs it.
|
||||
*/
|
||||
|
||||
import { notenApi, loginAsLektor } from "../../../../support/api/notenApi";
|
||||
import {
|
||||
expectBulkRowAccepted,
|
||||
expectBulkRowError,
|
||||
expectNotenError,
|
||||
expectNotenSuccess,
|
||||
} from "../../../../support/helpers/notenErrors";
|
||||
import { requireConfig, requireNotenMode, requireRepeat, skipIf } from "../../../../support/helpers/notenConfig";
|
||||
import {
|
||||
antrittDate,
|
||||
baselineDate,
|
||||
loadNotenContext,
|
||||
requireDbReset,
|
||||
resetNotenState,
|
||||
seedBaseline,
|
||||
shiftDate,
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
import {
|
||||
addPruefung,
|
||||
pruefungenOf,
|
||||
editPruefung,
|
||||
givenBaseline,
|
||||
readStateViaApi,
|
||||
} from "../../../../support/helpers/notenScenario";
|
||||
|
||||
describe("Noten API - konfigurierbare Terminregeln", () => {
|
||||
let ctx;
|
||||
|
||||
before(() => {
|
||||
loadNotenContext().then((loaded) => {
|
||||
ctx = loaded;
|
||||
cy.log(`TERMIN_GLEICHER_TAG = ${ctx.cisConfig.CIS_GESAMTNOTE_PRUEFUNG_GLEICHER_TAG}`);
|
||||
cy.log(
|
||||
`NOTE_SPERRE_BEI_SPAETEREM_TERMIN = ${ctx.cisConfig.CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETERER_PRUEFUNG}`,
|
||||
);
|
||||
cy.log(`DATUM_ZUKUNFT = ${ctx.cisConfig.CIS_GESAMTNOTE_DATUM_ZUKUNFT}`);
|
||||
cy.log(`ANTRITT_MIN_ABSTAND_TAGE = ${ctx.cisConfig.CIS_GESAMTNOTE_ANTRITT_MIN_ABSTAND_TAGE}`);
|
||||
cy.log(`ANTRITT_MAX_ABSTAND_TAGE = ${ctx.cisConfig.CIS_GESAMTNOTE_ANTRITT_MAX_ABSTAND_TAGE}`);
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => loginAsLektor());
|
||||
|
||||
beforeEach(() => requireDbReset());
|
||||
|
||||
// The rules compare Y-m-d strings. A different format sorts the Pruefungen wrong.
|
||||
describe("Prüfungsdatum", () => {
|
||||
const impossibleDay = () => `${baselineDate(ctx).slice(0, 4)}-02-31`;
|
||||
|
||||
[
|
||||
["einen Kalendertag, den es nicht gibt", impossibleDay],
|
||||
["das Format d.m.Y", () => antrittDate(ctx, 1).split("-").reverse().join(".")],
|
||||
["ein leeres Datum", () => ""],
|
||||
].forEach(([label, datum]) => {
|
||||
it(`lehnt ${label} ab`, () => {
|
||||
const student = ctx.students[0];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: datum() }).then((response) =>
|
||||
expectNotenError(response, "pruefungsdatumUngueltig"),
|
||||
);
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
expect(pruefungenOf(data, student.uid), "nur Antritt 1").to.have.length(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("speichert ein Datum mit Uhrzeit als Kalendertag", function () {
|
||||
requireRepeat(this, ctx);
|
||||
|
||||
const student = ctx.students[1];
|
||||
const datum = antrittDate(ctx, 1);
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: `${datum} 10:00:00` }).then((response) =>
|
||||
expectNotenSuccess(response, "Datum mit Uhrzeit"),
|
||||
);
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const newPruefung = pruefungenOf(data, student.uid)[1];
|
||||
expect(String(newPruefung.datum).slice(0, 10), "der Kalendertag").to.eq(datum);
|
||||
});
|
||||
});
|
||||
|
||||
it("lehnt im Import nur die Zeile mit dem ungültigen Datum ab", function () {
|
||||
requireNotenMode(this, ctx);
|
||||
requireRepeat(this, ctx);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNGSIMPORT", true);
|
||||
|
||||
const valid = ctx.students[0];
|
||||
const invalid = ctx.students[1];
|
||||
const bulkRow = (student, datum) => ({
|
||||
uid: student.uid,
|
||||
note: ctx.noten.negativ,
|
||||
punkte: null,
|
||||
datum,
|
||||
lehreinheit_id: student.lehreinheit_id,
|
||||
});
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, valid);
|
||||
seedBaseline(ctx, invalid);
|
||||
|
||||
notenApi
|
||||
.importPruefungen(ctx.lvId, ctx.semKurzbz, [
|
||||
bulkRow(valid, antrittDate(ctx, 1)),
|
||||
bulkRow(invalid, impossibleDay()),
|
||||
])
|
||||
.then((response) => {
|
||||
const data = expectNotenSuccess(response, "importPruefungen");
|
||||
expectBulkRowAccepted(data, valid.uid);
|
||||
expectBulkRowError(data, invalid.uid, "pruefungsdatumUngueltig");
|
||||
});
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
expect(pruefungenOf(data, invalid.uid), "nur Antritt 1").to.have.length(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// A second Pruefung on the same day: too early, or allowed by the configuration.
|
||||
describe("CIS_GESAMTNOTE_PRUEFUNG_GLEICHER_TAG", () => {
|
||||
beforeEach(function () {
|
||||
requireRepeat(this, ctx);
|
||||
});
|
||||
|
||||
// Antritt 1 is on baselineDate, and the new Pruefung is on exactly that day
|
||||
const pruefungOnBaselineDay = (student) => {
|
||||
givenBaseline(ctx, student);
|
||||
return addPruefung(ctx, student, { note: ctx.notenScale[1], datum: baselineDate(ctx) });
|
||||
};
|
||||
|
||||
it("lehnt einen Termin am selben Tag ab", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNG_GLEICHER_TAG", false);
|
||||
|
||||
pruefungOnBaselineDay(ctx.students[0]).then((response) =>
|
||||
expectNotenError(response, "pruefungDatumBeforeExisting"),
|
||||
);
|
||||
});
|
||||
|
||||
it("nimmt einen Termin am selben Tag an, wenn der Schalter an ist", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNG_GLEICHER_TAG", true);
|
||||
|
||||
pruefungOnBaselineDay(ctx.students[0]).then((response) =>
|
||||
expectNotenSuccess(response, "Termin am selben Tag"),
|
||||
);
|
||||
});
|
||||
|
||||
it("lehnt einen Termin vor einem bestehenden immer ab", () => {
|
||||
const student = ctx.students[1];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
// one day earlier is too early in both configurations
|
||||
addPruefung(ctx, student, {
|
||||
note: ctx.notenScale[1],
|
||||
datum: shiftDate(baselineDate(ctx), -1),
|
||||
}).then((response) => expectNotenError(response, "pruefungDatumBeforeExisting"));
|
||||
});
|
||||
});
|
||||
|
||||
// Does a later Pruefung lock the Note of the earlier one?
|
||||
describe("CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETERER_PRUEFUNG", () => {
|
||||
beforeEach(function () {
|
||||
requireRepeat(this, ctx);
|
||||
});
|
||||
|
||||
/** Antritt 1 and a later Pruefung. Then the request changes only the Note of Antritt 1. */
|
||||
const changeNoteOfAntritt1 = (student) => {
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
return addPruefung(ctx, student, { note: ctx.notenScale[1], datum: antrittDate(ctx, 1) })
|
||||
.then((response) => expectNotenSuccess(response, "späterer Termin"))
|
||||
.then(() => readStateViaApi(ctx))
|
||||
.then((data) => {
|
||||
const firstAntritt = pruefungenOf(data, student.uid)[0];
|
||||
expect(firstAntritt, "Antritt 1").to.exist;
|
||||
|
||||
return editPruefung(ctx, student, {
|
||||
pruefungId: firstAntritt.pruefung_id,
|
||||
note: ctx.notenScale[1],
|
||||
datum: firstAntritt.datum,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
it("sperrt die Note eines früheren Termins", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETERER_PRUEFUNG", true);
|
||||
|
||||
changeNoteOfAntritt1(ctx.students[2]).then((response) => expectNotenError(response, "pruefungNoteLocked"));
|
||||
});
|
||||
|
||||
it("lässt die Note eines früheren Termins ändern, wenn der Schalter aus ist", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETERER_PRUEFUNG", false);
|
||||
|
||||
changeNoteOfAntritt1(ctx.students[2]).then((response) =>
|
||||
expectNotenSuccess(response, "Note trotz späterem Termin"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// A Benotungsdatum in the future.
|
||||
describe("CIS_GESAMTNOTE_DATUM_ZUKUNFT", () => {
|
||||
const proposalWithTomorrow = (student) => {
|
||||
const tomorrow = shiftDate(new Date().toISOString().slice(0, 10), 1);
|
||||
|
||||
givenBaseline(ctx, student, { erstantritt: false });
|
||||
return notenApi.saveLvNote(ctx.lvId, ctx.semKurzbz, student.uid, ctx.notenScale[0], null, tomorrow);
|
||||
};
|
||||
|
||||
it("lehnt ein künftiges Benotungsdatum ab", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_DATUM_ZUKUNFT", false);
|
||||
|
||||
proposalWithTomorrow(ctx.students[3]).then((response) =>
|
||||
expectNotenError(response, "benotungsdatumInZukunft"),
|
||||
);
|
||||
});
|
||||
|
||||
it("nimmt ein künftiges Benotungsdatum an, wenn der Schalter an ist", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_DATUM_ZUKUNFT", true);
|
||||
|
||||
proposalWithTomorrow(ctx.students[3]).then((response) =>
|
||||
expectNotenSuccess(response, "künftiges Benotungsdatum"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// The minimum gap between two Antritte. Without a value the rule is off.
|
||||
describe("CIS_GESAMTNOTE_ANTRITT_MIN_ABSTAND_TAGE", () => {
|
||||
let min;
|
||||
|
||||
beforeEach(function () {
|
||||
min = Number(ctx.cisConfig.CIS_GESAMTNOTE_ANTRITT_MIN_ABSTAND_TAGE);
|
||||
skipIf(
|
||||
this,
|
||||
!Number.isFinite(min) || min <= 0,
|
||||
"Übersprungen: CIS_GESAMTNOTE_ANTRITT_MIN_ABSTAND_TAGE ist nicht gesetzt.",
|
||||
);
|
||||
requireRepeat(this, ctx);
|
||||
});
|
||||
|
||||
it("lehnt einen Antritt vor Ablauf der Wartefrist ab", () => {
|
||||
const student = ctx.students[4];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
addPruefung(ctx, student, {
|
||||
note: ctx.notenScale[1],
|
||||
datum: shiftDate(baselineDate(ctx), min - 1),
|
||||
}).then((response) => expectNotenError(response, "pruefungAbstandZuKurz"));
|
||||
});
|
||||
|
||||
it("nimmt einen Antritt genau am Ende der Wartefrist an", () => {
|
||||
const student = ctx.students[5];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
addPruefung(ctx, student, {
|
||||
note: ctx.notenScale[1],
|
||||
datum: shiftDate(baselineDate(ctx), min),
|
||||
}).then((response) => expectNotenSuccess(response, "Antritt am Ende der Wartefrist"));
|
||||
});
|
||||
|
||||
it("lehnt es ab, einen Antritt in die Wartefrist zu verschieben", () => {
|
||||
const student = ctx.students[5];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: shiftDate(baselineDate(ctx), min) }).then(
|
||||
(response) => {
|
||||
const data = expectNotenSuccess(response, "Antritt am Ende der Wartefrist");
|
||||
|
||||
editPruefung(ctx, student, {
|
||||
pruefungId: data[student.uid].pruefung.pruefung_id,
|
||||
note: ctx.noten.negativ,
|
||||
datum: shiftDate(baselineDate(ctx), min - 1),
|
||||
}).then((edit) => expectNotenError(edit, "pruefungAbstandZuKurz"));
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// The maximum gap to the next Antritt.
|
||||
describe("CIS_GESAMTNOTE_ANTRITT_MAX_ABSTAND_TAGE", () => {
|
||||
let max;
|
||||
|
||||
beforeEach(function () {
|
||||
max = Number(ctx.cisConfig.CIS_GESAMTNOTE_ANTRITT_MAX_ABSTAND_TAGE);
|
||||
skipIf(
|
||||
this,
|
||||
!Number.isFinite(max) || max <= 0,
|
||||
"Übersprungen: CIS_GESAMTNOTE_ANTRITT_MAX_ABSTAND_TAGE ist nicht gesetzt.",
|
||||
);
|
||||
requireRepeat(this, ctx);
|
||||
});
|
||||
|
||||
it("lehnt einen Antritt nach Ablauf der Höchstfrist ab", () => {
|
||||
const student = ctx.students[6];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
addPruefung(ctx, student, {
|
||||
note: ctx.notenScale[1],
|
||||
datum: shiftDate(baselineDate(ctx), max + 1),
|
||||
}).then((response) => expectNotenError(response, "pruefungAbstandZuLang"));
|
||||
});
|
||||
|
||||
it("lehnt es ab, einen Antritt hinter die Höchstfrist zu verschieben", () => {
|
||||
const student = ctx.students[6];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.noten.negativ, datum: shiftDate(baselineDate(ctx), max) }).then(
|
||||
(response) => {
|
||||
const data = expectNotenSuccess(response, "Antritt am Ende der Höchstfrist");
|
||||
|
||||
editPruefung(ctx, student, {
|
||||
pruefungId: data[student.uid].pruefung.pruefung_id,
|
||||
note: ctx.noten.negativ,
|
||||
datum: shiftDate(baselineDate(ctx), max + 1),
|
||||
}).then((edit) => expectNotenError(edit, "pruefungAbstandZuLang"));
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* The role matrix: which permission may do which action.
|
||||
*
|
||||
* Each account asks getCisConfig for its actions. Each action has two tests: one runs when the matrix
|
||||
* allows the action, the other when the matrix withholds it. The profile "rollen" withholds the Freigabe
|
||||
* and the kommissionelle Pruefung from the Assistenz, otherwise the second test never runs.
|
||||
*/
|
||||
|
||||
import { apiGet, apiPost, loginAsLektor } from "../../../../support/api/notenApi";
|
||||
import { expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
|
||||
import { requireKommissionellerAntritt, skipIf } from "../../../../support/helpers/notenConfig";
|
||||
import { antrittDate, loadNotenContext, requireDbReset } from "../../../../support/helpers/notenTestData";
|
||||
import { addPruefung, givenBaseline } from "../../../../support/helpers/notenScenario";
|
||||
|
||||
const accounts = () => [
|
||||
{
|
||||
label: "Lektor",
|
||||
user: Cypress.env("NOTEN_LEKTOR_USER"),
|
||||
pass: Cypress.env("NOTEN_LEKTOR_PASSWORD"),
|
||||
},
|
||||
{
|
||||
label: "Assistenz",
|
||||
user: Cypress.env("NOTEN_ASSISTENZ_USER"),
|
||||
pass: Cypress.env("NOTEN_ASSISTENZ_PASSWORD"),
|
||||
},
|
||||
];
|
||||
|
||||
// the first statement of each endpoint checks its action; kommpruef is checked at the kommissionell Antritt
|
||||
const ENDPOINT = {
|
||||
lvnote: "saveLvNote",
|
||||
pruefung: "savePruefung",
|
||||
freigabe: "saveFreigabe",
|
||||
import: "importLvNoten",
|
||||
};
|
||||
|
||||
/** A call that only reaches the action check. The empty body ends in missingParameters otherwise. */
|
||||
const callAction = (auth, action, ctx) =>
|
||||
apiPost(ENDPOINT[action], { lv_id: ctx.lvId, sem_kurzbz: ctx.semKurzbz }, auth);
|
||||
|
||||
const errorCodes = (response) => (response.body?.errors ?? []).map((e) => e.code);
|
||||
|
||||
describe("Noten API - die Rollenmatrix", () => {
|
||||
let ctx;
|
||||
|
||||
before(() => {
|
||||
loadNotenContext().then((loaded) => {
|
||||
ctx = loaded;
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => loginAsLektor());
|
||||
|
||||
accounts().forEach(({ label, user, pass }) => {
|
||||
describe(label, () => {
|
||||
const auth = { username: user, password: pass };
|
||||
let allowed = [];
|
||||
|
||||
before(function () {
|
||||
skipIf(this, !user || !pass, `Übersprungen: kein Konto für "${label}" in tests/cypress/suites/.env.`);
|
||||
|
||||
cy.clearAllCookies();
|
||||
apiGet("getCisConfig", undefined, auth).then((response) => {
|
||||
// every account of the suite holds a permission of the matrix; seeder group benotungstool_berechtigungen creates it
|
||||
expect(response.status, `getCisConfig als ${label}`).to.eq(200);
|
||||
allowed = response.body.data.CIS_GESAMTNOTE_AKTIONEN || [];
|
||||
cy.log(`${label} darf: ${JSON.stringify(allowed)}`);
|
||||
});
|
||||
});
|
||||
|
||||
// otherwise the request sends the session cookie of loginAsLektor, and the server prefers the cookie
|
||||
beforeEach(() => cy.clearAllCookies());
|
||||
|
||||
Object.keys(ENDPOINT).forEach((action) => {
|
||||
it(`führt "${action}" aus, wenn die Matrix es erlaubt`, function () {
|
||||
skipIf(this, !allowed.includes(action), `Übersprungen: die Matrix entzieht ${label} "${action}".`);
|
||||
|
||||
callAction(auth, action, ctx).then((res) => {
|
||||
expect(errorCodes(res), `${label} darf "${action}"`).to.not.include("aktionNichtErlaubt");
|
||||
});
|
||||
});
|
||||
|
||||
it(`lehnt "${action}" ab, wenn die Matrix es entzieht`, function () {
|
||||
skipIf(this, allowed.includes(action), `Übersprungen: die Matrix erlaubt ${label} "${action}".`);
|
||||
|
||||
callAction(auth, action, ctx).then((res) => expectNotenError(res, "aktionNichtErlaubt"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("der kommissionelle Antritt", () => {
|
||||
const student = () => ctx.students[6];
|
||||
|
||||
beforeEach(function () {
|
||||
requireKommissionellerAntritt(this, ctx);
|
||||
requireDbReset();
|
||||
});
|
||||
|
||||
/** NOTEN_LEKTOR_USER builds the chain up to the kommissionell Antritt. Returns its date. */
|
||||
const bringToBeforeKommission = () => {
|
||||
const fromAntritt = ctx.cisConfig.CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT;
|
||||
|
||||
givenBaseline(ctx, student());
|
||||
for (let nr = 2; nr < fromAntritt; nr += 1) {
|
||||
addPruefung(ctx, student(), { note: ctx.noten.negativ, datum: antrittDate(ctx, nr - 1) }).then(
|
||||
(response) => expectNotenSuccess(response, `Antritt ${nr}`),
|
||||
);
|
||||
}
|
||||
return antrittDate(ctx, fromAntritt - 1);
|
||||
};
|
||||
|
||||
const createKommissionell = (datum) => {
|
||||
const s = student();
|
||||
// the chain requests can leave a session cookie of NOTEN_LEKTOR_USER
|
||||
cy.clearAllCookies();
|
||||
return apiPost(
|
||||
"savePruefung",
|
||||
{
|
||||
lv_id: ctx.lvId,
|
||||
sem_kurzbz: ctx.semKurzbz,
|
||||
student_uid: s.uid,
|
||||
pruefung_id: null,
|
||||
lehreinheit_id: s.lehreinheit_id,
|
||||
datum,
|
||||
note: ctx.noten.negativ,
|
||||
punkte: null,
|
||||
},
|
||||
auth,
|
||||
);
|
||||
};
|
||||
|
||||
it("legt ihn an, wenn die Matrix es erlaubt", function () {
|
||||
skipIf(
|
||||
this,
|
||||
!allowed.includes("kommpruef"),
|
||||
`Übersprungen: die Matrix entzieht ${label} "kommpruef".`,
|
||||
);
|
||||
|
||||
createKommissionell(bringToBeforeKommission()).then((response) => {
|
||||
const { pruefung } = expectNotenSuccess(response, `kommissioneller Antritt als ${label}`)[
|
||||
student().uid
|
||||
];
|
||||
expect(pruefung.pruefungstyp_kurzbz, "der Antritt ist kommissionell").to.eq(
|
||||
ctx.cisConfig.PRUEFUNG_TYP_KOMMISSIONELL,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("lehnt ihn ab, wenn die Matrix es entzieht", function () {
|
||||
skipIf(
|
||||
this,
|
||||
allowed.includes("kommpruef"),
|
||||
`Übersprungen: die Matrix erlaubt ${label} "kommpruef".`,
|
||||
);
|
||||
|
||||
createKommissionell(bringToBeforeKommission()).then((response) =>
|
||||
expectNotenError(response, "kommPruefNichtErlaubt"),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,469 @@
|
||||
/**
|
||||
* The scope of the write endpoints: a Pruefung belongs to the student and the LV of the request.
|
||||
*
|
||||
* An edit changes only a Pruefung of this student in this LV (else pruefungNichtBearbeitbar). The server
|
||||
* picks the Lehreinheit, and a student outside the LV gets no Note (studentNichtInLv). Every test also
|
||||
* checks the database.
|
||||
*
|
||||
* Seeder group `benotungstool_fixture_erweitert` creates a second Lehreinheit with two Lektoren.
|
||||
*/
|
||||
|
||||
import { apiPost, notenApi, loginAsLektor } from "../../../../support/api/notenApi";
|
||||
import {
|
||||
expectBulkRowAccepted,
|
||||
expectBulkRowError,
|
||||
expectNotenError,
|
||||
expectNotenSuccess,
|
||||
} from "../../../../support/helpers/notenErrors";
|
||||
import { requireConfig, requireNotenMode, requireRepeat, skipIf } from "../../../../support/helpers/notenConfig";
|
||||
import { assistenzAuth, requireAssistenz } from "../../../../support/helpers/notenAssistenz";
|
||||
import {
|
||||
antrittDate,
|
||||
baselineDate,
|
||||
loadNotenContext,
|
||||
readAuthUid,
|
||||
readLvGesamtnoteViaDb,
|
||||
requireDbReset,
|
||||
resetNotenState,
|
||||
seedBaseline,
|
||||
seedPruefung,
|
||||
shiftDate,
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
import { pruefungenOf, editPruefung, readStateViaApi } from "../../../../support/helpers/notenScenario";
|
||||
|
||||
const dayOf = (value) => String(value).slice(0, 10);
|
||||
|
||||
describe("Noten API - Scope der Schreibpfade", () => {
|
||||
let ctx;
|
||||
let a;
|
||||
let b;
|
||||
|
||||
// null: the instance cannot build the case, and the test skips
|
||||
let outsider = null;
|
||||
let foreign = null;
|
||||
let otherLe = null;
|
||||
let typeWithoutAntritt = null;
|
||||
|
||||
const lvScope = () => ({ lvId: ctx.lvId, semKurzbz: ctx.semKurzbz });
|
||||
|
||||
/** A student with exactly one Lehreinheit, and a second Lehreinheit of the same LV. */
|
||||
const findOtherLe = (lehreinheiten, i = 0) => {
|
||||
if (lehreinheiten.length < 2 || i >= ctx.students.length) return cy.wrap(null, { log: false });
|
||||
|
||||
const student = ctx.students[i];
|
||||
return cy.task("noten:db:readLehreinheitenOfStudent", { ...lvScope(), studentUid: student.uid }).then((own) => {
|
||||
if (own.length !== 1) return findOtherLe(lehreinheiten, i + 1);
|
||||
const other = lehreinheiten.find((le) => String(le) !== String(own[0]));
|
||||
return { student, ownLe: own[0], otherLe: other };
|
||||
});
|
||||
};
|
||||
|
||||
/** The first type from PRUEFUNG_TYPEN_OHNE_ANTRITT that tbl_pruefungstyp contains. */
|
||||
const findTypeWithoutAntritt = (types, i = 0) => {
|
||||
if (i >= types.length) return cy.wrap(null, { log: false });
|
||||
|
||||
return cy
|
||||
.task("noten:db:hasPruefungstyp", { type: types[i] })
|
||||
.then((found) => (found ? types[i] : findTypeWithoutAntritt(types, i + 1)));
|
||||
};
|
||||
|
||||
before(() => {
|
||||
requireDbReset();
|
||||
loadNotenContext().then((loaded) => {
|
||||
ctx = loaded;
|
||||
[a, b] = ctx.students;
|
||||
|
||||
cy.task("noten:db:readNonParticipant", lvScope()).then((uid) => {
|
||||
outsider = uid;
|
||||
});
|
||||
cy.task("noten:db:readForeignLehreinheit", lvScope()).then((row) => {
|
||||
foreign = row;
|
||||
});
|
||||
findTypeWithoutAntritt(ctx.cisConfig.PRUEFUNG_TYPEN_OHNE_ANTRITT || []).then((type) => {
|
||||
typeWithoutAntritt = type;
|
||||
});
|
||||
cy.task("noten:db:readLehreinheitenOfLv", lvScope())
|
||||
.then((lehreinheiten) => findOtherLe(lehreinheiten))
|
||||
.then((found) => {
|
||||
otherLe = found;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => loginAsLektor());
|
||||
|
||||
describe("ein Edit trifft nur eine eigene Prüfung", () => {
|
||||
/** The Pruefung of B that the request for A tries to edit. */
|
||||
const seedForeignRow = () =>
|
||||
seedPruefung(ctx, b, { note: ctx.noten.negativ, datum: baselineDate(ctx), type: "Termin1" }).then(
|
||||
(seeded) => seeded.pruefungId,
|
||||
);
|
||||
|
||||
const expectForeignRowUnchanged = (foreignId) =>
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const storedPruefung = pruefungenOf(data, b.uid).find(
|
||||
(p) => String(p.pruefung_id) === String(foreignId),
|
||||
);
|
||||
expect(storedPruefung, "die Prüfung von B existiert weiter").to.exist;
|
||||
expect(String(storedPruefung.note), "die Prüfung von B behält ihre Note").to.eq(
|
||||
String(ctx.noten.negativ),
|
||||
);
|
||||
expect(dayOf(storedPruefung.datum), "die Prüfung von B behält ihr Datum").to.eq(baselineDate(ctx));
|
||||
});
|
||||
|
||||
it("lehnt die Prüfung eines anderen Studierenden ab", () => {
|
||||
const otherNote = ctx.notenScale.find((n) => String(n) !== String(ctx.noten.negativ));
|
||||
let foreignId;
|
||||
let lvNoteBefore;
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, a);
|
||||
seedBaseline(ctx, b, { erstantritt: false });
|
||||
seedForeignRow().then((id) => {
|
||||
foreignId = id;
|
||||
});
|
||||
readLvGesamtnoteViaDb(ctx, a).then((row) => {
|
||||
lvNoteBefore = row;
|
||||
});
|
||||
|
||||
cy.then(() =>
|
||||
editPruefung(ctx, a, {
|
||||
pruefungId: foreignId,
|
||||
note: otherNote,
|
||||
datum: shiftDate(baselineDate(ctx), 3),
|
||||
}),
|
||||
).then((response) => expectNotenError(response, "pruefungNichtBearbeitbar"));
|
||||
|
||||
cy.then(() => expectForeignRowUnchanged(foreignId));
|
||||
readLvGesamtnoteViaDb(ctx, a).then((row) => {
|
||||
expect(String(row.note), "die LV-Note von A bleibt").to.eq(String(lvNoteBefore.note));
|
||||
expect(String(row.benotungsdatum), "das Benotungsdatum von A bleibt").to.eq(
|
||||
String(lvNoteBefore.benotungsdatum),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("lehnt eine fremde Prüfung für einen Studierenden ohne Prüfung ab", () => {
|
||||
let foreignId;
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedForeignRow().then((id) => {
|
||||
foreignId = id;
|
||||
});
|
||||
|
||||
cy.then(() =>
|
||||
editPruefung(ctx, a, {
|
||||
pruefungId: foreignId,
|
||||
note: ctx.notenScale[0],
|
||||
datum: shiftDate(baselineDate(ctx), 3),
|
||||
}),
|
||||
).then((response) => expectNotenError(response, "pruefungNichtBearbeitbar"));
|
||||
|
||||
readLvGesamtnoteViaDb(ctx, a).then((row) => {
|
||||
expect(row, "A bekommt keine LV-Note").to.be.null;
|
||||
});
|
||||
cy.then(() => expectForeignRowUnchanged(foreignId));
|
||||
});
|
||||
|
||||
// The server locks every Pruefungstyp from PRUEFUNG_TYPEN_OHNE_ANTRITT against edits.
|
||||
// Today: zusKommPruef, which the StV enters.
|
||||
it("lehnt eine Prüfung der Studierendenverwaltung ab", function () {
|
||||
skipIf(
|
||||
this,
|
||||
!typeWithoutAntritt,
|
||||
"Übersprungen: kein Typ aus PRUEFUNG_TYPEN_OHNE_ANTRITT in tbl_pruefungstyp.",
|
||||
);
|
||||
|
||||
const datum = antrittDate(ctx, 1);
|
||||
let pruefungId;
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, a);
|
||||
seedPruefung(ctx, a, { note: ctx.noten.negativ, datum, type: typeWithoutAntritt }).then((seeded) => {
|
||||
pruefungId = seeded.pruefungId;
|
||||
});
|
||||
|
||||
cy.then(() =>
|
||||
editPruefung(ctx, a, { pruefungId, note: ctx.noten.negativ, datum: shiftDate(datum, 2) }),
|
||||
).then((response) => expectNotenError(response, "pruefungNichtBearbeitbar"));
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const storedPruefung = pruefungenOf(data, a.uid).find(
|
||||
(p) => String(p.pruefung_id) === String(pruefungId),
|
||||
);
|
||||
expect(storedPruefung, `die Prüfung vom Typ ${typeWithoutAntritt} existiert weiter`).to.exist;
|
||||
expect(dayOf(storedPruefung.datum), "sie behält ihr Datum").to.eq(datum);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("die Lehreinheit einer Prüfung", () => {
|
||||
const savePruefungFor = (uid, lehreinheitId) =>
|
||||
notenApi.savePruefung(ctx.lvId, ctx.semKurzbz, uid, {
|
||||
pruefung_id: null,
|
||||
lehreinheit_id: lehreinheitId,
|
||||
datum: antrittDate(ctx, 1),
|
||||
note: ctx.noten.negativ,
|
||||
punkte: null,
|
||||
});
|
||||
|
||||
// the server replaces the Lehreinheit without an error message
|
||||
it("ersetzt eine andere Lehreinheit derselben LV", function () {
|
||||
skipIf(this, !otherLe, "Übersprungen: die LV hat keine zweite Lehreinheit.");
|
||||
requireRepeat(this, ctx);
|
||||
|
||||
const { student, ownLe } = otherLe;
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student);
|
||||
|
||||
savePruefungFor(student.uid, otherLe.otherLe).then((response) => {
|
||||
const { pruefung } = expectNotenSuccess(response, "Termin mit einer anderen Lehreinheit")[student.uid];
|
||||
expect(String(pruefung.lehreinheit_id), "die Lehreinheit des Studierenden").to.eq(String(ownLe));
|
||||
});
|
||||
});
|
||||
|
||||
it("ersetzt die Lehreinheit einer fremden LV", function () {
|
||||
skipIf(this, !foreign, "Übersprungen: das Semester hat keine Lehreinheit einer anderen LV.");
|
||||
requireRepeat(this, ctx);
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, a);
|
||||
|
||||
savePruefungFor(a.uid, foreign.lehreinheit_id)
|
||||
.then((response) => {
|
||||
// resetNotenState does not reach a row in the foreign LV
|
||||
const saved = response.body?.data?.[a.uid]?.pruefung;
|
||||
if (saved && String(saved.lehreinheit_id) === String(foreign.lehreinheit_id)) {
|
||||
cy.task("noten:db:deletePruefung", { pruefungId: saved.pruefung_id, studentUids: [a.uid] });
|
||||
}
|
||||
return cy.wrap(response, { log: false });
|
||||
})
|
||||
.then((response) => {
|
||||
const saved = expectNotenSuccess(response, "Termin mit einer fremden Lehreinheit")[a.uid].pruefung;
|
||||
return cy
|
||||
.task("noten:db:readLehreinheitenOfStudent", { ...lvScope(), studentUid: a.uid })
|
||||
.then((own) => {
|
||||
expect(own.map(String), "eine Lehreinheit des Studierenden").to.include(
|
||||
String(saved.lehreinheit_id),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
expect(pruefungenOf(data, a.uid), "Antritt 1 und der neue Termin").to.have.length(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Nicht-Teilnehmer", () => {
|
||||
beforeEach(function () {
|
||||
skipIf(this, !outsider, "Übersprungen: kein aktiver Studierender ausserhalb der LV.");
|
||||
resetNotenState(ctx, [outsider]);
|
||||
});
|
||||
|
||||
// the reset is the check: it deletes what the request wrote and counts it
|
||||
const expectNothingWritten = () =>
|
||||
resetNotenState(ctx, [outsider]).then((deleted) => {
|
||||
expect(deleted.deletedLvGesamtnoten, `LV-Noten von ${outsider}`).to.eq(0);
|
||||
expect(deleted.deletedPruefungen, `Prüfungen von ${outsider}`).to.eq(0);
|
||||
});
|
||||
|
||||
it("savePruefung legt nichts an", () => {
|
||||
savePruefungFor(outsider, a.lehreinheit_id).then((response) =>
|
||||
expectNotenError(response, "studentNichtInLv"),
|
||||
);
|
||||
expectNothingWritten();
|
||||
});
|
||||
|
||||
it("createPruefungen lehnt nur die Zeile des Nicht-Teilnehmers ab", () => {
|
||||
resetNotenState(ctx);
|
||||
|
||||
notenApi
|
||||
.createPruefungen(
|
||||
ctx.lvId,
|
||||
ctx.semKurzbz,
|
||||
[
|
||||
{ uid: a.uid, lehreinheit_id: a.lehreinheit_id },
|
||||
{ uid: outsider, lehreinheit_id: a.lehreinheit_id },
|
||||
],
|
||||
{ datum: antrittDate(ctx, 1) },
|
||||
)
|
||||
.then((response) => {
|
||||
const data = expectNotenSuccess(response, "createPruefungen");
|
||||
expectBulkRowError(data, outsider, "studentNichtInLv");
|
||||
expect(data[a.uid].pruefung, `Prüfung für ${a.uid}`).to.exist;
|
||||
});
|
||||
expectNothingWritten();
|
||||
});
|
||||
|
||||
it("importPruefungen lehnt nur die Zeile des Nicht-Teilnehmers ab", function () {
|
||||
requireNotenMode(this, ctx);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNGSIMPORT", true);
|
||||
|
||||
const bulkRow = (uid) => ({
|
||||
uid,
|
||||
note: ctx.noten.negativ,
|
||||
punkte: null,
|
||||
datum: antrittDate(ctx, 1),
|
||||
lehreinheit_id: a.lehreinheit_id,
|
||||
});
|
||||
|
||||
resetNotenState(ctx);
|
||||
|
||||
notenApi
|
||||
.importPruefungen(ctx.lvId, ctx.semKurzbz, [bulkRow(a.uid), bulkRow(outsider)])
|
||||
.then((response) => {
|
||||
const data = expectNotenSuccess(response, "importPruefungen");
|
||||
expectBulkRowError(data, outsider, "studentNichtInLv");
|
||||
expectBulkRowAccepted(data, a.uid);
|
||||
});
|
||||
expectNothingWritten();
|
||||
});
|
||||
|
||||
it("saveLvNote schreibt keine LV-Note", () => {
|
||||
notenApi
|
||||
.saveLvNote(ctx.lvId, ctx.semKurzbz, outsider, ctx.noten.negativ)
|
||||
.then((response) => expectNotenError(response, "studentNichtInLv"));
|
||||
expectNothingWritten();
|
||||
});
|
||||
|
||||
it("importLvNoten lehnt nur die Zeile des Nicht-Teilnehmers ab", function () {
|
||||
requireNotenMode(this, ctx);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENIMPORT", true);
|
||||
|
||||
resetNotenState(ctx);
|
||||
|
||||
notenApi
|
||||
.importLvNoten(ctx.lvId, ctx.semKurzbz, [
|
||||
{ uid: a.uid, note: ctx.notenScale[0], punkte: null },
|
||||
{ uid: outsider, note: ctx.notenScale[0], punkte: null },
|
||||
])
|
||||
.then((response) => {
|
||||
const data = expectNotenSuccess(response, "importLvNoten");
|
||||
expectBulkRowError(data, outsider, "studentNichtInLv");
|
||||
expectBulkRowAccepted(data, a.uid);
|
||||
});
|
||||
expectNothingWritten();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLektorenForLehreinheit", () => {
|
||||
it("nennt die Lehrenden einer Lehreinheit der LV", () => {
|
||||
notenApi.getLektorenForLehreinheit(ctx.lvId, ctx.semKurzbz, a.lehreinheit_id).then((response) => {
|
||||
expect(expectNotenSuccess(response, "eigene Lehreinheit"), "Lehrende").to.be.an("array");
|
||||
});
|
||||
});
|
||||
|
||||
it("lehnt die Lehreinheit einer fremden LV ab", function () {
|
||||
skipIf(this, !foreign, "Übersprungen: das Semester hat keine Lehreinheit einer anderen LV.");
|
||||
|
||||
notenApi
|
||||
.getLektorenForLehreinheit(ctx.lvId, ctx.semKurzbz, foreign.lehreinheit_id)
|
||||
.then((response) => expectNotenError(response, "wrongParameters"));
|
||||
});
|
||||
|
||||
it("lehnt eine nicht numerische Lehreinheit ab", () => {
|
||||
notenApi
|
||||
.getLektorenForLehreinheit(ctx.lvId, ctx.semKurzbz, "abc")
|
||||
.then((response) => expectNotenError(response, "missingParameters"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// The Lektor in mitarbeiter_uid of the Pruefung and the LV-Note: a valid selection; else the caller,
|
||||
// if the caller teaches the Lehreinheit; else the first Lektor of the Lehreinheit by uid.
|
||||
describe("die benotende Person", () => {
|
||||
// a student whose Lehreinheit has several Lektoren; NOTEN_LEKTOR_USER is not the first of them
|
||||
let target = null;
|
||||
let lektorUid = null;
|
||||
|
||||
before(() => {
|
||||
const search = (i) =>
|
||||
i >= ctx.students.length
|
||||
? cy.wrap(null, { log: false })
|
||||
: cy
|
||||
.task("noten:db:readLektorenOfLehreinheit", {
|
||||
lehreinheitId: ctx.students[i].lehreinheit_id,
|
||||
})
|
||||
.then((lehrende) =>
|
||||
lehrende.length > 1 && lehrende.includes(lektorUid) && lehrende[0] !== lektorUid
|
||||
? { student: ctx.students[i], lehrende }
|
||||
: search(i + 1),
|
||||
);
|
||||
|
||||
readAuthUid()
|
||||
.then((uid) => {
|
||||
lektorUid = uid;
|
||||
return search(0);
|
||||
})
|
||||
.then((found) => {
|
||||
target = found;
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
expect(
|
||||
target,
|
||||
"eine Lehreinheit mit mehreren Lehrenden, darunter der Suite-Benutzer (Seeder-Gruppe benotungstool_fixture_erweitert)",
|
||||
).to.not.be.null;
|
||||
requireDbReset();
|
||||
resetNotenState(ctx);
|
||||
});
|
||||
|
||||
const pruefung = (mitarbeiter_uid) => ({
|
||||
pruefung_id: null,
|
||||
lehreinheit_id: target.student.lehreinheit_id,
|
||||
datum: antrittDate(ctx, 1),
|
||||
note: ctx.noten.negativ,
|
||||
punkte: null,
|
||||
mitarbeiter_uid,
|
||||
});
|
||||
|
||||
const savePruefungWithLektor = (mitarbeiter_uid) =>
|
||||
notenApi.savePruefung(ctx.lvId, ctx.semKurzbz, target.student.uid, pruefung(mitarbeiter_uid));
|
||||
|
||||
const grader = (response) => {
|
||||
const saved = expectNotenSuccess(response, "Termin")[target.student.uid].pruefung;
|
||||
return cy.task("noten:db:readPruefungMitarbeiter", {
|
||||
pruefungId: saved.pruefung_id,
|
||||
studentUid: target.student.uid,
|
||||
});
|
||||
};
|
||||
|
||||
it("trägt den Aufrufer ein, wenn er die Lehreinheit unterrichtet", () => {
|
||||
savePruefungWithLektor()
|
||||
.then(grader)
|
||||
.then((uid) => expect(uid, "benotende Person der Prüfung").to.eq(lektorUid));
|
||||
|
||||
readLvGesamtnoteViaDb(ctx, target.student).then((row) => {
|
||||
expect(row.mitarbeiter_uid, "benotende Person der LV-Note").to.eq(lektorUid);
|
||||
});
|
||||
});
|
||||
|
||||
it("trägt die gewählte Lehrperson ein", () => {
|
||||
const chosen = target.lehrende.find((uid) => uid !== lektorUid);
|
||||
|
||||
savePruefungWithLektor(chosen)
|
||||
.then(grader)
|
||||
.then((uid) => expect(uid, "benotende Person der Prüfung").to.eq(chosen));
|
||||
});
|
||||
|
||||
it("übergeht eine Auswahl ausserhalb der Lehreinheit", () => {
|
||||
savePruefungWithLektor("keine-lehrperson")
|
||||
.then(grader)
|
||||
.then((uid) => expect(uid, "benotende Person der Prüfung").to.eq(lektorUid));
|
||||
});
|
||||
|
||||
it("trägt ohne eigene Lehre die erste Lehrperson der Lehreinheit ein", function () {
|
||||
requireAssistenz(this);
|
||||
|
||||
// otherwise the request runs with the session cookie of loginAsLektor
|
||||
cy.clearAllCookies();
|
||||
|
||||
const body = { lv_id: ctx.lvId, sem_kurzbz: ctx.semKurzbz, student_uid: target.student.uid, ...pruefung() };
|
||||
apiPost("savePruefung", body, assistenzAuth())
|
||||
.then(grader)
|
||||
.then((uid) => expect(uid, "benotende Person der Prüfung").to.eq(target.lehrende[0]));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { freigabePassword, loginAsLektor } from "../../../../support/api/notenApi";
|
||||
import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
|
||||
import { requireConfig } from "../../../../support/helpers/notenConfig";
|
||||
import {
|
||||
baselineDate,
|
||||
loadNotenContext,
|
||||
readLvGesamtnoteViaDb,
|
||||
requireDbReset,
|
||||
resetNotenState,
|
||||
seedBaseline,
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
|
||||
/**
|
||||
* The Freigabe through the dialog, with the password.
|
||||
*
|
||||
* The dialog lists exactly the rows that it releases (changedLvNoten) and asks for the LDAP password.
|
||||
* After that the state column must change from "changed" to "freigegeben" without a reload.
|
||||
*
|
||||
* A successful Freigabe sends the Freigabe mail. The dev instances send it to a debug mailbox.
|
||||
*/
|
||||
|
||||
describe("Benotungstool UI - Notenfreigabe", () => {
|
||||
let ctx;
|
||||
let bezeichnung;
|
||||
|
||||
before(() => {
|
||||
requireDbReset();
|
||||
loadNotenContext().then((loaded) => {
|
||||
ctx = loaded;
|
||||
bezeichnung = (note) => page.bezeichnungOf(ctx, note);
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => loginAsLektor());
|
||||
|
||||
describe("Dialog", () => {
|
||||
it("listet die noch nicht freigegebene Note mit ihrer Zielnote", () => {
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, { note: ctx.notenScale[0], freigegeben: false, erstantritt: false });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
page.expectFreigabeState(student.uid, "changed");
|
||||
|
||||
page.openFreigabeModal();
|
||||
page.expectFreigabeSummaryRow(student.uid, bezeichnung(ctx.notenScale[0]));
|
||||
});
|
||||
|
||||
it("sperrt die Freigabe, wenn keine LV-Note geändert ist", () => {
|
||||
resetNotenState(ctx);
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
page.openFreigabeModal();
|
||||
page.expectFreigabeEmpty();
|
||||
});
|
||||
|
||||
it("lehnt ein falsches Passwort ab und lässt den Status unverändert", function () {
|
||||
// without CIS_GESAMTNOTE_FREIGABE_PASSWORT every password passes
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_FREIGABE_PASSWORT", true);
|
||||
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, { note: ctx.notenScale[0], freigegeben: false, erstantritt: false });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
page.openFreigabeModal();
|
||||
|
||||
page.typeFreigabePassword("definitely-not-the-password");
|
||||
page.submitFreigabe();
|
||||
|
||||
// the page shows the rejection, and the state stays the same
|
||||
page.expectRejected("@saveFreigabe");
|
||||
page.expectFreigabeState(student.uid, "changed");
|
||||
|
||||
readLvGesamtnoteViaDb(ctx, student).then((rowData) => {
|
||||
expect(rowData.freigabedatum, "eine abgelehnte Freigabe stempelt nichts").to.be.null;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Freigabe", () => {
|
||||
it("gibt frei und schaltet die Statusspalte auf freigegeben", () => {
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, { note: ctx.notenScale[0], freigegeben: false, erstantritt: false });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
page.expectFreigabeState(student.uid, "changed");
|
||||
|
||||
page.openFreigabeModal();
|
||||
page.saveFreigabe(freigabePassword());
|
||||
|
||||
page.expectFreigabeState(student.uid, "freigegeben");
|
||||
page.expectLvNote(student.uid, bezeichnung(ctx.notenScale[0]));
|
||||
});
|
||||
|
||||
it("legt mit der Freigabe den ersten Antritt an", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true);
|
||||
|
||||
const student = ctx.students[1];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, { note: ctx.notenScale[0], freigegeben: false, erstantritt: false });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
page.openFreigabeModal();
|
||||
page.saveFreigabe(freigabePassword());
|
||||
|
||||
// the Freigabe writes Antritt 1; a new load proves that the server stored it
|
||||
page.visitAndWaitForTable(ctx);
|
||||
page.expectPruefung(student.uid, "antritt_1", { note: ctx.notenScale[0], antritt: 1 });
|
||||
page.expectAntrittCount(student.uid, 1);
|
||||
|
||||
// Antritt 1 gets the benotungsdatum of the LV-Note, not an implicit date
|
||||
page.getCell(student.uid, "antritt_1").should("contain.text", page.toDDMMYYYY(baselineDate(ctx)));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import { loginAsLektor } from "../../../../support/api/notenApi";
|
||||
import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
|
||||
import { requireConfig, requireNotenMode, requireRepeat, skipIf } from "../../../../support/helpers/notenConfig";
|
||||
import {
|
||||
antrittDate,
|
||||
loadNotenContext,
|
||||
requireDbReset,
|
||||
resetNotenState,
|
||||
seedBaseline,
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
|
||||
/**
|
||||
* The two imports.
|
||||
*
|
||||
* Noten import: "uid<TAB>Note" per line; writes only the LV-Note.
|
||||
* Pruefungen import: "uid<TAB>dd.MM.yyyy<TAB>Note" per line; also creates the Pruefung.
|
||||
*
|
||||
* CIS_GESAMTNOTE_NOTENIMPORT / CIS_GESAMTNOTE_PRUEFUNGSIMPORT turn them on. Without the flag the
|
||||
* button is missing, and the block skips.
|
||||
*/
|
||||
describe("Benotungstool UI - Import", () => {
|
||||
let ctx;
|
||||
let bezeichnung;
|
||||
|
||||
before(() => {
|
||||
requireDbReset();
|
||||
loadNotenContext().then((loaded) => {
|
||||
ctx = loaded;
|
||||
bezeichnung = (note) => page.bezeichnungOf(ctx, note);
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => loginAsLektor());
|
||||
|
||||
beforeEach(function () {
|
||||
// in the Punkte mode both imports expect Punkte instead of a Note
|
||||
requireNotenMode(this, ctx);
|
||||
});
|
||||
|
||||
/**
|
||||
* A Note whose Kuerzel from tbl_note.anmerkung works in the import: unique, not empty, and not the
|
||||
* Note itself. If the configuration does not fit, it returns null, and the test skips.
|
||||
*/
|
||||
const kuerzelNote = () => {
|
||||
if (!ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_NOTENKUERZEL) return null;
|
||||
|
||||
const kuerzelOf = (n) =>
|
||||
String(n.anmerkung ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
return (ctx.notenOptions ?? []).find((n) => {
|
||||
const k = kuerzelOf(n);
|
||||
if (!n.lehre || k === "" || k === String(n.note).trim()) return false;
|
||||
|
||||
const duplicate = ctx.notenOptions.filter((o) => kuerzelOf(o) === k).length > 1;
|
||||
const isNoteValue = ctx.notenOptions.some((o) => String(o.note).trim() === k);
|
||||
|
||||
return !duplicate && !isNoteValue;
|
||||
});
|
||||
};
|
||||
|
||||
describe("Notenimport", () => {
|
||||
beforeEach(function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENIMPORT", true);
|
||||
});
|
||||
|
||||
it("schreibt die LV-Note für jede Zeile", () => {
|
||||
const [a, b] = ctx.students;
|
||||
|
||||
resetNotenState(ctx);
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.importLvNoten([
|
||||
[a.uid, ctx.notenScale[0]],
|
||||
[b.uid, ctx.notenScale[1]],
|
||||
]);
|
||||
|
||||
page.expectLvNote(a.uid, bezeichnung(ctx.notenScale[0]));
|
||||
page.expectLvNote(b.uid, bezeichnung(ctx.notenScale[1]));
|
||||
|
||||
// entered, but not freigegeben
|
||||
page.expectFreigabeState(a.uid, "changed");
|
||||
page.expectFreigabeState(b.uid, "changed");
|
||||
});
|
||||
|
||||
it("legt dabei den ersten Antritt an", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true);
|
||||
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.importLvNoten([[student.uid, ctx.notenScale[0]]]);
|
||||
|
||||
// the LV-Note IS Antritt 1; the import writes it as its own row
|
||||
page.expectAntrittCount(student.uid, 1);
|
||||
page.expectPruefung(student.uid, "antritt_1", { note: ctx.notenScale[0], antritt: 1 });
|
||||
});
|
||||
|
||||
it("nimmt das Kürzel aus der Notenliste, sobald die Option das erlaubt", function () {
|
||||
const kuerzel = kuerzelNote();
|
||||
skipIf(this, !kuerzel, "Übersprungen: keine Note mit brauchbarem Kürzel in tbl_note.anmerkung.");
|
||||
|
||||
const student = ctx.students[1];
|
||||
|
||||
resetNotenState(ctx);
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.importLvNoten([[student.uid, kuerzel.anmerkung]]);
|
||||
|
||||
page.expectLvNote(student.uid, kuerzel.bezeichnung);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Prüfungsimport", () => {
|
||||
beforeEach(function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNGSIMPORT", true);
|
||||
});
|
||||
|
||||
it("legt je Zeile einen datierten Antritt an", function () {
|
||||
requireRepeat(this, ctx);
|
||||
|
||||
const [a, b] = ctx.students;
|
||||
// the configuration sets the format, not the test
|
||||
const datum = page.importDate(antrittDate(ctx, 1), ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT);
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, a, { note: ctx.noten.negativ });
|
||||
seedBaseline(ctx, b, { note: ctx.noten.negativ });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.importPruefungen([
|
||||
[a.uid, datum, ctx.notenScale[1]],
|
||||
[b.uid, datum, ctx.notenScale[1]],
|
||||
]);
|
||||
|
||||
[a, b].forEach((student) => {
|
||||
page.expectPruefung(student.uid, "antritt_2", { note: ctx.notenScale[1], antritt: 2 });
|
||||
page.expectAntrittCount(student.uid, 2);
|
||||
page.expectLvNote(student.uid, bezeichnung(ctx.notenScale[1]));
|
||||
});
|
||||
});
|
||||
|
||||
it("legt für einen Studenten ohne LV-Note beides an", () => {
|
||||
const student = ctx.students[2];
|
||||
|
||||
resetNotenState(ctx);
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.importPruefungen([
|
||||
[
|
||||
student.uid,
|
||||
page.importDate(antrittDate(ctx, 1), ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT),
|
||||
ctx.notenScale[0],
|
||||
],
|
||||
]);
|
||||
|
||||
page.expectPruefung(student.uid, "antritt_1", { note: ctx.notenScale[0], antritt: 1 });
|
||||
page.expectLvNote(student.uid, bezeichnung(ctx.notenScale[0]));
|
||||
});
|
||||
});
|
||||
|
||||
// The client checks each line before it sends. It shows a warning for each invalid line and sends
|
||||
// only the other lines. It does not look for duplicate lines, on purpose.
|
||||
describe("fehlerhafte Zeilen", () => {
|
||||
it("warnt im Prüfungsimport je fehlerhafter Zeile und schickt nur die gültigen", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNGSIMPORT", true);
|
||||
requireRepeat(this, ctx);
|
||||
|
||||
const [perUid, perMatrikelnr, wrongDate, wrongNote, wrongColumns] = ctx.students;
|
||||
const format = ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT;
|
||||
const datum = page.importDate(antrittDate(ctx, 1), format);
|
||||
const year = antrittDate(ctx, 1).slice(0, 4);
|
||||
const impossible = format === "yyyy-MM-dd" ? `${year}-02-31` : `31.02.${year}`;
|
||||
const note = ctx.noten.negativ;
|
||||
|
||||
expect(perMatrikelnr.matrikelnr, "Matrikelnummer des Studierenden").to.be.a("string").and.not.be.empty;
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, perUid, { note: ctx.noten.negativ });
|
||||
seedBaseline(ctx, perMatrikelnr, { note: ctx.noten.negativ });
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.importPruefungen([
|
||||
[perUid.uid, datum, note],
|
||||
[perMatrikelnr.matrikelnr, datum, note],
|
||||
["zz_unbekannt", datum, note],
|
||||
[wrongDate.uid, impossible, note],
|
||||
[wrongNote.uid, datum, "keine-note"],
|
||||
[wrongColumns.uid, datum],
|
||||
]);
|
||||
|
||||
page.expectWarning("zz_unbekannt");
|
||||
page.expectWarning(wrongDate.uid);
|
||||
page.expectWarning(wrongNote.uid);
|
||||
// a line with the wrong number of columns names only its line number
|
||||
page.expectWarnings(4);
|
||||
|
||||
page.sentUids("@importPruefungen", "pruefungen").then((uids) => {
|
||||
expect(uids, "nur die gültigen Zeilen, die Matrikelnummer aufgelöst").to.deep.eq([
|
||||
perUid.uid,
|
||||
perMatrikelnr.uid,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("warnt im Notenimport je fehlerhafter Zeile und schickt nur die gültigen", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENIMPORT", true);
|
||||
|
||||
const [valid, wrongNote, wrongColumns] = ctx.students;
|
||||
|
||||
resetNotenState(ctx);
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.importLvNoten([
|
||||
[valid.uid, ctx.notenScale[0]],
|
||||
["zz_unbekannt", ctx.notenScale[0]],
|
||||
[wrongNote.uid, "keine-note"],
|
||||
[wrongColumns.uid, ctx.notenScale[0], "zu viel"],
|
||||
]);
|
||||
|
||||
page.expectWarning("zz_unbekannt");
|
||||
page.expectWarning(wrongNote.uid);
|
||||
page.expectWarnings(3);
|
||||
|
||||
page.sentUids("@importLvNoten", "lv_noten").then((uids) => {
|
||||
expect(uids, "nur die gültige Zeile").to.deep.eq([valid.uid]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { loginAsLektor } from "../../../../support/api/notenApi";
|
||||
import { waitForOk } from "../../../../support/helpers/network";
|
||||
import {
|
||||
assistenzAuth,
|
||||
assistenzLvWithLehreinheiten,
|
||||
requireAssistenz,
|
||||
} from "../../../../support/helpers/notenAssistenz";
|
||||
import { loadNotenContext, requireDbReset } from "../../../../support/helpers/notenTestData";
|
||||
|
||||
/**
|
||||
* The route is /Cis/Benotungstool/:sem_kurzbz?/:lv_id?. The semester comes before the LV, so the
|
||||
* router does not read a URL without an LV as lv_id.
|
||||
*/
|
||||
describe("Benotungstool UI - Navigation", () => {
|
||||
const SEMESTER = "[data-cy='dropdown-semester']";
|
||||
const TIMEOUT = 60_000;
|
||||
|
||||
/** The text that appears in the closed dropdown once the component has selected a semester. */
|
||||
const selectedSemester = () =>
|
||||
cy
|
||||
.get(`${SEMESTER} .p-dropdown-label`, { timeout: TIMEOUT })
|
||||
.should(($label) => expect($label.text().trim(), "vorausgewähltes Semester").to.not.be.empty)
|
||||
.then(($label) => $label.text().trim());
|
||||
|
||||
beforeEach(() => loginAsLektor());
|
||||
|
||||
it("behält ein Semester ohne LV nach einem Reload", () => {
|
||||
cy.intercept({ method: "GET", url: "**/api/frontend/v1/Noten/getBenotungstoolContext*" }).as("context");
|
||||
cy.visit("/cis.php/Cis/Benotungstool");
|
||||
|
||||
selectedSemester()
|
||||
.then((preselected) => {
|
||||
cy.get(SEMESTER).click();
|
||||
return cy
|
||||
.get(".p-dropdown-panel .p-dropdown-item")
|
||||
.filter((i, el) => el.innerText.trim() !== preselected)
|
||||
.first();
|
||||
})
|
||||
.then(($item) => {
|
||||
const semKurzbz = $item.text().trim();
|
||||
|
||||
cy.wrap($item).click();
|
||||
waitForOk("@context");
|
||||
cy.location("pathname").should("match", new RegExp(`/Benotungstool/${semKurzbz}$`));
|
||||
|
||||
cy.reload();
|
||||
|
||||
selectedSemester().should("eq", semKurzbz);
|
||||
cy.get(".p-toast-message-error").should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
// the list used to contain only the LVs that the caller teaches
|
||||
it("zeigt einer Assistenz die Lehreinheiten der LV", function () {
|
||||
requireAssistenz(this);
|
||||
|
||||
const { username, password } = assistenzAuth();
|
||||
|
||||
requireDbReset();
|
||||
loadNotenContext().then((ctx) => {
|
||||
cy.clearAllCookies();
|
||||
|
||||
assistenzLvWithLehreinheiten(ctx.semKurzbz).then((target) => {
|
||||
expect(target, "eine LV der Assistenz mit Lehreinheiten").to.not.be.null;
|
||||
|
||||
cy.login(username, password);
|
||||
cy.intercept({ method: "GET", url: "**/api/frontend/v1/Noten/getLehreinheitenForLv*" }).as(
|
||||
"lehreinheiten",
|
||||
);
|
||||
cy.visit(`/cis.php/Cis/Benotungstool/${target.semKurzbz}/${target.lvId}`);
|
||||
|
||||
waitForOk("@lehreinheiten");
|
||||
cy.get("[data-cy='dropdown-lehreinheit']", { timeout: TIMEOUT }).click();
|
||||
cy.get(".p-dropdown-panel .p-dropdown-item").should("have.length", target.lehreinheiten.length);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { loginAsLektor } from "../../../../support/api/notenApi";
|
||||
import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
|
||||
import { requireConfig, requireNotenMode } from "../../../../support/helpers/notenConfig";
|
||||
import {
|
||||
antrittDate,
|
||||
baselineDate,
|
||||
loadNotenContext,
|
||||
readLvGesamtnoteViaDb,
|
||||
requireDbReset,
|
||||
resetNotenState,
|
||||
seedBaseline,
|
||||
seedPruefung,
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
|
||||
/**
|
||||
* Enter a proposal and apply it as the LV-Note.
|
||||
*
|
||||
* api/noten/lvnote.cy.js checks the rules. This spec checks that the table shows the server state
|
||||
* without a reload: the LV-Note, the Freigabe state, and that the apply button goes away.
|
||||
*/
|
||||
describe("Benotungstool UI - Notenvorschlag", () => {
|
||||
let ctx;
|
||||
let bezeichnung;
|
||||
|
||||
before(() => {
|
||||
requireDbReset();
|
||||
loadNotenContext().then((loaded) => {
|
||||
ctx = loaded;
|
||||
bezeichnung = (note) => page.bezeichnungOf(ctx, note);
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => loginAsLektor());
|
||||
|
||||
beforeEach(function () {
|
||||
// in the Punkte mode the proposal column is locked: the Note comes from the Punkte
|
||||
requireNotenMode(this, ctx);
|
||||
});
|
||||
|
||||
it("trägt einen Vorschlag über den Zelleneditor ein", () => {
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
// without a Note the row is empty: no benotungsdatum -> open
|
||||
page.expectFreigabeState(student.uid, "open");
|
||||
|
||||
page.setProposal(student.uid, bezeichnung(ctx.notenScale[0]));
|
||||
|
||||
page.expectProposal(student.uid, bezeichnung(ctx.notenScale[0]));
|
||||
// the proposal alone writes no LV-Note
|
||||
page.expectFreigabeState(student.uid, "open");
|
||||
});
|
||||
|
||||
it("übernimmt den Vorschlag als LV-Note und zeigt den Status changed", () => {
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.setProposal(student.uid, bezeichnung(ctx.notenScale[0]));
|
||||
page.applyProposal(student.uid);
|
||||
|
||||
page.expectLvNote(student.uid, bezeichnung(ctx.notenScale[0]));
|
||||
page.expectFreigabeState(student.uid, "changed");
|
||||
});
|
||||
|
||||
// The selected date is the date of Antritt 1, not the benotungsdatum. The benotungsdatum stays the
|
||||
// time of the entry, otherwise a changed Note would look freigegeben.
|
||||
it("schreibt das im Dialog gewählte Datum in den ersten Antritt", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true);
|
||||
|
||||
const student = ctx.students[2];
|
||||
const datum = baselineDate(ctx);
|
||||
|
||||
resetNotenState(ctx);
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.setProposal(student.uid, bezeichnung(ctx.notenScale[0]));
|
||||
page.applyProposal(student.uid, { datum: page.toDDMMYYYY(datum) });
|
||||
|
||||
page.expectLvNote(student.uid, bezeichnung(ctx.notenScale[0]));
|
||||
page.getCell(student.uid, "antritt_1").should("contain.text", page.toDDMMYYYY(datum));
|
||||
|
||||
readLvGesamtnoteViaDb(ctx, student).then((rowData) => {
|
||||
expect(String(rowData.benotungsdatum).slice(0, 10), "das benotungsdatum bleibt heute").to.eq(
|
||||
new Date().toISOString().slice(0, 10),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// A single repeat without Antritt 1, as the StV can leave it. After a repeat the Note belongs to the
|
||||
// Pruefung: the proposal column is locked, so the row has neither an editor nor a button. The control
|
||||
// row shows that the button appears without a repeat.
|
||||
it("zeigt bei einer einzelnen Wiederholung keinen Übernehmen-Button", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_VORSCHLAG_NACH_WIEDERHOLUNG", false);
|
||||
|
||||
const wiederholung = ctx.students[3];
|
||||
const control = ctx.students[4];
|
||||
// "Sehr Gut" first: setProposal uses contains, and "Gut" would match "Sehr Gut"
|
||||
const vorschlag = ctx.notenScale[0];
|
||||
const pruefungsnote = ctx.notenScale.find(
|
||||
(n) => ![vorschlag, ctx.noten.negativ].map(String).includes(String(n)),
|
||||
);
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, wiederholung, { erstantritt: false });
|
||||
seedBaseline(ctx, control);
|
||||
seedPruefung(ctx, wiederholung, { note: pruefungsnote, datum: antrittDate(ctx, 1), type: "Termin2" });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.setProposal(control.uid, bezeichnung(vorschlag));
|
||||
page.getApplyProposalButton(control.uid).should("exist");
|
||||
|
||||
page.expectProposalLocked(wiederholung.uid);
|
||||
page.getApplyProposalButton(wiederholung.uid).should("not.exist");
|
||||
});
|
||||
|
||||
// With the switch on the server leaves the LV-Note open after a repeat, and the page follows it.
|
||||
it("bietet nach einer Wiederholung den Übernehmen-Button an, wenn der Schalter an ist", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_VORSCHLAG_NACH_WIEDERHOLUNG", true);
|
||||
|
||||
const student = ctx.students[3];
|
||||
const vorschlag = ctx.notenScale[0];
|
||||
const pruefungsnote = ctx.notenScale.find(
|
||||
(n) => ![vorschlag, ctx.noten.negativ].map(String).includes(String(n)),
|
||||
);
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, { erstantritt: false });
|
||||
seedPruefung(ctx, student, { note: pruefungsnote, datum: antrittDate(ctx, 1), type: "Termin2" });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.setProposal(student.uid, bezeichnung(vorschlag));
|
||||
page.getApplyProposalButton(student.uid).should("exist");
|
||||
});
|
||||
|
||||
// The LV-Note is never 'entschuldigt', so the editor does not offer that Note.
|
||||
it("bietet 'entschuldigt' nicht als Notenvorschlag an", () => {
|
||||
const student = ctx.students[1];
|
||||
|
||||
resetNotenState(ctx);
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.getCell(student.uid, "proposed_note").click();
|
||||
|
||||
// exact match: "unentschuldigt" contains "entschuldigt"
|
||||
cy.get(".tabulator-edit-list-item")
|
||||
.should("have.length.greaterThan", 0)
|
||||
.then((items) => {
|
||||
const labels = [...items].map((item) => item.innerText.trim());
|
||||
expect(labels, "Optionen des Editors").to.include(bezeichnung(ctx.notenScale[0]));
|
||||
expect(labels, "Optionen des Editors").to.not.include(bezeichnung(ctx.noten.entschuldigt));
|
||||
});
|
||||
});
|
||||
|
||||
it("blendet den Übernehmen-Button aus, sobald Vorschlag und LV-Note übereinstimmen", () => {
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.setProposal(student.uid, bezeichnung(ctx.notenScale[0]));
|
||||
page.applyProposal(student.uid);
|
||||
|
||||
page.getApplyProposalButton(student.uid).should("not.exist");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
import { loginAsLektor } from "../../../../support/api/notenApi";
|
||||
import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
|
||||
import { waitForOk } from "../../../../support/helpers/network";
|
||||
import {
|
||||
requireKommissionellerAntritt,
|
||||
requireConfig,
|
||||
requireNotenMode,
|
||||
requireRepeat,
|
||||
skipIf,
|
||||
} from "../../../../support/helpers/notenConfig";
|
||||
import { expectNotenSuccess } from "../../../../support/helpers/notenErrors";
|
||||
import { addPruefung, givenBaseline } from "../../../../support/helpers/notenScenario";
|
||||
import {
|
||||
antrittDate,
|
||||
loadNotenContext,
|
||||
readLvGesamtnoteViaDb,
|
||||
requireDbReset,
|
||||
resetNotenState,
|
||||
seedBaseline,
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
|
||||
/**
|
||||
* Add and edit Pruefungen in both ways: the dialog of a table cell and the "Neue Prüfung" dialog.
|
||||
* Both use the same core on the server (see api/noten/pruefung.cy.js). The spec checks that the cell
|
||||
* is correct without a reload.
|
||||
*
|
||||
* The specs use the column layout "antritt" (the "Termine" button), because its column names are fixed:
|
||||
* antritt_1, antritt_2, ... In the date mode a column is named after the date of the Pruefung.
|
||||
*/
|
||||
describe("Benotungstool UI - Prüfungen", () => {
|
||||
let ctx;
|
||||
let bezeichnung;
|
||||
|
||||
before(() => {
|
||||
requireDbReset();
|
||||
loadNotenContext().then((loaded) => {
|
||||
ctx = loaded;
|
||||
bezeichnung = (note) => page.bezeichnungOf(ctx, note);
|
||||
|
||||
expect(ctx.maxAntritte, "braucht Platz für mindestens eine Wiederholung").to.be.greaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => loginAsLektor());
|
||||
|
||||
beforeEach(function () {
|
||||
// in the Punkte mode the Note field is locked: the Note comes from the Notenschluessel
|
||||
requireNotenMode(this, ctx);
|
||||
});
|
||||
|
||||
it("legt aus der Zelle eine Wiederholung an und zählt sie als Antritt 2", function () {
|
||||
requireRepeat(this, ctx);
|
||||
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, { note: ctx.noten.negativ });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.addPruefungInCell(student.uid, "antritt_2", {
|
||||
note: bezeichnung(ctx.notenScale[1]),
|
||||
datum: page.toDDMMYYYY(antrittDate(ctx, 1)),
|
||||
});
|
||||
|
||||
page.expectPruefung(student.uid, "antritt_2", { note: ctx.notenScale[1], antritt: 2 });
|
||||
page.expectAntrittCount(student.uid, 2);
|
||||
|
||||
// the LV-Note follows the newest Antritt
|
||||
page.expectLvNote(student.uid, bezeichnung(ctx.notenScale[1]));
|
||||
|
||||
// the cell shows the Freigabe state that the server wrote; api/noten/pruefung.cy.js checks the rule
|
||||
readLvGesamtnoteViaDb(ctx, student).then((row) => {
|
||||
const changed = new Date(row.benotungsdatum) > new Date(row.freigabedatum);
|
||||
page.expectFreigabeState(student.uid, changed ? "changed" : "freigegeben");
|
||||
});
|
||||
});
|
||||
|
||||
it("nennt nach einer positiven Note den Grund statt der Schaltfläche", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENVERBESSERUNG", false);
|
||||
skipIf(
|
||||
this,
|
||||
!ctx.noten.positiv,
|
||||
"Übersprungen: keine positive Note, die die Kette nur ohne Notenverbesserung schliesst.",
|
||||
);
|
||||
|
||||
const student = ctx.students[0];
|
||||
|
||||
givenBaseline(ctx, student, { note: ctx.noten.positiv });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.getBestandenHint(student.uid, "antritt_2").should("exist").and("have.attr", "title").and("not.be.empty");
|
||||
page.getPruefungAddButton(student.uid, "antritt_2").should("not.exist");
|
||||
});
|
||||
|
||||
it("legt einen Antritt für einen Studenten ohne LV-Note an und weist darauf hin", () => {
|
||||
const student = ctx.students[2];
|
||||
|
||||
resetNotenState(ctx);
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.getPruefungAddButton(student.uid, "antritt_1").click();
|
||||
page.getPruefungModal().should("be.visible");
|
||||
cy.get("[data-cy='pruefung-without-lvnote']").should("be.visible");
|
||||
cy.get("[data-cy='pruefung-submit']").click();
|
||||
page.getPruefungModal().should("not.be.visible");
|
||||
|
||||
// without a selected Note the server writes "Noch nicht eingetragen", which uses no Antritt
|
||||
page.expectPruefung(student.uid, "antritt_1", { note: ctx.noten.nochNichtEingetragen });
|
||||
page.expectLvNote(student.uid, bezeichnung(ctx.noten.nochNichtEingetragen));
|
||||
page.expectAntrittCount(student.uid, 0);
|
||||
});
|
||||
|
||||
it("korrigiert nur das Datum eines bestehenden Antritts", function () {
|
||||
requireRepeat(this, ctx);
|
||||
|
||||
const student = ctx.students[0];
|
||||
const newDate = antrittDate(ctx, 2);
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, { note: ctx.noten.negativ });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.addPruefungInCell(student.uid, "antritt_2", {
|
||||
note: bezeichnung(ctx.notenScale[1]),
|
||||
datum: page.toDDMMYYYY(antrittDate(ctx, 1)),
|
||||
});
|
||||
page.editPruefungInCell(student.uid, "antritt_2", { datum: page.toDDMMYYYY(newDate) });
|
||||
|
||||
page.expectPruefung(student.uid, "antritt_2", { note: ctx.notenScale[1], antritt: 2 });
|
||||
page.getCell(student.uid, "antritt_2").should("contain.text", page.toDDMMYYYY(newDate));
|
||||
});
|
||||
|
||||
it("sperrt die Note, sobald ein späterer Antritt existiert", function () {
|
||||
requireRepeat(this, ctx);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETERER_PRUEFUNG", true);
|
||||
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, { note: ctx.noten.negativ });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
// entschuldigt first: it uses no Antritt, so the third column stays reachable also with
|
||||
// maxAntritte = 2
|
||||
page.addPruefungInCell(student.uid, "antritt_2", {
|
||||
note: bezeichnung(ctx.noten.entschuldigt),
|
||||
datum: page.toDDMMYYYY(antrittDate(ctx, 1)),
|
||||
});
|
||||
page.addPruefungInCell(student.uid, "antritt_3", {
|
||||
note: bezeichnung(ctx.notenScale[1]),
|
||||
datum: page.toDDMMYYYY(antrittDate(ctx, 2)),
|
||||
});
|
||||
|
||||
page.openPruefungModalForEdit(student.uid, "antritt_2");
|
||||
|
||||
cy.get("[data-cy='pruefung-note-locked']").should("be.visible");
|
||||
cy.get("[data-cy='pruefung-note']").should("have.class", "p-disabled");
|
||||
});
|
||||
|
||||
it("legt über die Sammelanlage für mehrere Studierende denselben Termin an", function () {
|
||||
requireRepeat(this, ctx);
|
||||
|
||||
const [a, b] = ctx.students;
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, a, { note: ctx.noten.negativ });
|
||||
seedBaseline(ctx, b, { note: ctx.noten.negativ });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.createPruefungen({
|
||||
uids: [a.uid, b.uid],
|
||||
note: bezeichnung(ctx.notenScale[1]),
|
||||
datum: page.toDDMMYYYY(antrittDate(ctx, 1)),
|
||||
});
|
||||
|
||||
[a, b].forEach((student) => {
|
||||
page.expectPruefung(student.uid, "antritt_2", { note: ctx.notenScale[1], antritt: 2 });
|
||||
page.expectAntrittCount(student.uid, 2);
|
||||
page.expectLvNote(student.uid, bezeichnung(ctx.notenScale[1]));
|
||||
});
|
||||
});
|
||||
|
||||
// The table holds the selection; the multiselect of the dialog changes it through the table.
|
||||
it("gleicht die Auswahl zwischen Tabelle und Sammeldialog ab", function () {
|
||||
requireRepeat(this, ctx);
|
||||
|
||||
const [a, b] = ctx.students;
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, a, { note: ctx.noten.negativ });
|
||||
seedBaseline(ctx, b, { note: ctx.noten.negativ });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.getRowCheckbox(a.uid).click();
|
||||
page.openCreatePruefungen();
|
||||
page.expectStudentsInDialog([a.uid]);
|
||||
|
||||
// b in, a out
|
||||
page.toggleStudentsInDialog([b.uid, a.uid]);
|
||||
|
||||
page.expectStudentsInDialog([b.uid]);
|
||||
page.expectRowSelected(b.uid, true);
|
||||
page.expectRowSelected(a.uid, false);
|
||||
});
|
||||
|
||||
// The "Neue Prüfung" dialog checks a Pruefung on the same day like the server does,
|
||||
// by CIS_GESAMTNOTE_PRUEFUNG_GLEICHER_TAG.
|
||||
describe("Termin am selben Tag im Sammeldialog", () => {
|
||||
beforeEach(function () {
|
||||
requireRepeat(this, ctx);
|
||||
});
|
||||
|
||||
// 'entschuldigt' uses no Antritt, so one more Pruefung stays possible
|
||||
const withPruefungOn = (student, datum) => {
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, { note: ctx.noten.negativ });
|
||||
addPruefung(ctx, student, { note: ctx.noten.entschuldigt, datum }).then((response) =>
|
||||
expectNotenSuccess(response, "bestehender Termin"),
|
||||
);
|
||||
page.visitAndWaitForTable(ctx);
|
||||
};
|
||||
|
||||
const submitNewPruefung = (student, datum) =>
|
||||
page.submitCreatePruefungen({ uids: [student.uid], datum: page.toDDMMYYYY(datum) });
|
||||
|
||||
it("schickt einen Termin am selben Tag ab, wenn der Schalter an ist", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNG_GLEICHER_TAG", true);
|
||||
|
||||
const student = ctx.students[0];
|
||||
const datum = antrittDate(ctx, 1);
|
||||
|
||||
withPruefungOn(student, datum);
|
||||
submitNewPruefung(student, datum);
|
||||
|
||||
waitForOk("@createPruefungen");
|
||||
cy.get(".p-toast-message-warn").should("not.exist");
|
||||
});
|
||||
|
||||
it("warnt bei einem Termin am selben Tag und schickt nichts ab", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNG_GLEICHER_TAG", false);
|
||||
|
||||
const student = ctx.students[0];
|
||||
const datum = antrittDate(ctx, 1);
|
||||
|
||||
withPruefungOn(student, datum);
|
||||
submitNewPruefung(student, datum);
|
||||
|
||||
cy.get(".p-toast-message-warn").should("contain.text", student.uid);
|
||||
page.getPruefungModal().should("not.be.visible");
|
||||
cy.get("@createPruefungen.all").should("have.length", 0);
|
||||
});
|
||||
});
|
||||
|
||||
it("legt den letzten Antritt als kommissionelle Prüfung an", function () {
|
||||
requireKommissionellerAntritt(this, ctx);
|
||||
skipIf(
|
||||
this,
|
||||
ctx.cisConfig.CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT !== ctx.maxAntritte,
|
||||
"Übersprungen: der letzte Antritt ist nicht kommissionell.",
|
||||
);
|
||||
|
||||
const student = ctx.students[1];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, { note: ctx.noten.negativ });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
// the baseline gives Antritt 1; the normal columns go up to the second-to-last Antritt
|
||||
for (let i = 1; i < ctx.maxAntritte - 1; i += 1) {
|
||||
page.addPruefungInCell(student.uid, `antritt_${i + 1}`, {
|
||||
note: bezeichnung(ctx.noten.negativ),
|
||||
datum: page.toDDMMYYYY(antrittDate(ctx, i)),
|
||||
});
|
||||
}
|
||||
|
||||
// By the Pruefungsordnung the last Antritt is kommissionell. It gets no column of its own,
|
||||
// but the next Pruefung column. The K in the cell marks it.
|
||||
const lastAntrittColumn = `antritt_${ctx.maxAntritte}`;
|
||||
|
||||
page.getPruefungAddButton(student.uid, lastAntrittColumn).should("exist");
|
||||
|
||||
page.addPruefungInCell(student.uid, lastAntrittColumn, {
|
||||
note: bezeichnung(ctx.noten.negativ),
|
||||
datum: page.toDDMMYYYY(antrittDate(ctx, ctx.maxAntritte)),
|
||||
});
|
||||
|
||||
page.expectAntrittCount(student.uid, ctx.maxAntritte);
|
||||
// the badge shows the Antritt number and the "K" of the kommissionelle Pruefung
|
||||
page.expectPruefung(student.uid, lastAntrittColumn, {
|
||||
note: ctx.noten.negativ,
|
||||
antritt: `${ctx.maxAntritte}-K`,
|
||||
});
|
||||
page.expectNoAntrittColumn(ctx.maxAntritte + 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
|
||||
import { requireConfig, requirePunkteMode, requireRepeat } from "../../../../support/helpers/notenConfig";
|
||||
import { pruefungenOf, readStateViaApi } from "../../../../support/helpers/notenScenario";
|
||||
import {
|
||||
antrittDate,
|
||||
loadNotenContext,
|
||||
readLvGesamtnoteViaDb,
|
||||
requireDbReset,
|
||||
resetNotenState,
|
||||
seedBaseline,
|
||||
seedPruefung,
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
import { notenApi, loginAsLektor } from "../../../../support/api/notenApi";
|
||||
|
||||
/**
|
||||
* The Punkte mode (CIS_GESAMTNOTE_PUNKTE) on the page.
|
||||
*
|
||||
* The Punkte column and the Punkte fields of both dialogs exist only with this flag. The Note then
|
||||
* comes from the Notenschluessel; nobody selects it. No other spec covers these fields, so the whole
|
||||
* mode has its own file.
|
||||
*
|
||||
* The spec never hardcodes the Notenschluessel: it gets the expected Note from getNoteByPunkte at
|
||||
* runtime, so it works with the Notenschluessel of each instance.
|
||||
*/
|
||||
describe("Benotungstool UI - Punktemodus", () => {
|
||||
let ctx;
|
||||
// Punkte values; the test gets their Noten at runtime
|
||||
const TOP = 100;
|
||||
const MIDDLE = 70;
|
||||
let noteTop;
|
||||
let noteMiddle;
|
||||
|
||||
before(() => {
|
||||
requireDbReset();
|
||||
loadNotenContext().then((loaded) => {
|
||||
ctx = loaded;
|
||||
if (!ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) return;
|
||||
|
||||
notenApi.getNoteByPunkte(ctx.lvId, ctx.semKurzbz, TOP).then((r) => {
|
||||
noteTop = r.body.data;
|
||||
expect(noteTop, `${TOP} Punkte müssen eine Note ergeben`).to.not.be.null;
|
||||
});
|
||||
notenApi.getNoteByPunkte(ctx.lvId, ctx.semKurzbz, MIDDLE).then((r) => {
|
||||
noteMiddle = r.body.data;
|
||||
expect(noteMiddle, `${MIDDLE} Punkte müssen eine Note ergeben`).to.not.be.null;
|
||||
expect(String(noteMiddle), "die beiden Punktewerte müssen verschiedene Noten liefern").to.not.eq(
|
||||
String(noteTop),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => loginAsLektor());
|
||||
|
||||
beforeEach(function () {
|
||||
requirePunkteMode(this, ctx);
|
||||
});
|
||||
|
||||
describe("Punktespalte in der Tabelle", () => {
|
||||
it("zeigt die Punktespalte und sperrt dafür die Notenauswahl", () => {
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.getPunkteCell(student.uid).should("exist");
|
||||
// the Note comes from the Punkte; nobody may select it directly
|
||||
page.expectProposalLocked(student.uid);
|
||||
});
|
||||
|
||||
it("schreibt Note und Punkte, wenn der Vorschlag übernommen wird", () => {
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.setPunkteInCell(student.uid, MIDDLE);
|
||||
page.applyProposal(student.uid);
|
||||
|
||||
page.expectLvNote(student.uid, page.bezeichnungOf(ctx, noteMiddle));
|
||||
page.expectFreigabeState(student.uid, "changed");
|
||||
|
||||
readLvGesamtnoteViaDb(ctx, student).then((row) => {
|
||||
expect(String(row.note), "abgeleitete Note").to.eq(String(noteMiddle));
|
||||
expect(Number(row.punkte), "die Punkte werden mitgeschrieben").to.eq(MIDDLE);
|
||||
});
|
||||
});
|
||||
|
||||
// Antritt 1 and the LV-Note are the same result, so the column stays open for it.
|
||||
// Only a repeat takes Note and Punkte away from the LV-Note.
|
||||
it("sperrt die Punktespalte ab der ersten Wiederholung", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_VORSCHLAG_NACH_WIEDERHOLUNG", false);
|
||||
|
||||
const student = ctx.students[1];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, { note: ctx.notenScale[0], freigegeben: true });
|
||||
seedPruefung(ctx, student, {
|
||||
note: ctx.notenScale[0],
|
||||
datum: antrittDate(ctx, 1),
|
||||
type: "Termin2",
|
||||
});
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.expectPunkteCellLocked(student.uid);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Prüfungsdialog", () => {
|
||||
it("bietet ein Punktefeld statt der Notenauswahl", () => {
|
||||
const student = ctx.students[1];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, { note: ctx.noten.negativ, freigegeben: true });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
page.getPruefungAddButton(student.uid, "antritt_2").click();
|
||||
page.getPruefungModal().should("be.visible");
|
||||
|
||||
cy.get("[data-cy='pruefung-punkte']").should("be.visible");
|
||||
page.expectNoteFieldLocked("pruefung-note");
|
||||
});
|
||||
|
||||
it("legt den Termin mit der aus den Punkten abgeleiteten Note an", () => {
|
||||
const student = ctx.students[1];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, { note: ctx.noten.negativ, freigegeben: true });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
page.getPruefungAddButton(student.uid, "antritt_2").click();
|
||||
page.getPruefungModal().should("be.visible");
|
||||
|
||||
page.setDate("pruefung-datum", page.toDDMMYYYY(antrittDate(ctx, 1)));
|
||||
page.setPruefungPunkte(TOP);
|
||||
|
||||
cy.get("[data-cy='pruefung-submit']").click();
|
||||
cy.wait("@savePruefung").its("response.statusCode").should("eq", 200);
|
||||
page.getPruefungModal().should("not.be.visible");
|
||||
|
||||
page.expectPruefung(student.uid, "antritt_2", { note: noteTop, antritt: 2 });
|
||||
page.expectLvNote(student.uid, page.bezeichnungOf(ctx, noteTop));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sammelanlage", () => {
|
||||
// the "Neue Prüfung" dialog sends the Punkte; the server derives the Note from them
|
||||
it("legt den Termin mit der aus den Punkten abgeleiteten Note an", function () {
|
||||
requireRepeat(this, ctx);
|
||||
|
||||
const [a, b] = [ctx.students[2], ctx.students[3]];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, a, { note: ctx.noten.negativ, freigegeben: true });
|
||||
seedBaseline(ctx, b, { note: ctx.noten.negativ, freigegeben: true });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
page.createPruefungen({
|
||||
uids: [a.uid, b.uid],
|
||||
punkte: MIDDLE,
|
||||
datum: page.toDDMMYYYY(antrittDate(ctx, 1)),
|
||||
});
|
||||
|
||||
[a, b].forEach((student) =>
|
||||
page.expectPruefung(student.uid, "antritt_2", { note: noteMiddle, antritt: 2 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// The Excel list writes decimals with a comma or with a dot. Both give the same number.
|
||||
describe("Import mit Punkten", () => {
|
||||
const PUNKTE = 89.5;
|
||||
|
||||
[
|
||||
["Dezimalkomma", "89,5"],
|
||||
["Dezimalpunkt", "89.5"],
|
||||
].forEach(([notation, input]) => {
|
||||
it(`liest Punkte mit ${notation} im Prüfungsimport`, function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNGSIMPORT", true);
|
||||
requireRepeat(this, ctx);
|
||||
|
||||
const student = ctx.students[4];
|
||||
const datum = page.importDate(antrittDate(ctx, 1), ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT);
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, { note: ctx.noten.negativ, freigegeben: true });
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.importPruefungen([[student.uid, datum, input]]);
|
||||
|
||||
cy.get("@importPruefungen").its("request.body.pruefungen.0.punkte").should("eq", PUNKTE);
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const newPruefung = pruefungenOf(data, student.uid)[1];
|
||||
expect(newPruefung, "der importierte Termin").to.exist;
|
||||
expect(Number(newPruefung.punkte), "die Punkte des Termins").to.eq(PUNKTE);
|
||||
});
|
||||
});
|
||||
|
||||
it(`liest Punkte mit ${notation} im Notenimport`, function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENIMPORT", true);
|
||||
|
||||
const student = ctx.students[5];
|
||||
|
||||
resetNotenState(ctx);
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.importLvNoten([[student.uid, input]]);
|
||||
|
||||
cy.get("@importLvNoten").its("request.body.lv_noten.0.punkte").should("eq", PUNKTE);
|
||||
|
||||
readLvGesamtnoteViaDb(ctx, student).then((row) => {
|
||||
expect(row, "die importierte LV-Note").to.not.be.null;
|
||||
expect(Number(row.punkte), "die Punkte der LV-Note").to.eq(PUNKTE);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { loginAsLektor } from "../../../../support/api/notenApi";
|
||||
import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
|
||||
import { requireConfig, requireNotenMode, requireRepeat } from "../../../../support/helpers/notenConfig";
|
||||
import {
|
||||
antrittDate,
|
||||
loadNotenContext,
|
||||
requireDbReset,
|
||||
resetNotenState,
|
||||
seedBaseline,
|
||||
seedPruefung,
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
|
||||
/**
|
||||
* The server rejects a save, and the page shows it.
|
||||
*
|
||||
* Each test loads the table and then changes the database. So the table is out of date, as if
|
||||
* someone entered data in a second window. The client checks the old state and sends the request;
|
||||
* only the server rejects it. The test reads the message from the response and expects exactly this
|
||||
* message in a toast. So it needs no phrase and runs in every profile.
|
||||
*/
|
||||
describe("Benotungstool UI - Ablehnung durch den Server", () => {
|
||||
let ctx;
|
||||
let bezeichnung;
|
||||
|
||||
before(() => {
|
||||
requireDbReset();
|
||||
loadNotenContext().then((loaded) => {
|
||||
ctx = loaded;
|
||||
bezeichnung = (note) => page.bezeichnungOf(ctx, note);
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => loginAsLektor());
|
||||
|
||||
beforeEach(function () {
|
||||
// in the Punkte mode the Note fields are locked: the Note comes from the Punkte
|
||||
requireNotenMode(this, ctx);
|
||||
});
|
||||
|
||||
/** An entschuldigt Pruefung after the date that the test enters. It uses no Antritt. */
|
||||
const laterPruefungInBackground = (student) =>
|
||||
seedPruefung(ctx, student, { note: ctx.noten.entschuldigt, datum: antrittDate(ctx, 3), type: "Termin2" });
|
||||
|
||||
it("zeigt die Ablehnung eines Termins aus dem Zelldialog", function () {
|
||||
requireRepeat(this, ctx);
|
||||
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, { note: ctx.noten.negativ, freigegeben: true });
|
||||
page.visitAndWaitForTable(ctx);
|
||||
laterPruefungInBackground(student);
|
||||
|
||||
page.submitPruefungInCell(student.uid, "antritt_2", {
|
||||
note: bezeichnung(ctx.noten.negativ),
|
||||
datum: page.toDDMMYYYY(antrittDate(ctx, 1)),
|
||||
});
|
||||
|
||||
page.expectRejected("@savePruefung");
|
||||
page.expectNoPruefung(student.uid, "antritt_2");
|
||||
});
|
||||
|
||||
it("zeigt die Ablehnung einer Übernahme und behält die LV-Note", function () {
|
||||
// the repeat in the background locks the LV-Note only while this switch is off
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_VORSCHLAG_NACH_WIEDERHOLUNG", false);
|
||||
|
||||
const student = ctx.students[1];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, { note: ctx.noten.negativ, freigegeben: false, erstantritt: false });
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
// a Pruefung written in the background blocks the change; the table does not know it
|
||||
seedPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, 1), type: "Termin2" });
|
||||
|
||||
// "Sehr Gut" first: setProposal uses contains, and "Gut" would match "Sehr Gut"
|
||||
page.setProposal(student.uid, bezeichnung(ctx.notenScale[0]));
|
||||
page.submitApplyProposal(student.uid);
|
||||
|
||||
page.expectRejected("@saveLvNote");
|
||||
page.expectLvNote(student.uid, bezeichnung(ctx.noten.negativ));
|
||||
});
|
||||
|
||||
it("zeigt die abgelehnte Zeile der Sammelanlage und meldet keinen Erfolg", function () {
|
||||
requireRepeat(this, ctx);
|
||||
|
||||
const student = ctx.students[2];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, { note: ctx.noten.negativ, freigegeben: true });
|
||||
page.visitAndWaitForTable(ctx);
|
||||
laterPruefungInBackground(student);
|
||||
|
||||
page.submitCreatePruefungen({
|
||||
uids: [student.uid],
|
||||
note: bezeichnung(ctx.noten.negativ),
|
||||
datum: page.toDDMMYYYY(antrittDate(ctx, 1)),
|
||||
});
|
||||
|
||||
page.expectRowRejected("@createPruefungen", student.uid);
|
||||
page.expectNoSuccess();
|
||||
page.expectNoPruefung(student.uid, "antritt_2");
|
||||
});
|
||||
|
||||
it("zeigt die abgelehnte Zeile des Prüfungsimports und meldet keinen Erfolg", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNGSIMPORT", true);
|
||||
requireRepeat(this, ctx);
|
||||
|
||||
const student = ctx.students[3];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, { note: ctx.noten.negativ, freigegeben: true });
|
||||
page.visitAndWaitForTable(ctx);
|
||||
laterPruefungInBackground(student);
|
||||
|
||||
page.submitPruefungImport([
|
||||
[
|
||||
student.uid,
|
||||
page.importDate(antrittDate(ctx, 1), ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT),
|
||||
ctx.noten.negativ,
|
||||
],
|
||||
]);
|
||||
|
||||
page.expectRowRejected("@importPruefungen", student.uid);
|
||||
page.expectNoSuccess();
|
||||
page.expectNoPruefung(student.uid, "antritt_2");
|
||||
});
|
||||
|
||||
it("zeigt die abgelehnte Zeile des Notenimports und meldet keinen Erfolg", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENIMPORT", true);
|
||||
|
||||
const student = ctx.students[4];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, { note: ctx.noten.negativ, freigegeben: false, erstantritt: false });
|
||||
page.visitAndWaitForTable(ctx);
|
||||
seedPruefung(ctx, student, { note: ctx.noten.negativ, datum: antrittDate(ctx, 1), type: "Termin2" });
|
||||
|
||||
page.submitLvNotenImport([[student.uid, ctx.notenScale[0]]]);
|
||||
|
||||
page.expectRowRejected("@importLvNoten", student.uid);
|
||||
page.expectNoSuccess();
|
||||
page.expectLvNote(student.uid, bezeichnung(ctx.noten.negativ));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
# Local workstation: SSH tunnel to the database and configuration profiles via SFTP.
|
||||
# Copy to tests/cypress/local/.env. The suite itself never reads this file.
|
||||
|
||||
# ---- SSH tunnel (withTunnel.js) --------------------- ---------------------------------------------
|
||||
# pg_hba.conf only allows the application server. NOTEN_DB_HOST in suites/.env remains the ACTUAL database.
|
||||
# - The SSH host is usually NOT the web hostname
|
||||
# - A key outside the OpenSSH default names is never automatically tried; specify it here,
|
||||
# or leave NOTEN_SSH_KEY empty for the SSH agent
|
||||
NOTEN_SSH_HOST=
|
||||
NOTEN_SSH_PORT=22
|
||||
NOTEN_SSH_USER=
|
||||
NOTEN_SSH_KEY=
|
||||
NOTEN_SSH_PASSPHRASE=
|
||||
|
||||
# ---- Configuration profiles (notenProfiles.js) ------------------------------------------------ ---
|
||||
# Absolute path of the instance on its host.
|
||||
NOTEN_REMOTE_ROOT=
|
||||
# Only if the instance is on a different host than the tunnel. With NOTEN_PROFILE_SSH_HOST, every
|
||||
# SSH value in the profiles comes from NOTEN_PROFILE_SSH_*; an empty key uses the SSH agent.
|
||||
NOTEN_PROFILE_SSH_HOST=
|
||||
NOTEN_PROFILE_SSH_USER=
|
||||
NOTEN_PROFILE_SSH_KEY=
|
||||
|
||||
# Only for an instance with a self-signed certificate: otherwise, the Node side will reject it.
|
||||
# NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
@@ -0,0 +1,80 @@
|
||||
const js = require("@eslint/js");
|
||||
const globals = require("globals");
|
||||
const pluginCypress = require("eslint-plugin-cypress");
|
||||
const prettier = require("eslint-config-prettier");
|
||||
|
||||
/**
|
||||
* Lint for the Cypress suite only. The rest of the repo is older than this setup.
|
||||
*
|
||||
* The no-restricted-syntax rules check two conventions from tests/cypress/suites/readme_noten.txt.
|
||||
* Paths start at the repo root: `npm run lint` in local/ changes to it first.
|
||||
*/
|
||||
module.exports = [
|
||||
{ ignores: ["tests/cypress/e2e/fhcomplete/**", "tests/cypress/e2e/*.cy.js"] },
|
||||
|
||||
// --- Node side: tasks, tools, profiles, local tools, configuration (CommonJS) ---
|
||||
{
|
||||
files: ["tests/cypress/{tasks,tools,profiles,suites,local}/**/*.js", "cypress.config.js"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2023,
|
||||
sourceType: "commonjs",
|
||||
globals: { ...globals.node },
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
"no-unused-vars": ["warn", { argsIgnorePattern: "^_", caughtErrors: "none" }],
|
||||
},
|
||||
},
|
||||
|
||||
// --- browser side: specs and support (ES modules in the Cypress runner) ---
|
||||
{
|
||||
files: ["tests/cypress/e2e/**/*.js", "tests/cypress/support/**/*.js"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2023,
|
||||
sourceType: "module",
|
||||
globals: { ...globals.browser, ...globals.mocha, cy: "readonly", Cypress: "readonly", expect: "readonly" },
|
||||
},
|
||||
plugins: { cypress: pluginCypress },
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
...pluginCypress.configs.recommended.rules,
|
||||
"no-unused-vars": ["warn", { argsIgnorePattern: "^_", caughtErrors: "none" }],
|
||||
|
||||
// warn, not error: four .clear().type() calls in the page object work, and three of them
|
||||
// run only in the Punkte profile, so a change there cannot be tested here
|
||||
"cypress/unsafe-to-chain-command": "warn",
|
||||
},
|
||||
},
|
||||
|
||||
// --- the conventions of the suite ---
|
||||
{
|
||||
files: ["tests/cypress/e2e/specs/**/*.cy.js"],
|
||||
rules: {
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
// skipIf and the require* helpers are the only way to skip
|
||||
selector: "CallExpression[callee.property.name='skip'][callee.object.type='ThisExpression']",
|
||||
message:
|
||||
"Kein this.skip() im Spec. Nimm skipIf(this, condition, reason) oder einen require*-Helfer aus support/helpers/notenConfig.js.",
|
||||
},
|
||||
{
|
||||
// a stub of the Noten API removes exactly the server rules that the suite checks
|
||||
selector:
|
||||
"CallExpression[callee.object.name='cy'][callee.property.name='intercept'] > ObjectExpression:has(Property[key.name='body'])",
|
||||
message:
|
||||
"Kein Stub der Noten-API. cy.intercept nur zum Aliasen und Warten, siehe tests/cypress/suites/readme_noten.txt.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// notenConfig.js has the only this.skip() call
|
||||
{
|
||||
files: ["tests/cypress/support/helpers/notenConfig.js"],
|
||||
rules: { "no-restricted-syntax": "off" },
|
||||
},
|
||||
|
||||
// must be last: turns off all style rules that Prettier sets
|
||||
prettier,
|
||||
];
|
||||
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Switches a c3p0 instance to a profile of the noten suite, over SFTP.
|
||||
*
|
||||
* node tests/cypress/local/notenProfiles.js status Profiles and the active profile
|
||||
* node tests/cypress/local/notenProfiles.js apply <profile> switch, for example before `cypress open`
|
||||
* node tests/cypress/local/notenProfiles.js restore restore the originals
|
||||
* node tests/cypress/local/notenProfiles.js run [api|ui|all] [a,b] one run per profile, followed by the report
|
||||
*
|
||||
* The profiles are located in tests/cypress/profiles/noten.js. The `run` command opens the SSH tunnel itself.
|
||||
*/
|
||||
|
||||
const path = require("path");
|
||||
|
||||
["local", "suites", "."].forEach((dir) => require("dotenv").config({ path: path.join(__dirname, "..", dir, ".env") }));
|
||||
|
||||
const profileSwitch = require("./profileSwitch");
|
||||
const { openDbTunnel } = require("./withTunnel");
|
||||
const { profiles } = require("../profiles/noten");
|
||||
|
||||
const SCOPES = {
|
||||
api: { spec: "tests/cypress/e2e/specs/{unit,api}/**/*.cy.js" },
|
||||
// Electron crashes on the Benotungstool page
|
||||
ui: { spec: "tests/cypress/e2e/specs/ui/**/*.cy.js", browser: "chrome" },
|
||||
all: { spec: "tests/cypress/e2e/specs/**/*.cy.js", browser: "chrome" },
|
||||
};
|
||||
|
||||
const NOT_RUN = ["pending", "skipped"];
|
||||
|
||||
// api/noten/freigabe.cy.js and ui/noten/freigabe.cy.js share a file name, so the key keeps the folder
|
||||
const specName = (run) => path.relative("tests/cypress/e2e/specs", run.spec.relative).split(path.sep).join("/");
|
||||
|
||||
const testTitle = (run, test) => `${specName(run)} › ${test.title.join(" › ")}`;
|
||||
|
||||
const column = (value, width) => String(value).padStart(width);
|
||||
|
||||
/** The numbers and the red tests of each profile, and the tests that run in no profile. */
|
||||
const report = (names, results) => {
|
||||
const tests = new Map();
|
||||
const completed = [];
|
||||
let failed = false;
|
||||
|
||||
console.log(`\n${"=".repeat(78)}\n${"Profil".padEnd(20)} Tests grün rot pending\n${"-".repeat(78)}`);
|
||||
|
||||
names.forEach((name) => {
|
||||
const result = results[name];
|
||||
if (!result || !Array.isArray(result.runs)) {
|
||||
failed = true;
|
||||
console.log(`${name.padEnd(20)} Lauf gescheitert: ${result ? result.message : "kein Ergebnis"}`);
|
||||
return;
|
||||
}
|
||||
|
||||
completed.push(name);
|
||||
const counts = { passed: 0, failed: 0, pending: 0, skipped: 0 };
|
||||
result.runs.forEach((run) =>
|
||||
run.tests.forEach((test) => {
|
||||
counts[test.state] = (counts[test.state] || 0) + 1;
|
||||
const key = testTitle(run, test);
|
||||
if (!tests.has(key)) tests.set(key, {});
|
||||
tests.get(key)[name] = test;
|
||||
}),
|
||||
);
|
||||
|
||||
if (counts.failed) failed = true;
|
||||
const total = counts.passed + counts.failed + counts.pending + counts.skipped;
|
||||
console.log(
|
||||
`${name.padEnd(20)} ${column(total, 5)} ${column(counts.passed, 6)} ${column(counts.failed, 6)} ${column(counts.pending + counts.skipped, 7)}`,
|
||||
);
|
||||
});
|
||||
|
||||
completed.forEach((name) => {
|
||||
const failures = [...tests.entries()].filter(
|
||||
([, byProfile]) => byProfile[name] && byProfile[name].state === "failed",
|
||||
);
|
||||
if (!failures.length) return;
|
||||
|
||||
console.log(`\nRot in "${name}":`);
|
||||
failures.forEach(([key, byProfile]) =>
|
||||
console.log(` - ${key}\n ${String(byProfile[name].displayError || "").split("\n")[0]}`),
|
||||
);
|
||||
});
|
||||
|
||||
const neverRun = [...tests.keys()].filter((key) =>
|
||||
completed.every((name) => !tests.get(key)[name] || NOT_RUN.includes(tests.get(key)[name].state)),
|
||||
);
|
||||
console.log(`\nIn keinem Profil gelaufen (${neverRun.length}):`);
|
||||
neverRun.forEach((key) => console.log(` - ${key}`));
|
||||
console.log("=".repeat(78));
|
||||
|
||||
return failed ? 1 : 0;
|
||||
};
|
||||
|
||||
const runProfiles = async (scope = "api", selection = null) => {
|
||||
const options = SCOPES[scope];
|
||||
if (!options) {
|
||||
console.error(`Unbekannter Umfang "${scope}". Erlaubt: ${Object.keys(SCOPES).join(", ")}`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
const names = selection
|
||||
? selection
|
||||
.split(",")
|
||||
.map((n) => n.trim())
|
||||
.filter(Boolean)
|
||||
: Object.keys(profiles);
|
||||
names.forEach((name) => profileSwitch.profileData(name));
|
||||
|
||||
const cypress = require("cypress");
|
||||
const results = {};
|
||||
const closeTunnel = await openDbTunnel();
|
||||
|
||||
try {
|
||||
for (const name of names) {
|
||||
console.log(`\n### Profil "${name}": ${profiles[name].description}\n`);
|
||||
try {
|
||||
await profileSwitch.apply(name);
|
||||
results[name] = await cypress.run(options);
|
||||
} catch (error) {
|
||||
// a failed profile switch ends only this run; the report names the reason
|
||||
results[name] = { message: error.message };
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await profileSwitch.restore();
|
||||
await closeTunnel();
|
||||
}
|
||||
|
||||
return report(names, results);
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
const [command, first, second] = process.argv.slice(2);
|
||||
|
||||
if (command === "run") return runProfiles(first, second);
|
||||
|
||||
if (command === "apply" && first) {
|
||||
await profileSwitch.apply(first);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (command === "restore") {
|
||||
if (!(await profileSwitch.restore())) console.log("Kein Profil war aktiv.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (command === "status") {
|
||||
const state = await profileSwitch.status();
|
||||
console.log(state && state.profile ? `\nAktiv auf ${state.root}: "${state.profile}"` : "\nKein Profil aktiv.");
|
||||
console.log("\nProfile:");
|
||||
Object.entries(profiles).forEach(([name, p]) => console.log(` ${name.padEnd(18)} ${p.description}`));
|
||||
console.log("");
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.error(
|
||||
"\n node tests/cypress/local/notenProfiles.js status | apply <profile> | restore | run [api|ui|all] [profile,profile]\n",
|
||||
);
|
||||
return 2;
|
||||
};
|
||||
|
||||
main()
|
||||
.then((code) => process.exit(code))
|
||||
.catch((error) => {
|
||||
console.error(`\n${error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
Generated
+1138
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"private": true,
|
||||
"description": "Eigener Arbeitsplatz: SSH-Tunnel, Profile, Lint und Format.",
|
||||
"scripts": {
|
||||
"lint": "cd ../../.. && eslint -c tests/cypress/local/eslint.config.js tests/cypress cypress.config.js",
|
||||
"format": "cd ../../.. && prettier --config tests/cypress/local/package.json --write \"tests/cypress/**/*.js\" \"!tests/cypress/e2e/fhcomplete/**\" \"!tests/cypress/e2e/*.cy.js\""
|
||||
},
|
||||
"prettier": {
|
||||
"useTabs": true,
|
||||
"tabWidth": 4,
|
||||
"printWidth": 120,
|
||||
"endOfLine": "auto"
|
||||
},
|
||||
"dependencies": {
|
||||
"ssh2": "^1.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"eslint": "^10.10.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-cypress": "^7.0.2",
|
||||
"globals": "^17.12.0",
|
||||
"prettier": "^3.9.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* Switches the test instance to a configuration profile of the noten suite.
|
||||
*
|
||||
* A profile changes application/config/noten.php and the define() flags in config/global.config.inc.php.
|
||||
* The originals go to the home directory of the SSH user, outside the web root, because
|
||||
* global.config.inc.php contains credentials.
|
||||
*
|
||||
* state.json keeps the MD5 hash of each written file. If a file has a different hash, someone deployed
|
||||
* it again. Then that file is the new original, and the old backup is void.
|
||||
*/
|
||||
|
||||
const crypto = require("crypto");
|
||||
const path = require("path");
|
||||
const { resolveAuth } = require("./sshTunnel");
|
||||
const { base, profiles } = require("../profiles/noten");
|
||||
|
||||
// The instance can run on a different host than the database tunnel. If NOTEN_PROFILE_SSH_HOST is set,
|
||||
// all SSH values come from NOTEN_PROFILE_SSH_*; without a key, the SSH agent is used.
|
||||
const envValue = (key) => {
|
||||
const ownHost = key.startsWith("SSH_") && Boolean(process.env.NOTEN_PROFILE_SSH_HOST);
|
||||
return process.env[`NOTEN_${ownHost ? "PROFILE_" : ""}${key}`];
|
||||
};
|
||||
|
||||
const FILES = {
|
||||
config: "application/config/noten.php",
|
||||
flags: "config/global.config.inc.php",
|
||||
};
|
||||
|
||||
// relative to the home directory; that's where every SFTP session starts
|
||||
const BACKUP_DIR = ".cypress-noten-profile";
|
||||
const STATE_FILE = `${BACKUP_DIR}/state.json`;
|
||||
|
||||
const NAME_PATTERN = /^[a-z0-9-]+$/;
|
||||
const KEY_PATTERN = /^[A-Z0-9_]+$/;
|
||||
|
||||
const md5 = (content) => crypto.createHash("md5").update(content).digest("hex");
|
||||
|
||||
const sftpCall = (fn) => new Promise((resolve, reject) => fn((err, value) => (err ? reject(err) : resolve(value))));
|
||||
|
||||
const sshConfigured = () => Boolean(envValue("SSH_HOST") && envValue("SSH_USER"));
|
||||
|
||||
// --- SFTP ---
|
||||
|
||||
const connect = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
const { Client } = require("ssh2");
|
||||
|
||||
let auth;
|
||||
try {
|
||||
auth = resolveAuth({
|
||||
keyPath: envValue("SSH_KEY"),
|
||||
passphrase: envValue("SSH_PASSPHRASE"),
|
||||
agent: envValue("SSH_AGENT"),
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new Client();
|
||||
client
|
||||
.on("ready", () => client.sftp((err, sftp) => (err ? reject(err) : resolve({ client, sftp }))))
|
||||
.on("error", (err) =>
|
||||
reject(new Error(`SSH zu ${envValue("SSH_USER")}@${envValue("SSH_HOST")} gescheitert: ${err.message}`)),
|
||||
)
|
||||
.connect({
|
||||
host: envValue("SSH_HOST"),
|
||||
port: Number(envValue("SSH_PORT") || 22),
|
||||
username: envValue("SSH_USER"),
|
||||
privateKey: auth.privateKey,
|
||||
passphrase: auth.passphrase,
|
||||
agent: auth.agent,
|
||||
readyTimeout: 15000,
|
||||
});
|
||||
});
|
||||
|
||||
const withSftp = async (fn) => {
|
||||
const { client, sftp } = await connect();
|
||||
try {
|
||||
return await fn(sftp);
|
||||
} finally {
|
||||
client.end();
|
||||
}
|
||||
};
|
||||
|
||||
const exists = (sftp, file) => new Promise((resolve) => sftp.stat(file, (err) => resolve(!err)));
|
||||
const read = (sftp, file) => sftpCall((cb) => sftp.readFile(file, cb));
|
||||
const writeRaw = (sftp, file, content, mode) => sftpCall((cb) => sftp.writeFile(file, content, { mode }, cb));
|
||||
|
||||
/** Writes to a temporary file first, so no request reads a half-written configuration. */
|
||||
const write = async (sftp, file, content) => {
|
||||
// .php: a web server runs the file instead of sending its credentials
|
||||
const temp = `${file}.cypress-temp.php`;
|
||||
await writeRaw(sftp, temp, content, 0o644);
|
||||
await sftpCall((cb) => sftp.ext_openssh_rename(temp, file, cb));
|
||||
};
|
||||
|
||||
const backupOf = (remotePath) => `${BACKUP_DIR}/${path.posix.basename(remotePath)}`;
|
||||
|
||||
const readState = async (sftp) =>
|
||||
(await exists(sftp, STATE_FILE)) ? JSON.parse((await read(sftp, STATE_FILE)).toString("utf8")) : null;
|
||||
|
||||
const writeState = (sftp, state) => writeRaw(sftp, STATE_FILE, JSON.stringify(state, null, 2), 0o600);
|
||||
|
||||
// --- Changing the files ---
|
||||
|
||||
const phpString = (text) => `'${String(text).replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
||||
|
||||
const phpValue = (value) => {
|
||||
if (value === null) return "null";
|
||||
if (typeof value === "boolean" || typeof value === "number") return String(value);
|
||||
if (typeof value === "string") return phpString(value);
|
||||
throw new Error(`Ein define()-Schalter nimmt nur einen einfachen Wert, nicht ${JSON.stringify(value)}.`);
|
||||
};
|
||||
|
||||
// ASCII only: this block does not change the file's encoding
|
||||
const asciiJson = (value) =>
|
||||
JSON.stringify(value).replace(/[-]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
||||
|
||||
/** Appends the profile keys to noten.php. The later assignment wins. */
|
||||
const withConfig = (original, name, config) => {
|
||||
const text = original.toString("latin1");
|
||||
const block =
|
||||
`\n// cypress-profil "${name}": nur auf der Testinstanz, die Suite stellt das Original wieder her\n` +
|
||||
`$config = array_replace($config, json_decode(${phpString(asciiJson(config))}, true));\n`;
|
||||
const end = text.search(/\?>\s*$/);
|
||||
|
||||
return Buffer.from(end === -1 ? text + block : text.slice(0, end) + block + text.slice(end), "latin1");
|
||||
};
|
||||
|
||||
/** Replaces the `define()` line of each flag. A second `define()` would print a notice on the page. */
|
||||
const withFlags = (original, flags) => {
|
||||
let text = original.toString("latin1");
|
||||
|
||||
Object.entries(flags).forEach(([flag, value]) => {
|
||||
const pattern = new RegExp(`^([ \\t]*)define\\(\\s*['"]${flag}['"]\\s*,[^;]*;`, "m");
|
||||
if (!pattern.test(text)) {
|
||||
throw new Error(
|
||||
`${FILES.flags} definiert ${flag} nicht. Ein Profil ändert nur einen bestehenden Schalter.`,
|
||||
);
|
||||
}
|
||||
text = text.replace(pattern, (match, indent) => `${indent}define('${flag}', ${phpValue(value)});`);
|
||||
});
|
||||
|
||||
return Buffer.from(text, "latin1");
|
||||
};
|
||||
|
||||
// --- Profile ---
|
||||
|
||||
/** The profile plus the base. `expect` lists getCisConfig values that a flag changes only indirectly. */
|
||||
const profileData = (name) => {
|
||||
if (!NAME_PATTERN.test(String(name)) || !Object.prototype.hasOwnProperty.call(profiles, name)) {
|
||||
throw new Error(`Unbekanntes Profil "${name}". Vorhanden: ${Object.keys(profiles).join(", ")}`);
|
||||
}
|
||||
|
||||
const data = {
|
||||
config: { ...base.config, ...profiles[name].config },
|
||||
flags: { ...base.flags, ...profiles[name].flags },
|
||||
expect: { ...profiles[name].expect },
|
||||
};
|
||||
|
||||
[...Object.keys(data.config), ...Object.keys(data.flags)].forEach((key) => {
|
||||
if (!KEY_PATTERN.test(key)) throw new Error(`Ungültiger Schlüssel "${key}" in Profil "${name}".`);
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
/** Restores a file that a profile wrote. A file deployed again since then stays. */
|
||||
const restoreFile = async (sftp, state, fileKey) => {
|
||||
const entry = state.files[fileKey];
|
||||
if (!entry) return;
|
||||
|
||||
const remotePath = `${state.root}/${FILES[fileKey]}`;
|
||||
const backup = backupOf(remotePath);
|
||||
|
||||
if (await exists(sftp, backup)) {
|
||||
if (md5(await read(sftp, remotePath)) === entry.md5) {
|
||||
await write(sftp, remotePath, await read(sftp, backup));
|
||||
} else {
|
||||
console.warn(`[profile] ${remotePath} ist seit dem Profil neu ausgerollt und bleibt stehen.`);
|
||||
}
|
||||
await sftpCall((cb) => sftp.unlink(backup, cb));
|
||||
}
|
||||
|
||||
delete state.files[fileKey];
|
||||
await writeState(sftp, state);
|
||||
};
|
||||
|
||||
/** Writes the profile version of a file and keeps the original as a backup. */
|
||||
const applyFile = async (sftp, state, fileKey, transform) => {
|
||||
const remotePath = `${state.root}/${FILES[fileKey]}`;
|
||||
const backup = backupOf(remotePath);
|
||||
const current = await read(sftp, remotePath);
|
||||
const entry = state.files[fileKey];
|
||||
const fromProfile = Boolean(entry) && md5(current) === entry.md5;
|
||||
|
||||
let original = current;
|
||||
if (fromProfile) {
|
||||
if (!(await exists(sftp, backup))) {
|
||||
throw new Error(`Die Sicherung ${backup} fehlt. Das Original von ${remotePath} ist unbekannt.`);
|
||||
}
|
||||
original = await read(sftp, backup);
|
||||
} else {
|
||||
if (entry)
|
||||
console.warn(`[profile] ${remotePath} ist seit dem letzten Profil neu ausgerollt und gilt als Original.`);
|
||||
await writeRaw(sftp, backup, current, 0o600);
|
||||
}
|
||||
|
||||
const updated = transform(original);
|
||||
|
||||
// the state first: if the write fails, the next run takes the file as the original
|
||||
state.files[fileKey] = { md5: md5(updated) };
|
||||
await writeState(sftp, state);
|
||||
await write(sftp, remotePath, updated);
|
||||
};
|
||||
|
||||
const matches = (actual, expected) =>
|
||||
typeof expected === "boolean" ? Boolean(actual) === expected : JSON.stringify(actual) === JSON.stringify(expected);
|
||||
|
||||
/** The getCisConfig data, or null with a reason for a temporary error (network, HTTP 5xx). */
|
||||
const readCisConfig = async (url, auth) => {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, { headers: { Authorization: auth } });
|
||||
} catch (error) {
|
||||
return { actual: null, reason: error.message };
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
try {
|
||||
const actual = JSON.parse(text).data;
|
||||
if (actual) return { actual };
|
||||
} catch (e) {
|
||||
// no JSON response
|
||||
}
|
||||
|
||||
// below 500 a PHP message comes before the JSON: the file is broken, and waiting does not help
|
||||
if (response.status < 500) {
|
||||
throw new Error(
|
||||
`getCisConfig antwortet nach dem Profilwechsel mit HTTP ${response.status}: ${text.slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
return { actual: null, reason: `getCisConfig antwortet mit HTTP ${response.status}` };
|
||||
};
|
||||
|
||||
/** Waits until getCisConfig returns the profile. PHP-FPM reads a changed file only after opcache.revalidate_freq. */
|
||||
const verifyInstance = async (data) => {
|
||||
const expected = { ...data.config, ...data.flags, ...data.expect };
|
||||
const baseUrl = String(process.env.BASE_URL).replace(/\/+$/, "");
|
||||
const url = `${baseUrl}/index.ci.php/api/frontend/v1/Noten/getCisConfig`;
|
||||
const auth = `Basic ${Buffer.from(`${process.env.NOTEN_LEKTOR_USER}:${process.env.NOTEN_LEKTOR_PASSWORD}`).toString("base64")}`;
|
||||
const deadline = Date.now() + 60000;
|
||||
|
||||
for (;;) {
|
||||
const { actual, reason } = await readCisConfig(url, auth);
|
||||
let pending = reason;
|
||||
|
||||
if (actual) {
|
||||
const checkable = Object.keys(expected).filter((key) => key in actual);
|
||||
if (!checkable.length) {
|
||||
console.warn("[profile] getCisConfig liefert keinen Schlüssel des Profils. Der Wechsel ist ungeprüft.");
|
||||
return;
|
||||
}
|
||||
|
||||
const deviating = checkable.filter((key) => !matches(actual[key], expected[key]));
|
||||
if (!deviating.length) return;
|
||||
|
||||
const lines = deviating.map(
|
||||
(k) => ` ${k}: ${JSON.stringify(actual[k])} statt ${JSON.stringify(expected[k])}`,
|
||||
);
|
||||
pending = `die Instanz zeigt das Profil nicht:\n${lines.join("\n")}`;
|
||||
}
|
||||
|
||||
if (Date.now() > deadline) throw new Error(`Profilwechsel nach 60 Sekunden ungeprüft, ${pending}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
};
|
||||
|
||||
// --- public ---
|
||||
|
||||
/** Puts the original files back. Returns the name of the profile that was active, or null. */
|
||||
const restore = async () => {
|
||||
if (!sshConfigured()) return null;
|
||||
|
||||
return withSftp(async (sftp) => {
|
||||
const state = await readState(sftp);
|
||||
if (!state || !Object.keys(state.files).length) return null;
|
||||
|
||||
const previous = state.profile;
|
||||
for (const fileKey of Object.keys(FILES)) await restoreFile(sftp, state, fileKey);
|
||||
|
||||
state.profile = null;
|
||||
await writeState(sftp, state);
|
||||
console.log(`[profile] "${previous}" ist zurückgenommen.`);
|
||||
return previous;
|
||||
});
|
||||
};
|
||||
|
||||
/** Switches the instance to a profile. Without a name it restores the originals. */
|
||||
const apply = async (name) => {
|
||||
if (!name) return restore();
|
||||
|
||||
const data = profileData(name);
|
||||
const root = envValue("REMOTE_ROOT");
|
||||
if (!sshConfigured() || !root) {
|
||||
throw new Error(
|
||||
`Profil "${name}" braucht NOTEN_SSH_HOST, NOTEN_SSH_USER und NOTEN_REMOTE_ROOT in tests/cypress/local/.env.`,
|
||||
);
|
||||
}
|
||||
|
||||
await withSftp(async (sftp) => {
|
||||
if (!(await exists(sftp, BACKUP_DIR))) await sftpCall((cb) => sftp.mkdir(BACKUP_DIR, { mode: 0o700 }, cb));
|
||||
|
||||
const state = (await readState(sftp)) || { files: {} };
|
||||
if (state.root && state.root !== root && Object.keys(state.files).length) {
|
||||
throw new Error(
|
||||
`Auf ${state.root} ist noch "${state.profile}" aktiv. Zuerst: node tests/cypress/local/notenProfiles.js restore`,
|
||||
);
|
||||
}
|
||||
state.root = root;
|
||||
state.profile = name;
|
||||
|
||||
if (Object.keys(data.config).length)
|
||||
await applyFile(sftp, state, "config", (o) => withConfig(o, name, data.config));
|
||||
else await restoreFile(sftp, state, "config");
|
||||
|
||||
if (Object.keys(data.flags).length) await applyFile(sftp, state, "flags", (o) => withFlags(o, data.flags));
|
||||
else await restoreFile(sftp, state, "flags");
|
||||
|
||||
await writeState(sftp, state);
|
||||
});
|
||||
|
||||
await verifyInstance(data);
|
||||
console.log(`[profile] "${name}" ist aktiv.`);
|
||||
return name;
|
||||
};
|
||||
|
||||
const status = () => (sshConfigured() ? withSftp(readState) : Promise.resolve(null));
|
||||
|
||||
module.exports = { apply, restore, status, profileData };
|
||||
@@ -0,0 +1,137 @@
|
||||
LOCAL WORKSTATION - SSH TUNNEL AND CONFIGURATION PROFILES
|
||||
=========================================================
|
||||
These tools are for local workstations only. No file of the suite refers to this folder. The team does
|
||||
not need it to run the tests.
|
||||
|
||||
The tools do two things:
|
||||
|
||||
- They open an SSH tunnel to the test database. pg_hba.conf accepts only the application server, so
|
||||
a workstation cannot connect directly.
|
||||
- They switch the test instance to a configuration profile, run the suite, and put the original
|
||||
configuration back.
|
||||
|
||||
1. Files
|
||||
-----------------
|
||||
|
||||
withTunnel.js Opens the tunnel, starts a command, and closes the tunnel when the command ends.
|
||||
sshTunnel.js The tunnel itself (package ssh2). withTunnel.js and profileSwitch.js use it.
|
||||
notenProfiles.js Command line for the profiles: status, apply, restore, run.
|
||||
profileSwitch.js Writes the profile over SFTP, keeps the backups, checks the instance.
|
||||
eslint.config.js Lint rules of the suite. Two rules check conventions from suites/readme_noten.txt.
|
||||
package.json ssh2, ESLint and Prettier. Cypress, dotenv and pg come from the repository root.
|
||||
.env SSH access and the path of the instance (gitignored).
|
||||
|
||||
The profiles are in tests/cypress/profiles/noten.js. That file is part of the suite.
|
||||
|
||||
|
||||
2. Preparation
|
||||
---------------
|
||||
|
||||
Install the packages in this folder:
|
||||
|
||||
cd tests/cypress/local
|
||||
npm install
|
||||
|
||||
Create the credentials file:
|
||||
|
||||
tests/cypress/local/.env.example -> tests/cypress/local/.env
|
||||
|
||||
The tools also read tests/cypress/.env (BASE_URL) and tests/cypress/suites/.env (NOTEN_LEKTOR_USER,
|
||||
NOTEN_DB_*). Fill out these two files first. suites/readme_noten.txt explains them.
|
||||
|
||||
- NOTEN_DB_HOST in suites/.env stays the real database host, as the SSH host sees it. Do not write
|
||||
127.0.0.1. The tunnel changes the value only for the command that it starts.
|
||||
- NOTEN_SSH_HOST is the SSH host. It is usually not the web hostname.
|
||||
- Without NOTEN_SSH_KEY, the tools use only the SSH agent. They do not read ~/.ssh themselves.
|
||||
- Use the agent if possible: run ssh-add and keep NOTEN_SSH_KEY empty. On Windows, the service
|
||||
"OpenSSH Authentication Agent" must run.
|
||||
- NOTEN_SSH_PASSPHRASE keeps the passphrase in plain text. Use it only if the agent is not possible.
|
||||
- NOTEN_REMOTE_ROOT is the absolute path of the instance on its host. Only the profiles need it.
|
||||
- Set NOTEN_PROFILE_SSH_* only when the instance runs on a different host than the database tunnel.
|
||||
- The tools need Node 18 or later.
|
||||
|
||||
|
||||
3. SSH tunnel
|
||||
--------------
|
||||
|
||||
Run the commands from the repository root. Put withTunnel.js in front of each command that needs
|
||||
the database:
|
||||
|
||||
node tests/cypress/local/withTunnel.js npm run noten:check
|
||||
node tests/cypress/local/withTunnel.js npm run noten:api
|
||||
node tests/cypress/local/withTunnel.js npm run noten:ui
|
||||
node tests/cypress/local/withTunnel.js npx cypress open
|
||||
|
||||
withTunnel.js opens the tunnel on a free local port. Then it sets NOTEN_DB_HOST=127.0.0.1 and
|
||||
NOTEN_DB_PORT=<port> for the command. The .env files do not change. When the command ends, the tunnel
|
||||
closes. Ctrl+C stops the command first and then the tunnel.
|
||||
|
||||
The same result without this code: open the tunnel in a second terminal, and write fixed values in
|
||||
suites/.env.
|
||||
|
||||
ssh -N -L 15432:<database host>:5432 <user>@<ssh host>
|
||||
|
||||
NOTEN_DB_HOST=127.0.0.1
|
||||
NOTEN_DB_PORT=15432
|
||||
|
||||
|
||||
4. Configuration profiles
|
||||
--------------------------
|
||||
|
||||
The suite tests the configuration it finds. Some rules have a second branch, for example two
|
||||
Antritte instead of three. A profile switches the instance to such a branch.
|
||||
|
||||
node tests/cypress/local/notenProfiles.js status Active profile and all profiles.
|
||||
node tests/cypress/local/notenProfiles.js apply <profile> Switch the instance to a profile.
|
||||
node tests/cypress/local/notenProfiles.js restore Put the original files back.
|
||||
node tests/cypress/local/notenProfiles.js run [api|ui|all] [a,b] One run for each profile.
|
||||
|
||||
|
||||
5. Lint and format
|
||||
-------------------
|
||||
|
||||
Run the commands in this folder. The paths in eslint.config.js start at the repository root, so
|
||||
both scripts change to the root first.
|
||||
|
||||
npm run lint ESLint for tests/cypress and cypress.config.js. Add "-- --fix" to fix.
|
||||
npm run format Prettier for tests/cypress: tabs, 120 columns. It writes the files.
|
||||
|
||||
|
||||
6. Debugging
|
||||
------------------------
|
||||
|
||||
"SSH to <user>@<host> ... failed" or "SSH zu <user>@<host> gescheitert"
|
||||
The SSH host is often not the web hostname. Without NOTEN_SSH_KEY.
|
||||
Run ssh-add, or write the key path in NOTEN_SSH_KEY.
|
||||
|
||||
"SSH key does not exist"
|
||||
The path in NOTEN_SSH_KEY is wrong. A path can start with ~.
|
||||
|
||||
The tunnel is up, but the database connection closes immediately
|
||||
The SSH host cannot reach NOTEN_DB_HOST. If Postgres runs on the SSH host itself, write localhost.
|
||||
|
||||
"Profilwechsel nach 60 Sekunden ungeprüft"
|
||||
The instance at BASE_URL does not show the profile. The message lists each key with the actual and
|
||||
the expected value. Make sure that NOTEN_REMOTE_ROOT is the path of the instance at BASE_URL.
|
||||
|
||||
"getCisConfig antwortet nach dem Profilwechsel mit HTTP ..."
|
||||
HTTP 401: NOTEN_LEKTOR_USER or NOTEN_LEKTOR_PASSWORD is wrong. Other codes: the changed file contains a PHP
|
||||
error. Run restore, then examine the values of the profile.
|
||||
|
||||
"Auf <path> ist noch "<profile>" aktiv"
|
||||
A profile is still active on a different NOTEN_REMOTE_ROOT. Run restore. It uses the path in
|
||||
state.json.
|
||||
|
||||
"Die Sicherung ... fehlt"
|
||||
The backup in ~/.cypress-noten-profile/ is missing, so the original file is unknown. Deploy the
|
||||
file again. The tool then takes it as the original.
|
||||
|
||||
"... definiert <FLAG> nicht"
|
||||
The profile sets a flag that config/global.config.inc.php does not define. Add the define() line
|
||||
on the instance, or remove the flag from the profile.
|
||||
|
||||
Ctrl+C during notenProfiles.js run
|
||||
The instance stays on the current profile. Run restore.
|
||||
|
||||
Self-signed certificate on the instance
|
||||
Set NODE_TLS_REJECT_UNAUTHORIZED=0 in local/.env.
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Local port forward over SSH, opened inside the node process.
|
||||
*
|
||||
* Knows nothing about the environment: the caller passes host, user and credential.
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const net = require("net");
|
||||
|
||||
let server = null;
|
||||
let sshClient = null;
|
||||
let localPort = null;
|
||||
|
||||
const resolveAgent = (agent) => {
|
||||
if (agent && agent !== "true") return agent;
|
||||
if (process.env.SSH_AUTH_SOCK) return process.env.SSH_AUTH_SOCK;
|
||||
if (process.platform === "win32") return "\\\\.\\pipe\\openssh-ssh-agent";
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Key file or agent, either is enough. A configured but missing key path is an error rather than a
|
||||
* silent fallback to the agent, since that is nearly always a typo.
|
||||
*/
|
||||
const resolveAuth = ({ keyPath, passphrase, agent }) => {
|
||||
if (keyPath) {
|
||||
const resolved = keyPath.startsWith("~")
|
||||
? keyPath.replace(/^~/, process.env.HOME || process.env.USERPROFILE || "")
|
||||
: keyPath;
|
||||
|
||||
if (!fs.existsSync(resolved)) {
|
||||
throw new Error(`SSH key does not exist: ${resolved}. Fix the path, or clear it to use an agent.`);
|
||||
}
|
||||
return {
|
||||
privateKey: fs.readFileSync(resolved),
|
||||
passphrase: passphrase || undefined,
|
||||
description: `key ${resolved}`,
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedAgent = resolveAgent(agent);
|
||||
if (resolvedAgent) return { agent: resolvedAgent, description: `agent ${resolvedAgent}` };
|
||||
|
||||
throw new Error("No SSH credential: set a key path, or ssh-add your key and leave it empty.");
|
||||
};
|
||||
|
||||
/** Idempotent - repeated calls reuse the existing tunnel. */
|
||||
const openTunnel = (cfg) =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (localPort) return resolve(localPort);
|
||||
if (!cfg.sshHost || !cfg.sshUser) return reject(new Error("Tunnel needs an SSH host and user."));
|
||||
|
||||
const { Client } = require("ssh2");
|
||||
|
||||
let auth;
|
||||
try {
|
||||
auth = resolveAuth(cfg);
|
||||
} catch (error) {
|
||||
return reject(error);
|
||||
}
|
||||
|
||||
sshClient = new Client();
|
||||
|
||||
sshClient
|
||||
.on("ready", () => {
|
||||
server = net.createServer((socket) => {
|
||||
// an error before the channel is up must not crash the process
|
||||
socket.on("error", () => socket.destroy());
|
||||
sshClient.forwardOut(
|
||||
socket.remoteAddress || "127.0.0.1",
|
||||
socket.remotePort || 0,
|
||||
cfg.dbHost,
|
||||
cfg.dbPort,
|
||||
(err, stream) => {
|
||||
if (err) return socket.destroy();
|
||||
if (socket.destroyed) return stream.destroy();
|
||||
// pipe forwards only a clean end; an aborted side must close the other, or the channel keeps its Postgres connection
|
||||
socket.on("close", () => stream.destroy());
|
||||
stream.on("error", () => socket.destroy()).on("close", () => socket.destroy());
|
||||
socket.pipe(stream).pipe(socket);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
server.on("error", reject);
|
||||
|
||||
// port 0: let the OS pick, so a leftover process cannot collide
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
localPort = server.address().port;
|
||||
console.log(
|
||||
`[db] SSH tunnel up: 127.0.0.1:${localPort} -> ${cfg.dbHost}:${cfg.dbPort} ` +
|
||||
`via ${cfg.sshUser}@${cfg.sshHost} (${auth.description})`,
|
||||
);
|
||||
resolve(localPort);
|
||||
});
|
||||
})
|
||||
.on("error", (err) =>
|
||||
reject(
|
||||
new Error(
|
||||
`SSH to ${cfg.sshUser}@${cfg.sshHost} using ${auth.description} failed: ${err.message}. ` +
|
||||
"The SSH host is often not the web hostname, and a non-default key filename is never " +
|
||||
"tried automatically.",
|
||||
),
|
||||
),
|
||||
)
|
||||
.connect({
|
||||
host: cfg.sshHost,
|
||||
port: cfg.sshPort || 22,
|
||||
username: cfg.sshUser,
|
||||
privateKey: auth.privateKey,
|
||||
passphrase: auth.passphrase,
|
||||
agent: auth.agent,
|
||||
readyTimeout: 15000,
|
||||
});
|
||||
});
|
||||
|
||||
const closeTunnel = async () => {
|
||||
// the SSH connection first: its closing channels close the local sockets, which server.close waits for
|
||||
if (sshClient) {
|
||||
sshClient.end();
|
||||
sshClient = null;
|
||||
}
|
||||
if (server) {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
server = null;
|
||||
}
|
||||
localPort = null;
|
||||
return null;
|
||||
};
|
||||
|
||||
const ensureTunnel = async (cfg) => {
|
||||
try {
|
||||
return { tunnelled: true, localPort: await openTunnel(cfg) };
|
||||
} catch (error) {
|
||||
return { tunnelled: false, reason: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { ensureTunnel, closeTunnel, resolveAuth };
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Runs a command using the database behind an SSH tunnel. For your own workstation only:
|
||||
* No part of the suite references this folder.
|
||||
*
|
||||
* node tests/cypress/local/withTunnel.js npm run noten:api
|
||||
* node tests/cypress/local/withTunnel.js npm run noten:check
|
||||
* node tests/cypress/local/withTunnel.js npx cypress open
|
||||
*
|
||||
* The tunnel sets NOTEN_DB_HOST and NOTEN_DB_PORT of the command to its local end.
|
||||
*/
|
||||
|
||||
const path = require("path");
|
||||
const { spawn } = require("child_process");
|
||||
const { ensureTunnel, closeTunnel } = require("./sshTunnel");
|
||||
|
||||
["local", "suites"].forEach((dir) => require("dotenv").config({ path: path.join(__dirname, "..", dir, ".env") }));
|
||||
|
||||
const env = (key) => process.env[`NOTEN_${key}`];
|
||||
|
||||
/** Opens the tunnel and redirects the DB values in process.env to it. Provides the function to close it. */
|
||||
const openDbTunnel = async () => {
|
||||
const tunnel = await ensureTunnel({
|
||||
sshHost: env("SSH_HOST"),
|
||||
sshPort: Number(env("SSH_PORT") || 22),
|
||||
sshUser: env("SSH_USER"),
|
||||
keyPath: env("SSH_KEY"),
|
||||
passphrase: env("SSH_PASSPHRASE"),
|
||||
agent: env("SSH_AGENT"),
|
||||
dbHost: env("DB_HOST"),
|
||||
dbPort: Number(env("DB_PORT") || 5432),
|
||||
});
|
||||
if (!tunnel.tunnelled) throw new Error(`SSH tunnel failed: ${tunnel.reason}`);
|
||||
|
||||
process.env.NOTEN_DB_HOST = "127.0.0.1";
|
||||
process.env.NOTEN_DB_PORT = String(tunnel.localPort);
|
||||
return closeTunnel;
|
||||
};
|
||||
|
||||
// cmd.exe runs npm and npx only as command-line tools
|
||||
const commandLine = (parts) => parts.map((p) => (/[\s,;=&|<>^]/.test(p) ? `"${p}"` : p)).join(" ");
|
||||
|
||||
const main = async () => {
|
||||
const parts = process.argv.slice(2);
|
||||
if (!parts.length) {
|
||||
console.error("\n node tests/cypress/local/withTunnel.js <command> [arguments]\n");
|
||||
return 2;
|
||||
}
|
||||
|
||||
const close = await openDbTunnel();
|
||||
|
||||
// Ctrl+C also reaches the command; the tunnel stays open until the command ends
|
||||
process.on("SIGINT", () => {});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(commandLine(parts), { stdio: "inherit", shell: true });
|
||||
const finish = async (code) => {
|
||||
await close();
|
||||
resolve(code);
|
||||
};
|
||||
child.on("error", (error) => {
|
||||
console.error(`\n${error.message}\n`);
|
||||
finish(1);
|
||||
});
|
||||
child.on("exit", (code) => finish(code === null ? 1 : code));
|
||||
});
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
main()
|
||||
.then((code) => process.exit(code))
|
||||
.catch((error) => {
|
||||
console.error(`\n${error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { openDbTunnel };
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* The configurations that the noten suite must pass.
|
||||
*
|
||||
* A profile lists only the differences to the instance. `config` changes application/config/noten.php,
|
||||
* `flags` changes the define() flags in config/global.config.inc.php. After a switch, each key that
|
||||
* getCisConfig returns under the same name must have the profile value. `expect` lists values that a
|
||||
* flag changes only indirectly, or that getCisConfig returns in a resolved form.
|
||||
*
|
||||
* A profile enables branches that no other profile checks. A run over all profiles lists, at the
|
||||
* end, the tests that do not run in any profile.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
// applies to every profile
|
||||
base: {},
|
||||
|
||||
profiles: {
|
||||
default: {
|
||||
description: "die Konfiguration der Instanz",
|
||||
},
|
||||
|
||||
shipped: {
|
||||
description: "die Schalter aus global.config-default.inc.php: TERMIN3 an",
|
||||
flags: { CIS_GESAMTNOTE_PRUEFUNG_TERMIN3: true },
|
||||
// 1 + TERMIN2 + TERMIN3 + KOMMPRUEF, while CIS_GESAMTNOTE_MAX_ANTRITTE is null
|
||||
expect: { CIS_GESAMTNOTE_MAX_ANTRITTE: 4 },
|
||||
},
|
||||
|
||||
"no-erstantritt": {
|
||||
description: "kein Pfad legt Antritt 1 an, die Freigabe verlangt kein Passwort",
|
||||
config: {
|
||||
CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME: false,
|
||||
CIS_GESAMTNOTE_FREIGABE_PASSWORT: false,
|
||||
},
|
||||
},
|
||||
|
||||
final: {
|
||||
description: "eine freigegebene Note ist endgültig",
|
||||
config: { CIS_GESAMTNOTE_FREIGABE_FINAL: true },
|
||||
},
|
||||
|
||||
"two-antritte": {
|
||||
description: "zwei Antritte, die kommissionelle Prüfung trägt die Studierendenverwaltung ein",
|
||||
config: {
|
||||
CIS_GESAMTNOTE_MAX_ANTRITTE: 2,
|
||||
CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF: false,
|
||||
},
|
||||
},
|
||||
|
||||
"kommpruef-stv": {
|
||||
description: "drei Antritte, die kommissionelle Prüfung trägt die Studierendenverwaltung ein",
|
||||
config: { CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF: false },
|
||||
},
|
||||
|
||||
"no-kommission": {
|
||||
description: "kein Antritt ist kommissionell",
|
||||
config: { CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT: 0 },
|
||||
// getCisConfig resolves 0 to null
|
||||
expect: { CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT: null },
|
||||
},
|
||||
|
||||
rollen: {
|
||||
description: "die Assistenz darf weder freigeben noch die kommissionelle Prüfung anlegen",
|
||||
config: {
|
||||
CIS_GESAMTNOTE_ROLLENMATRIX: {
|
||||
"lehre/benotungstool": ["lvnote", "pruefung", "kommpruef", "freigabe", "import"],
|
||||
"lehre/benotungstool_assistenz": ["lvnote", "pruefung", "import"],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// The specs put Antritte 30 days apart, or 60 days with an entschuldigt Pruefung in between.
|
||||
// Both limits allow these gaps.
|
||||
"antritt-gap": {
|
||||
description: "Wartefrist 14 Tage und Höchstfrist 400 Tage zwischen zwei Antritten",
|
||||
config: {
|
||||
CIS_GESAMTNOTE_ANTRITT_MIN_ABSTAND_TAGE: 14,
|
||||
CIS_GESAMTNOTE_ANTRITT_MAX_ABSTAND_TAGE: 400,
|
||||
},
|
||||
},
|
||||
|
||||
notenverbesserung: {
|
||||
description: "Antritt nach einer positiven Note, die bessere Note bleibt LV-Note",
|
||||
config: {
|
||||
CIS_GESAMTNOTE_NOTENVERBESSERUNG: true,
|
||||
CIS_GESAMTNOTE_VERBESSERUNG_BESSERE_GEWINNT: true,
|
||||
},
|
||||
},
|
||||
|
||||
// without CIS_GESAMTNOTE_ANTRITT_MIN_ABSTAND_TAGE: on the same day the gap is 0 days
|
||||
"same-day": {
|
||||
description: "ein neuer Termin darf am Tag eines bestehenden liegen",
|
||||
config: { CIS_GESAMTNOTE_PRUEFUNG_GLEICHER_TAG: true },
|
||||
},
|
||||
|
||||
"frist-exception": {
|
||||
description: "die Lehrenden dürfen nach der Eingabefrist eintragen",
|
||||
config: { CIS_GESAMTNOTE_FRIST_AUSNAHME: ["lehre/benotungstool"] },
|
||||
expect: { CIS_GESAMTNOTE_FRIST_AUSNAHME_GILT: true },
|
||||
},
|
||||
|
||||
"frist-date-only": {
|
||||
description: "nur das Prüfungsdatum ist an die Frist gebunden, die Eingabe nicht",
|
||||
config: { CIS_GESAMTNOTE_FRIST_EINGABE: false },
|
||||
},
|
||||
|
||||
open: {
|
||||
description:
|
||||
"frühere Noten änderbar, künftiges Benotungsdatum, keine Datumsfrist, ein Termin hebt die Freigabe nicht auf, LV-Note nach einer Wiederholung änderbar",
|
||||
config: {
|
||||
CIS_GESAMTNOTE_VORSCHLAG_NACH_WIEDERHOLUNG: true,
|
||||
CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETERER_PRUEFUNG: false,
|
||||
CIS_GESAMTNOTE_DATUM_ZUKUNFT: true,
|
||||
CIS_GESAMTNOTE_FRIST_PRUEFUNGSDATUM: false,
|
||||
CIS_GESAMTNOTE_PRUEFUNG_HEBT_FREIGABE_AUF: false,
|
||||
},
|
||||
},
|
||||
|
||||
notenimport: {
|
||||
description:
|
||||
"nur der Notenimport: ohne Datum, mit dem Kürzel aus tbl_note.anmerkung, der Prüfungsimport ist aus",
|
||||
config: {
|
||||
CIS_GESAMTNOTE_NOTENIMPORT: true,
|
||||
CIS_GESAMTNOTE_IMPORT_NOTENKUERZEL: true,
|
||||
CIS_GESAMTNOTE_PRUEFUNGSIMPORT: false,
|
||||
},
|
||||
},
|
||||
|
||||
punkte: {
|
||||
description: "Punktemodus",
|
||||
flags: { CIS_GESAMTNOTE_PUNKTE: true },
|
||||
},
|
||||
|
||||
// without VERBESSERUNG_BESSERE_GEWINNT: a worse Note of the repeat becomes the LV-Note
|
||||
"punkte-notenverbesserung": {
|
||||
description: "Punktemodus mit Notenverbesserung und Notenimport",
|
||||
flags: { CIS_GESAMTNOTE_PUNKTE: true },
|
||||
config: {
|
||||
CIS_GESAMTNOTE_NOTENVERBESSERUNG: true,
|
||||
CIS_GESAMTNOTE_NOTENIMPORT: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
# Keys of the noten suite. Copy to tests/cypress/suites/.env (not in Git) and fill in the passwords.
|
||||
# This file IS in Git: never put a real password here. A value from the environment wins over the file.
|
||||
# WARNING: the suite deletes and writes grades. Use a test database only.
|
||||
# The default values are the test data of seeder TST60873 (npm run noten:check -- seed).
|
||||
|
||||
# ---- required: the Lektor the suite works as ---------------------------------------------------
|
||||
NOTEN_LEKTOR_USER=demolektor1
|
||||
NOTEN_LEKTOR_PASSWORD=
|
||||
|
||||
# ---- required: the database of the instance at BASE_URL ----------------------------------------
|
||||
# The suite resets its test data directly in the database. Without it every spec fails.
|
||||
NOTEN_DB_HOST=
|
||||
NOTEN_DB_PORT=5432
|
||||
NOTEN_DB_NAME=
|
||||
NOTEN_DB_USER=
|
||||
NOTEN_DB_PASSWORD=
|
||||
NOTEN_DB_SSL=false
|
||||
NOTEN_DB_ALLOW_WRITES=true
|
||||
|
||||
# ---- optional: empty -> the tests that need the value skip -------------------------------------
|
||||
|
||||
# The test LV. Empty -> the first LV of NOTEN_LEKTOR_USER. Empty semester -> the current one.
|
||||
NOTEN_LV_ID=5221
|
||||
NOTEN_SEM_KURZBZ=
|
||||
|
||||
# Role tests: an account with only the permission lehre/benotungstool_assistenz.
|
||||
NOTEN_ASSISTENZ_USER=demoassistenz
|
||||
NOTEN_ASSISTENZ_PASSWORD=
|
||||
|
||||
# Freigabe tests: the LDAP password of NOTEN_LEKTOR_USER. Empty -> NOTEN_LEKTOR_PASSWORD.
|
||||
# A Freigabe sends a mail. Use an instance that sends all mail to a debug mailbox (MAIL_DEBUG).
|
||||
NOTEN_FREIGABE_PASSWORD=
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Connects the noten suite to cypress.config.js: its cy.task functions, its run hooks and
|
||||
* the Cypress.env() values of its specs. The keys come from tests/cypress/suites/.env.
|
||||
*/
|
||||
|
||||
const path = require("path");
|
||||
|
||||
require("dotenv").config({ path: path.join(__dirname, ".env") });
|
||||
|
||||
const { registerNotenDbTasks } = require("../tasks/notenDb");
|
||||
const { closeDb } = require("../tasks/db");
|
||||
|
||||
// cy.request sends one request after the other; the concurrency test needs two at the same time.
|
||||
// Basic auth without a session cookie: PHP runs two requests of one session one after the other
|
||||
// (session lock), and the test would never reach the database lock.
|
||||
const postParallel = ({ path: apiPath, bodies }) => {
|
||||
const base = String(process.env.BASE_URL).replace(/\/+$/, "");
|
||||
const auth =
|
||||
"Basic " +
|
||||
Buffer.from(`${process.env.NOTEN_LEKTOR_USER}:${process.env.NOTEN_LEKTOR_PASSWORD}`).toString("base64");
|
||||
|
||||
return Promise.all(
|
||||
bodies.map((body) =>
|
||||
fetch(`${base}/index.ci.php/api/frontend/v1/Noten/${apiPath}`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: auth, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}).then(async (r) => {
|
||||
const text = await r.text();
|
||||
try {
|
||||
return { status: r.status, body: JSON.parse(text) };
|
||||
} catch (e) {
|
||||
return { status: r.status, body: null, text: text.slice(0, 500) };
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
// the keys of suites/.env.example that the specs read; the DB keys stay in Node
|
||||
const env = () => ({
|
||||
NOTEN_LEKTOR_USER: process.env.NOTEN_LEKTOR_USER || null,
|
||||
NOTEN_LEKTOR_PASSWORD: process.env.NOTEN_LEKTOR_PASSWORD || null,
|
||||
NOTEN_SEM_KURZBZ: process.env.NOTEN_SEM_KURZBZ || null,
|
||||
NOTEN_LV_ID: process.env.NOTEN_LV_ID || null,
|
||||
NOTEN_ASSISTENZ_USER: process.env.NOTEN_ASSISTENZ_USER || null,
|
||||
NOTEN_ASSISTENZ_PASSWORD: process.env.NOTEN_ASSISTENZ_PASSWORD || null,
|
||||
NOTEN_FREIGABE_PASSWORD: process.env.NOTEN_FREIGABE_PASSWORD || null,
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
setupNodeEvents: (on, config) => {
|
||||
registerNotenDbTasks(on);
|
||||
on("task", { "noten:http:postParallel": postParallel });
|
||||
|
||||
// the pool closes after the run, so the database keeps no idle connections of it
|
||||
on("after:run", () => closeDb());
|
||||
|
||||
// a value from --env wins over the suite's .env
|
||||
config.env = { ...env(), ...config.env };
|
||||
return config;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
NOTEN TEST SUITE
|
||||
================
|
||||
Tests for the Benotungstool page and its backend, the Noten API.
|
||||
|
||||
WARNING: the suite deletes and writes grades. Run it only against a test instance with a test database.
|
||||
|
||||
|
||||
1. Start
|
||||
--------
|
||||
One time:
|
||||
|
||||
npm install
|
||||
copy tests/cypress/.env.example -> tests/cypress/.env BASE_URL and one login
|
||||
copy tests/cypress/suites/.env.example -> tests/cypress/suites/.env passwords and database
|
||||
npm run noten:check -- seed Seeds the test data: demolektor1, LV 5221, demoassistenz.
|
||||
|
||||
NOTEN_LEKTOR_USER must be a Lektor who teaches an LV. An admin teaches nothing, so the suite finds no LV.
|
||||
|
||||
Run:
|
||||
|
||||
npm run noten:check Checks the test data in the database. Run it first when something fails.
|
||||
npm run noten:api API tests.
|
||||
npm run noten:ui UI tests in Chrome.
|
||||
npx cypress open Cypress with a window, to write a test.
|
||||
|
||||
Put more Cypress options after "--". A second --spec replaces the default:
|
||||
|
||||
npm run noten:api -- --spec tests/cypress/e2e/specs/api/noten/frist.cy.js
|
||||
npm run noten:ui -- --headed --no-exit
|
||||
|
||||
Pending tests are normal. A test skips when the instance has a different configuration than the
|
||||
test needs. The skip message names the key.
|
||||
|
||||
A profile in profiles/noten.js lists the keys for the other branch. To run such a test, set the keys
|
||||
of the profile in application/config/noten.php (`config`) or config/global.config.inc.php (`flags`)
|
||||
on the test instance, run the suite, and put the old values back.
|
||||
|
||||
|
||||
2. Domain model
|
||||
---------------
|
||||
Note A grade from lehre.tbl_note. Tests never use a fixed id: they use ctx.noten.
|
||||
LV-Note The grade of one student in one LV (campus.tbl_lvgesamtnote).
|
||||
Pruefung One exam with a date (lehre.tbl_pruefung).
|
||||
Antritt A Pruefung that uses one of the allowed attempts. "entschuldigt", "Noch nicht
|
||||
eingetragen" and "Nicht beurteilt" use no Antritt.
|
||||
Verlauf The rule state that the server derives for each student: pruefungen, antrittCount,
|
||||
maxAntritte, canAdd, bestanden, terminal. The client and the tests only read it.
|
||||
Freigabe The Lektor releases the LV-Notes. This makes them binding and sends a mail.
|
||||
Frist The last day to enter a Note (Noteneintragungsfrist).
|
||||
Zeugnisnote The final grade. Only the StV writes it. An Anrechnung blocks every Pruefung.
|
||||
Punkte With CIS_GESAMTNOTE_PUNKTE the Lektor enters Punkte; the Notenschluessel gives the Note.
|
||||
Lektor Teaches the LV and enters the Notes.
|
||||
Assistenz Enters Notes for a Studiengang. CIS_GESAMTNOTE_ROLLENMATRIX decides what it may do.
|
||||
|
||||
The Antritt chain of one student:
|
||||
|
||||
Antritt 1 the first Note
|
||||
Antritt 2, 3 ... a repeat, only after a NEGATIVE Note
|
||||
kommissionelle Pruefung the last Antritt; it always closes the chain
|
||||
|
||||
A Note from NOTEN_ABSCHLIESSEND always closes the chain ("bestanden"). Any other positive Note
|
||||
closes it only while CIS_GESAMTNOTE_NOTENVERBESSERUNG is off.
|
||||
|
||||
The server holds all rules: PruefungsverlaufLib the Antritt chain, the controller Noten.php access,
|
||||
Frist and Freigabe. The instance configuration decides every rule value (CIS_GESAMTNOTE_*).
|
||||
Tests read it from getCisConfig as ctx.cisConfig.
|
||||
|
||||
|
||||
3. Architecture
|
||||
---------------
|
||||
cypress.config.js Global: BASE_URL and one login for all suites. Loads suites/noten.js.
|
||||
tests/cypress/
|
||||
suites/noten.js The suite: its cy.task list and its Cypress.env values.
|
||||
suites/.env NOTEN_* keys: accounts and database (not in Git).
|
||||
e2e/specs/api/noten/<topic>.cy.js Calls the Noten API and checks the response.
|
||||
e2e/specs/ui/noten/<topic>.cy.js Clicks through the Benotungstool page.
|
||||
support/api/notenApi.js One function per endpoint, and the login.
|
||||
support/pages/benotungstool.po.js All selectors and clicks of the page.
|
||||
support/helpers/ ctx, test data, skips, assertions.
|
||||
tasks/ Database access. Runs in Node, not in the browser.
|
||||
tools/dbCheck.js Checks the test data without a browser.
|
||||
profiles/noten.js The configurations that the suite must pass.
|
||||
local/ Personal tools (SSH tunnel, lint). The suite never needs them.
|
||||
|
||||
The Noten API (application/controllers/api/frontend/v1/Noten.php):
|
||||
|
||||
read getCisConfig, getNoten, getBenotungstoolContext, getLvForStudiengang,
|
||||
getLehreinheitenForLv, getLektorenForLehreinheit, getStudentenNoten, getNoteByPunkte
|
||||
write saveLvNote, importLvNoten, savePruefung, createPruefungen, importPruefungen, saveFreigabe
|
||||
|
||||
getStudentenNoten { students: [row], domain }. A row has zeugnisnote, lv_note, lv_punkte,
|
||||
freigabedatum, benotungsdatum, teilnoten, proposed_note and verlauf.
|
||||
each write { <uid>: { lvgesamtnote, verlauf, pruefung } }
|
||||
a rejected row { <uid>: { error: { code, message } } } (HTTP 200: createPruefungen and the imports)
|
||||
a stopped request HTTP 500, errors: [{ code, message }]
|
||||
the code the phrase key of the message, for example "maxAntritteReached"
|
||||
|
||||
Every test has four steps:
|
||||
|
||||
1. before loadNotenContext() gives ctx: semester, LV, students, Noten, configuration.
|
||||
2. beforeEach loginAsLektor() and requireDbReset(). The test starts with givenBaseline(ctx, student).
|
||||
3. act notenApi.* in an API test, benotungstoolPage.* in a UI test.
|
||||
4. check readStateViaApi(ctx): what the server reports.
|
||||
readLvGesamtnoteViaDb(ctx, student): what the database stores.
|
||||
|
||||
The helpers:
|
||||
|
||||
notenApi.js notenApi.* as NOTEN_LEKTOR_USER; apiGet / apiPost(path, params, auth) as another
|
||||
account; loginAsLektor, lektorAuth, freigabePassword
|
||||
notenTestData.js loadNotenContext, requireDbReset, resetNotenState, seedBaseline, seedPruefung,
|
||||
seedZeugnisnote, readLvGesamtnoteViaDb, readAuthUid, antrittDate
|
||||
notenScenario.js givenBaseline, addPruefung, editPruefung, readStateViaApi,
|
||||
studentOf, verlaufOf, pruefungenOf, antritteOf
|
||||
notenConfig.js skipIf and the require* skips
|
||||
notenErrors.js expectNotenSuccess, expectNotenError, expectBulkRowAccepted, expectBulkRowError
|
||||
notenAssistenz.js the Assistenz account
|
||||
|
||||
|
||||
4. Rules for a new test
|
||||
-----------------------
|
||||
- Put the test into the spec of its topic. Create a new spec only for a new topic.
|
||||
- Write the test title in German and describe the behavior: "lehnt eine zweite Prüfung ohne Note ab".
|
||||
- Write the message of an expect in German too. Only the comments are English.
|
||||
- Use no fixed ids and no fixed dates. Use ctx.noten, ctx.notenScale, ctx.students, antrittDate(ctx, n).
|
||||
- Pass the student object (ctx.students[i]) to a helper, not the uid. Use at most ctx.students[6].
|
||||
- Call the API through notenApi, or apiGet / apiPost for another account. No own cy.request.
|
||||
- Start each test with givenBaseline, or with resetNotenState alone if the test needs a student
|
||||
without a Note. To seed several students after one reset, use resetNotenState and then seedBaseline
|
||||
for each student.
|
||||
- Never depend on an earlier test.
|
||||
- Check an error by its code: expectNotenError(response, "maxAntritteReached"). Never compare a text.
|
||||
- Skip only with skipIf(this, condition, reason) or a require* helper.
|
||||
- Write a test that skips as function () {}, not as () => {}. An arrow function has no "this".
|
||||
- Test the branch that the instance has, and skip the other branch.
|
||||
- Do not stub the Noten API. Use cy.intercept only to wait for a request.
|
||||
- UI: select elements only by data-cy. After a save, wait for the request (waitForOk), not for the DOM.
|
||||
- UI specs run in Chrome. Electron crashes on the Benotungstool page.
|
||||
- A JavaScript error of the page fails the test. Only a rejected API request does not: the page
|
||||
already shows it as a toast (support/commands.js).
|
||||
- Write comments in English: one line, and only when the code does not show the reason.
|
||||
|
||||
|
||||
5. Naming
|
||||
---------
|
||||
- Domain words stay German, in the code too: Note, LV-Note, Pruefung, Antritt, Verlauf, Freigabe,
|
||||
Frist, Zeugnisnote, Punkte, Lektor, Assistenz, Lehreinheit, Studiengang, Notenschluessel,
|
||||
Teilnote, Anrechnung, entschuldigt, kommissionell. All other words are English.
|
||||
- Two German words of the page are no domain words. The code uses the English word:
|
||||
|
||||
Notenvorschlag -> proposal the Note that the table proposes; applyProposal writes it as the LV-Note
|
||||
Wiederholung -> repeat a Pruefung after Antritt 1 (verlauf.hasRepeat, requireRepeat)
|
||||
|
||||
- In code and comments, do not translate a domain word, and do not use a second word for it: "course grade" is LV-Note,
|
||||
"attempt" is Antritt, "exam" and "Termin" are Pruefung.
|
||||
|
||||
- One thing has one name in every layer:
|
||||
|
||||
suites/.env JavaScript API field
|
||||
NOTEN_LV_ID ctx.lvId lv_id
|
||||
NOTEN_SEM_KURZBZ ctx.semKurzbz sem_kurzbz
|
||||
NOTEN_LEKTOR_USER lektorAuth()
|
||||
|
||||
An endpoint has the same name in Noten.php, public/js/api/factory/noten.js, notenApi.js and the
|
||||
intercept alias of the page object (@savePruefung).
|
||||
- "noten" is the name of the suite: NOTEN_* keys, noten*.js files, cy.task("noten:..."), npm run noten:*.
|
||||
"benotungstool" names only the page: benotungstool.po.js.
|
||||
- A function name starts with a verb: read, seed, add, edit, expect, require.
|
||||
- JavaScript names use camelCase. API fields keep the server spelling (student_uid, lv_id).
|
||||
|
||||
|
||||
6. Debugging
|
||||
------------
|
||||
401 in all tests
|
||||
The web instance and suites/.env use different databases. Compare DB_NAME in
|
||||
config/system.config.inc.php with NOTEN_DB_NAME.
|
||||
|
||||
"Fixture-Reset nicht verfügbar"
|
||||
The database connection is missing. Check NOTEN_DB_* in suites/.env. The pg_hba.conf of the
|
||||
database must allow the machine that runs Cypress.
|
||||
|
||||
A message contains "<< PHRASE"
|
||||
The phrase is missing in the database. Run system/phrasesupdate.php and phrasesync.php on the server.
|
||||
phrasesupdate.php only INSERTS a phrase. A changed text therefore needs a new key, which is why
|
||||
some keys end with a number: notenimportHinweistextv6.
|
||||
|
||||
"LV ... hat N Studierende, die Suite braucht 7"
|
||||
The test LV has too few students. Run: npm run noten:check -- seed
|
||||
|
||||
The next run does not start
|
||||
A Cypress process still runs. Stop it: Stop-Process -Name Cypress -Force
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* The Noten API. The same names and parameters as public/js/api/factory/noten.js.
|
||||
*
|
||||
* notenApi.* calls an endpoint as NOTEN_LEKTOR_USER. apiGet / apiPost call it as another account.
|
||||
*/
|
||||
|
||||
const NOTEN_API = "/index.ci.php/api/frontend/v1/Noten";
|
||||
|
||||
/** The Lektor the suite works as. No fallback to USER_NAME: that user belongs to every suite. */
|
||||
export const lektorAuth = () => {
|
||||
const username = Cypress.env("NOTEN_LEKTOR_USER");
|
||||
const password = Cypress.env("NOTEN_LEKTOR_PASSWORD");
|
||||
if (!username || !password) {
|
||||
throw new Error("NOTEN_LEKTOR_USER / NOTEN_LEKTOR_PASSWORD missing in tests/cypress/suites/.env");
|
||||
}
|
||||
return { username, password };
|
||||
};
|
||||
|
||||
/** The LDAP password for the Freigabe. NOTEN_FREIGABE_PASSWORD, else the password of NOTEN_LEKTOR_USER. */
|
||||
export const freigabePassword = () => Cypress.env("NOTEN_FREIGABE_PASSWORD") || lektorAuth().password;
|
||||
|
||||
/**
|
||||
* Logs in as NOTEN_LEKTOR_USER. Call it in beforeEach: cy.session logs in once per run and restores
|
||||
* the session before each test. Without a session cookie the server checks every request against LDAP,
|
||||
* and many LDAP binds can block all PHP-FPM workers of the instance.
|
||||
*/
|
||||
export const loginAsLektor = () => {
|
||||
const { username, password } = lektorAuth();
|
||||
cy.login(username, password);
|
||||
};
|
||||
|
||||
/** auth: the account (default NOTEN_LEKTOR_USER), or null for no credentials. A session cookie wins over auth. */
|
||||
export const apiGet = (path, qs, auth = lektorAuth()) =>
|
||||
cy.request({ method: "GET", url: `${NOTEN_API}/${path}`, qs, ...(auth ? { auth } : {}), failOnStatusCode: false });
|
||||
|
||||
export const apiPost = (path, body, auth = lektorAuth()) =>
|
||||
cy.request({
|
||||
method: "POST",
|
||||
url: `${NOTEN_API}/${path}`,
|
||||
body,
|
||||
...(auth ? { auth } : {}),
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
|
||||
export const notenApi = {
|
||||
getCisConfig: () => apiGet("getCisConfig"),
|
||||
|
||||
getNoten: () => apiGet("getNoten"),
|
||||
|
||||
getBenotungstoolContext: (sem_kurzbz, lv_id = null) => apiGet("getBenotungstoolContext", { sem_kurzbz, lv_id }),
|
||||
|
||||
getLvForStudiengang: (studiengang_kz, sem_kurzbz) => apiGet("getLvForStudiengang", { studiengang_kz, sem_kurzbz }),
|
||||
|
||||
getLehreinheitenForLv: (lv_id, sem_kurzbz) => apiGet("getLehreinheitenForLv", { lv_id, sem_kurzbz }),
|
||||
|
||||
getLektorenForLehreinheit: (lv_id, sem_kurzbz, lehreinheit_id) =>
|
||||
apiGet("getLektorenForLehreinheit", { lv_id, sem_kurzbz, lehreinheit_id }),
|
||||
|
||||
/** data -> { students, domain }. Read it with notenScenario.js. */
|
||||
getStudentenNoten: (lv_id, sem_kurzbz) => apiGet("getStudentenNoten", { lv_id, sem_kurzbz }),
|
||||
|
||||
getNoteByPunkte: (lv_id, sem_kurzbz, punkte) => apiPost("getNoteByPunkte", { lv_id, sem_kurzbz, punkte }),
|
||||
|
||||
// Each write answers data[uid] = { lvgesamtnote, verlauf, pruefung }. A bulk write answers HTTP 200
|
||||
// also for a rejected row: data[uid] = { error: { code, message } }.
|
||||
|
||||
saveLvNote: (lv_id, sem_kurzbz, student_uid, note, punkte = null, datum = null) =>
|
||||
apiPost("saveLvNote", { lv_id, sem_kurzbz, student_uid, note, punkte, datum }),
|
||||
|
||||
/** lv_noten: [{ uid, note, punkte }] */
|
||||
importLvNoten: (lv_id, sem_kurzbz, lv_noten) => apiPost("importLvNoten", { lv_id, sem_kurzbz, lv_noten }),
|
||||
|
||||
/** pruefung: { pruefung_id (null = a new Pruefung), lehreinheit_id, datum, note, punkte, mitarbeiter_uid } */
|
||||
savePruefung: (lv_id, sem_kurzbz, student_uid, pruefung) =>
|
||||
apiPost("savePruefung", { lv_id, sem_kurzbz, student_uid, ...pruefung }),
|
||||
|
||||
/** students: [{ uid, lehreinheit_id }]; pruefung: { datum, note, punkte, mitarbeiter_uid } */
|
||||
createPruefungen: (lv_id, sem_kurzbz, students, pruefung) =>
|
||||
apiPost("createPruefungen", { lv_id, sem_kurzbz, students, ...pruefung }),
|
||||
|
||||
/** pruefungen: [{ uid, lehreinheit_id, datum, note, punkte }] */
|
||||
importPruefungen: (lv_id, sem_kurzbz, pruefungen) => apiPost("importPruefungen", { lv_id, sem_kurzbz, pruefungen }),
|
||||
|
||||
/**
|
||||
* The Freigabe needs the LDAP password. The getCisConfig call first creates a session. Without it,
|
||||
* the Basic-auth login loads ldap.php with require_once, and the password check in the same request
|
||||
* finds no LDAP configuration and answers "Incorrect password".
|
||||
*/
|
||||
saveFreigabe: (lv_id, sem_kurzbz, password, uids) =>
|
||||
apiGet("getCisConfig").then(() => apiPost("saveFreigabe", { lv_id, sem_kurzbz, password, uids })),
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
// Custom commands: https://on.cypress.io/custom-commands
|
||||
|
||||
/** Visits a page and fails if any request returned >= 400. */
|
||||
Cypress.Commands.add("checkPageResources", (url) => {
|
||||
const failedRequests = [];
|
||||
|
||||
cy.intercept("**", (req) => {
|
||||
req.on("response", (res) => {
|
||||
if (res.statusCode >= 400) {
|
||||
failedRequests.push({ url: req.url, status: res.statusCode });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
cy.env(["adminusername", "adminpassword"]).then(({ adminusername, adminpassword }) => {
|
||||
cy.visit(url, { auth: { username: adminusername, password: adminpassword } });
|
||||
});
|
||||
|
||||
cy.then(() => {
|
||||
if (failedRequests.length > 0) {
|
||||
const message = failedRequests.map((r) => `${r.status} - ${r.url}`).join("\n");
|
||||
throw new Error(`Broken resources detected:\n${message}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Session login. cy.session keeps it across specs, so a test does not log in again.
|
||||
* Without arguments it logs in as USER_NAME.
|
||||
*/
|
||||
Cypress.Commands.add("login", (username, password) => {
|
||||
const { adminusername, adminpassword } = Cypress.env();
|
||||
const auth = { username: username || adminusername, password: password || adminpassword };
|
||||
const probe = () =>
|
||||
cy.request({ url: "/index.ci.php/api/frontend/v1/AuthInfo/getAuthUID", auth }).its("status").should("eq", 200);
|
||||
|
||||
cy.session(["login", auth.username], probe, {
|
||||
cacheAcrossSpecs: true,
|
||||
validate: probe,
|
||||
});
|
||||
});
|
||||
|
||||
// The FHC API layer shows a server error as a toast and then rejects the promise; the page does not
|
||||
// catch it. That is no crash. Every other error of the page fails the test.
|
||||
Cypress.on("uncaught:exception", (err) => (err.name === "AxiosError" ? false : undefined));
|
||||
@@ -0,0 +1,2 @@
|
||||
// Loaded automatically before every spec.
|
||||
import "./commands";
|
||||
@@ -0,0 +1 @@
|
||||
export const waitForOk = (alias) => cy.wait(alias).its("response.statusCode").should("eq", 200);
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* The Assistenz account: NOTEN_ASSISTENZ_USER / NOTEN_ASSISTENZ_PASSWORD.
|
||||
*
|
||||
* getByStgs returns a Studiengang only for a semester with a Studienplan. So the helpers search
|
||||
* backwards from the semester of the suite. Call cy.clearAllCookies() before the first request,
|
||||
* otherwise the request runs with the session cookie of NOTEN_LEKTOR_USER.
|
||||
*/
|
||||
|
||||
import { apiGet } from "../api/notenApi";
|
||||
import { expectNotenSuccess } from "./notenErrors";
|
||||
import { skipIf } from "./notenConfig";
|
||||
|
||||
const SEMESTER_SEARCH_DEPTH = 6;
|
||||
|
||||
export const assistenzConfigured = () =>
|
||||
Boolean(Cypress.env("NOTEN_ASSISTENZ_USER") && Cypress.env("NOTEN_ASSISTENZ_PASSWORD"));
|
||||
|
||||
/** Skips if no Assistenz account is configured. */
|
||||
export const requireAssistenz = (testContext) =>
|
||||
skipIf(
|
||||
testContext,
|
||||
!assistenzConfigured(),
|
||||
"Übersprungen: NOTEN_ASSISTENZ_USER / NOTEN_ASSISTENZ_PASSWORD fehlen.",
|
||||
);
|
||||
|
||||
export const assistenzAuth = () => ({
|
||||
username: Cypress.env("NOTEN_ASSISTENZ_USER"),
|
||||
password: Cypress.env("NOTEN_ASSISTENZ_PASSWORD"),
|
||||
});
|
||||
|
||||
/** WS2026 -> [WS2026, SS2026, WS2025, ...] */
|
||||
const semestersBackwards = (semKurzbz) => {
|
||||
const semesterList = [semKurzbz];
|
||||
let type = semKurzbz.slice(0, 2);
|
||||
let year = Number(semKurzbz.slice(2, 6));
|
||||
|
||||
while (semesterList.length < SEMESTER_SEARCH_DEPTH) {
|
||||
if (type === "WS") {
|
||||
type = "SS";
|
||||
} else {
|
||||
type = "WS";
|
||||
year -= 1;
|
||||
}
|
||||
semesterList.push(`${type}${year}`);
|
||||
}
|
||||
return semesterList;
|
||||
};
|
||||
|
||||
/** -> { semKurzbz, data } for the newest semester in which the Assistenz sees a Studiengang, else null. */
|
||||
export const assistenzContext = (startSemKurzbz) => {
|
||||
const semesterList = semestersBackwards(startSemKurzbz);
|
||||
|
||||
const trySemester = (i) =>
|
||||
i >= semesterList.length
|
||||
? cy.wrap(null, { log: false })
|
||||
: apiGet("getBenotungstoolContext", { sem_kurzbz: semesterList[i] }, assistenzAuth()).then((response) => {
|
||||
const data = expectNotenSuccess(response, `Kontext der Assistenz in ${semesterList[i]}`);
|
||||
return data.studiengaenge.length ? { semKurzbz: semesterList[i], data } : trySemester(i + 1);
|
||||
});
|
||||
|
||||
return trySemester(0);
|
||||
};
|
||||
|
||||
/** -> { semKurzbz, lvId, lehreinheiten } for an LV of the Assistenz with Lehreinheiten in the database, else null. */
|
||||
export const assistenzLvWithLehreinheiten = (startSemKurzbz) =>
|
||||
assistenzContext(startSemKurzbz).then((result) => {
|
||||
if (!result) return null;
|
||||
|
||||
const { semKurzbz } = result;
|
||||
const studiengaenge = result.data.studiengaenge;
|
||||
|
||||
const firstLvWithLehreinheiten = (lvs, j) =>
|
||||
j >= lvs.length
|
||||
? cy.wrap(null, { log: false })
|
||||
: cy
|
||||
.task("noten:db:readLehreinheitenOfLv", { lvId: lvs[j].lehrveranstaltung_id, semKurzbz })
|
||||
.then((ids) =>
|
||||
ids.length
|
||||
? {
|
||||
semKurzbz,
|
||||
lvId: lvs[j].lehrveranstaltung_id,
|
||||
lehreinheiten: ids.map(String).sort(),
|
||||
}
|
||||
: firstLvWithLehreinheiten(lvs, j + 1),
|
||||
);
|
||||
|
||||
const tryStudiengang = (i) =>
|
||||
i >= studiengaenge.length
|
||||
? cy.wrap(null, { log: false })
|
||||
: apiGet(
|
||||
"getLvForStudiengang",
|
||||
{ studiengang_kz: studiengaenge[i].studiengang_kz, sem_kurzbz: semKurzbz },
|
||||
assistenzAuth(),
|
||||
)
|
||||
.then((response) =>
|
||||
firstLvWithLehreinheiten(expectNotenSuccess(response, "LVs des Studiengangs"), 0),
|
||||
)
|
||||
.then((target) => target || tryStudiengang(i + 1));
|
||||
|
||||
return tryStudiengang(0);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Skip helpers for tests that depend on the configuration.
|
||||
*
|
||||
* The configuration of the instance decides which rule branch is active, and a test run cannot switch
|
||||
* it. So a test checks the active branch and skips the other one. A profile in profiles/noten.js
|
||||
* switches the instance to the other branch (readme_noten.txt, section 1).
|
||||
*/
|
||||
|
||||
/**
|
||||
* The only way to skip a test. `condition` true -> skip; `reason` says why.
|
||||
* Call it in it(function () {...}) or beforeEach(function () {...}), never in an arrow function:
|
||||
* an arrow function has no Mocha `this`, so `this.skip()` is not available.
|
||||
*
|
||||
* Each `require*` helper below names one domain precondition and calls `skipIf`.
|
||||
*/
|
||||
export const skipIf = (testContext, condition, reason) => {
|
||||
if (!condition) return;
|
||||
Cypress.log({ name: "skip", message: reason });
|
||||
testContext.skip();
|
||||
};
|
||||
|
||||
const punkteMode = (ctx) => Boolean(ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE);
|
||||
|
||||
/** Skips if the Punkte mode is off. Call it as the first line in beforeEach(function () {...}). */
|
||||
export const requirePunkteMode = (testContext, ctx) =>
|
||||
skipIf(testContext, !punkteMode(ctx), "Übersprungen: CIS_GESAMTNOTE_PUNKTE ist aus.");
|
||||
|
||||
/** Skips if the Punkte mode is on. */
|
||||
export const requireNotenMode = (testContext, ctx) =>
|
||||
skipIf(testContext, punkteMode(ctx), "Übersprungen: CIS_GESAMTNOTE_PUNKTE ist aktiv.");
|
||||
|
||||
/** Skips if the getCisConfig key does not have the value that the test needs. */
|
||||
export const requireConfig = (testContext, ctx, key, value) =>
|
||||
skipIf(
|
||||
testContext,
|
||||
ctx.cisConfig[key] !== value,
|
||||
`Übersprungen: ${key} ist ${JSON.stringify(ctx.cisConfig[key])}, der Test braucht ${JSON.stringify(value)}.`,
|
||||
);
|
||||
|
||||
/**
|
||||
* Skips if the Benotungstool cannot create a repeat (Antritt 2). Without CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF
|
||||
* the chain ends for the Benotungstool before the kommissionelle Pruefung.
|
||||
*/
|
||||
export const requireRepeat = (testContext, ctx) => {
|
||||
const fromAntritt = ctx.cisConfig.CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT;
|
||||
const creatable =
|
||||
ctx.cisConfig.CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF === false && fromAntritt !== null
|
||||
? Math.min(ctx.maxAntritte, fromAntritt - 1)
|
||||
: ctx.maxAntritte;
|
||||
skipIf(testContext, creatable < 2, `Übersprungen: das Werkzeug legt nur ${creatable} Antritt an.`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Skips if the Benotungstool cannot create the kommissionelle Pruefung: the chain has none, or
|
||||
* CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF forbids it.
|
||||
*/
|
||||
export const requireKommissionellerAntritt = (testContext, ctx) => {
|
||||
const fromAntritt = ctx.cisConfig.CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT;
|
||||
const creatable = ctx.cisConfig.CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF;
|
||||
const possible = creatable && fromAntritt !== null && fromAntritt >= 2 && fromAntritt <= ctx.maxAntritte;
|
||||
skipIf(
|
||||
testContext,
|
||||
!possible,
|
||||
`Übersprungen: kein anlegbarer kommissioneller Antritt (ab ${JSON.stringify(fromAntritt)}, anlegen ${creatable}).`,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Assertions for the answers of the Noten API.
|
||||
*
|
||||
* Each error has a code: the phrase key of its message. The helpers compare the code. A message with
|
||||
* "<< PHRASE" means that the phrase is missing in the database: run phrasesync.php on the instance.
|
||||
*/
|
||||
|
||||
const expectTranslated = (message, code) =>
|
||||
expect(message, `Meldung von "${code}"`)
|
||||
.to.be.a("string")
|
||||
.and.not.match(/<<\s*PHRASE/i);
|
||||
|
||||
/** The request stops with this error: HTTP 500 and the code in errors[]. */
|
||||
export const expectNotenError = (response, code) => {
|
||||
expect(response.status, `HTTP-Status für den erwarteten Fehler "${code}"`).to.eq(500);
|
||||
expect(response.body).to.have.nested.property("meta.status", "error");
|
||||
|
||||
const error = (response.body.errors ?? []).find((e) => e.code === code);
|
||||
expect(error, `Fehler "${code}", erhalten: ${JSON.stringify(response.body.errors)}`).to.exist;
|
||||
expectTranslated(error.message, code);
|
||||
};
|
||||
|
||||
export const expectNotenSuccess = (response, context = "Anfrage") => {
|
||||
// carry the server's message into the assertion, or a 500 says only "expected 200"
|
||||
const errors = ((response.body && response.body.errors) || []).map((e) => e.message).join(" | ");
|
||||
expect(response.status, `HTTP-Status für ${context}${errors ? ` -- ${errors}` : ""}`).to.eq(200);
|
||||
expect(response.body, context).to.have.nested.property("meta.status", "success");
|
||||
return response.body.data;
|
||||
};
|
||||
|
||||
/** An auth failure stops the request before any error is added, so the body has only meta. */
|
||||
export const expectAuthError = (response) => {
|
||||
expect(response.status, "HTTP-Status für die abgewiesene Anmeldung").to.eq(401);
|
||||
expect(response.body).to.have.nested.property("meta.status", "error");
|
||||
};
|
||||
|
||||
/** A bulk write rejects one row: data[uid] = { error: { code, message } }. */
|
||||
export const expectBulkRowError = (data, uid, code) => {
|
||||
expect(data).to.have.property(uid);
|
||||
expect(data[uid].error?.code, `Zeile "${uid}", erhalten: ${JSON.stringify(data[uid])}`).to.eq(code);
|
||||
expectTranslated(data[uid].error.message, code);
|
||||
};
|
||||
|
||||
export const expectBulkRowAccepted = (data, uid) => {
|
||||
expect(data).to.have.property(uid);
|
||||
expect(data[uid].error, `Zeile "${uid}" akzeptiert, erhalten: ${JSON.stringify(data[uid].error)}`).to.be.undefined;
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
/** Add or change a Pruefung through the API, then read what the server reports. */
|
||||
|
||||
import { notenApi } from "../api/notenApi";
|
||||
import { expectNotenSuccess } from "./notenErrors";
|
||||
import { resetNotenState, seedBaseline } from "./notenTestData";
|
||||
|
||||
/**
|
||||
* A new Pruefung: the server runs validateAdd.
|
||||
*
|
||||
* punkte stays null: in the Punkte mode any value (also 0) makes the server derive the Note from the
|
||||
* Notenschluessel, and that Note replaces the Note under test.
|
||||
*/
|
||||
export const addPruefung = (ctx, student, { note, datum }) =>
|
||||
notenApi.savePruefung(ctx.lvId, ctx.semKurzbz, student.uid, {
|
||||
pruefung_id: null,
|
||||
lehreinheit_id: student.lehreinheit_id,
|
||||
datum,
|
||||
note,
|
||||
punkte: null,
|
||||
});
|
||||
|
||||
/** Changes a Pruefung: the server runs validateEdit. */
|
||||
export const editPruefung = (ctx, student, { pruefungId, note, datum }) =>
|
||||
notenApi.savePruefung(ctx.lvId, ctx.semKurzbz, student.uid, {
|
||||
pruefung_id: pruefungId,
|
||||
lehreinheit_id: student.lehreinheit_id,
|
||||
datum,
|
||||
note,
|
||||
punkte: null,
|
||||
});
|
||||
|
||||
/** Clears the rows of the suite and seeds the baseline of Antritt 1 for one student. */
|
||||
export const givenBaseline = (ctx, student, options = {}) =>
|
||||
resetNotenState(ctx).then(() => seedBaseline(ctx, student, options));
|
||||
|
||||
/**
|
||||
* Reads getStudentenNoten: what the server REPORTS. Read the result with the functions below.
|
||||
* The counterpart is readLvGesamtnoteViaDb: what the database STORES.
|
||||
*/
|
||||
export const readStateViaApi = (ctx) =>
|
||||
notenApi
|
||||
.getStudentenNoten(ctx.lvId, ctx.semKurzbz)
|
||||
.then((response) => expectNotenSuccess(response, "getStudentenNoten"));
|
||||
|
||||
/** The row of one student: zeugnisnote, lv_note, lv_punkte, benotungsdatum, freigabedatum, verlauf. */
|
||||
export const studentOf = (data, uid) => data.students.find((s) => s.uid === uid);
|
||||
|
||||
/** The rule state that the server derives: antrittCount, maxAntritte, canAdd, terminal, bestanden, angerechnet. */
|
||||
export const verlaufOf = (data, uid) => studentOf(data, uid)?.verlauf;
|
||||
|
||||
/** All Pruefungen of one student, in Verlauf order. Check position, count and terminal, never the Pruefungstyp. */
|
||||
export const pruefungenOf = (data, uid) => verlaufOf(data, uid)?.pruefungen ?? [];
|
||||
|
||||
/** Only the Pruefungen that use an Antritt. "entschuldigt" and "Noch nicht eingetragen" do not. */
|
||||
export const antritteOf = (data, uid) => pruefungenOf(data, uid).filter((p) => p.is_antritt);
|
||||
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* Finds the test data at runtime (ctx), and resets and seeds it in the database.
|
||||
*
|
||||
* No fixed ids: the semester, the LV, the Noten and the rule values differ per installation, so the
|
||||
* suite reads them through the API. NOTEN_SEM_KURZBZ / NOTEN_LV_ID pin the semester and the LV.
|
||||
* The Noten API has no delete endpoint on purpose, so reset and seed use cy.task (tasks/notenDb.js).
|
||||
*/
|
||||
|
||||
import { notenApi, lektorAuth } from "../api/notenApi";
|
||||
import { expectNotenSuccess } from "./notenErrors";
|
||||
|
||||
const BEZ_ENTSCHULDIGT = "entschuldigt";
|
||||
const BEZ_NOCH_NICHT = "Noch nicht eingetragen";
|
||||
|
||||
// the highest index a spec uses is ctx.students[6]
|
||||
const MIN_STUDENTS = 7;
|
||||
|
||||
let cachedCtx = null;
|
||||
|
||||
// --- dates ---
|
||||
|
||||
export const pad2 = (n) => String(n).padStart(2, "0");
|
||||
|
||||
export const toDateString = (date) => `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
||||
|
||||
export const shiftDate = (dateString, days) => {
|
||||
const [y, m, d] = dateString.split("-").map(Number);
|
||||
const date = new Date(Date.UTC(y, m - 1, d));
|
||||
date.setUTCDate(date.getUTCDate() + days);
|
||||
return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())}`;
|
||||
};
|
||||
|
||||
/** The Frist the server derives (Noten::computeFrist). ssConfig/wsConfig: NOTENEINTRAGUNGSFRIST_SS/WS. */
|
||||
export const expectedFristString = (semKurzbz, ssConfig, wsConfig) => {
|
||||
const type = semKurzbz.slice(0, 2).toUpperCase();
|
||||
const year = Number(semKurzbz.slice(2, 6));
|
||||
const cfg = type === "SS" ? ssConfig : wsConfig;
|
||||
return `${pad2(cfg.day)}.${pad2(cfg.month)}.${type === "SS" ? year : year + 1}`;
|
||||
};
|
||||
|
||||
/** ssConfig/wsConfig: NOTENEINTRAGUNGSFRIST_SS/WS from getCisConfig. */
|
||||
export const fristHasPassed = (semKurzbz, ssConfig, wsConfig) => {
|
||||
const type = semKurzbz.slice(0, 2).toUpperCase();
|
||||
const year = Number(semKurzbz.slice(2, 6));
|
||||
if (!["SS", "WS"].includes(type) || !year) return false;
|
||||
const cfg = type === "SS" ? ssConfig : wsConfig;
|
||||
const frist = new Date(type === "SS" ? year : year + 1, cfg.month - 1, cfg.day, 23, 59, 59);
|
||||
return new Date() > frist;
|
||||
};
|
||||
|
||||
/**
|
||||
* A semester and LV where the Lektor teaches AND the Frist has passed.
|
||||
*
|
||||
* Both are necessary: assertLvAccess runs BEFORE the Frist check, so in a semester without teaching
|
||||
* the request fails there and never reaches the Frist. Seeder group benotungstool_fixture_erweitert
|
||||
* provides the Sommersemester.
|
||||
*
|
||||
* @param {string} type "SS" or "WS"
|
||||
* @returns {Cypress.Chainable<{semKurzbz: string, lvId: number}|null>}
|
||||
*/
|
||||
export const teachingSemesterWithExpiredFrist = (type = "SS", ssConfig, wsConfig) => {
|
||||
const year = new Date().getFullYear();
|
||||
const candidates = [];
|
||||
for (let y = year; y >= year - 6; y -= 1) {
|
||||
const semKurzbz = `${type}${y}`;
|
||||
if (fristHasPassed(semKurzbz, ssConfig, wsConfig)) candidates.push(semKurzbz);
|
||||
}
|
||||
|
||||
const trySemester = (i) => {
|
||||
if (i >= candidates.length) return cy.wrap(null, { log: false });
|
||||
|
||||
return notenApi.getBenotungstoolContext(candidates[i]).then((response) => {
|
||||
const lvs = (response.body && response.body.data && response.body.data.lehrveranstaltungen) || [];
|
||||
if (lvs.length > 0) {
|
||||
return { semKurzbz: candidates[i], lvId: lvs[0].lehrveranstaltung_id };
|
||||
}
|
||||
return trySemester(i + 1);
|
||||
});
|
||||
};
|
||||
|
||||
return trySemester(0);
|
||||
};
|
||||
|
||||
// --- discovery ---
|
||||
|
||||
const resolveSemester = () => {
|
||||
const configured = Cypress.env("NOTEN_SEM_KURZBZ");
|
||||
if (configured) return cy.wrap(configured, { log: false });
|
||||
|
||||
return cy
|
||||
.request({
|
||||
method: "GET",
|
||||
url: "/index.ci.php/api/frontend/v1/organisation/Studiensemester/getAll",
|
||||
qs: { order: "DESC" },
|
||||
auth: lektorAuth(),
|
||||
failOnStatusCode: false,
|
||||
})
|
||||
.then((response) => {
|
||||
const semesters = expectNotenSuccess(response, "Studiensemester/getAll");
|
||||
const today = toDateString(new Date());
|
||||
const active = semesters
|
||||
.filter((s) => (s.start || "").slice(0, 10) && (s.start || "").slice(0, 10) <= today)
|
||||
.sort((a, b) => (b.start || "").localeCompare(a.start || ""))[0];
|
||||
|
||||
expect(active, `ein Studiensemester, das am ${today} oder davor beginnt`).to.exist;
|
||||
return active.studiensemester_kurzbz;
|
||||
});
|
||||
};
|
||||
|
||||
const resolveLehrveranstaltung = (semKurzbz) => {
|
||||
const configured = Cypress.env("NOTEN_LV_ID");
|
||||
if (configured) return cy.wrap(Number(configured), { log: false });
|
||||
|
||||
return notenApi.getBenotungstoolContext(semKurzbz).then((response) => {
|
||||
const lvs = expectNotenSuccess(response, "getBenotungstoolContext").lehrveranstaltungen || [];
|
||||
expect(
|
||||
lvs.length,
|
||||
`Keine Lehrveranstaltung in ${semKurzbz}: der angemeldete Benutzer unterrichtet hier nichts ` +
|
||||
"(ein Admin meistens nirgends). Setze NOTEN_LV_ID.",
|
||||
).to.be.greaterThan(0);
|
||||
return lvs[0].lehrveranstaltung_id;
|
||||
});
|
||||
};
|
||||
|
||||
/** -> { semKurzbz, lvId, cisConfig, maxAntritte, noten, notenScale, notenOptions, students, studentUids } */
|
||||
export const loadNotenContext = () => {
|
||||
if (cachedCtx) return cy.wrap(cachedCtx, { log: false });
|
||||
|
||||
const ctx = {};
|
||||
|
||||
return resolveSemester()
|
||||
.then((semKurzbz) => {
|
||||
ctx.semKurzbz = semKurzbz;
|
||||
return resolveLehrveranstaltung(semKurzbz);
|
||||
})
|
||||
.then((lvId) => {
|
||||
ctx.lvId = lvId;
|
||||
return notenApi.getCisConfig();
|
||||
})
|
||||
.then((response) => {
|
||||
ctx.cisConfig = expectNotenSuccess(response, "getCisConfig");
|
||||
ctx.maxAntritte = ctx.cisConfig.CIS_GESAMTNOTE_MAX_ANTRITTE;
|
||||
return notenApi.getNoten();
|
||||
})
|
||||
.then((response) => {
|
||||
const noten = expectNotenSuccess(response, "getNoten");
|
||||
const byBezeichnung = (bez) => noten.find((n) => n.bezeichnung === bez);
|
||||
|
||||
const entschuldigt = byBezeichnung(BEZ_ENTSCHULDIGT);
|
||||
const nochNicht = byBezeichnung(BEZ_NOCH_NICHT);
|
||||
|
||||
expect(entschuldigt, `tbl_note braucht die Bezeichnung "${BEZ_ENTSCHULDIGT}"`).to.exist;
|
||||
expect(nochNicht, `tbl_note braucht die Bezeichnung "${BEZ_NOCH_NICHT}"`).to.exist;
|
||||
|
||||
// Only the 1..5 scale: the Lehre Noten also contain 0 ("Teilnote"), which is no result of an assessment.
|
||||
const specialPks = [entschuldigt.note, nochNicht.note];
|
||||
const usable = noten.filter((n) => n.lehre && !specialPks.includes(n.note));
|
||||
const ordinary = usable
|
||||
.map((n) => n.note)
|
||||
.filter((note) => Number(note) >= 1 && Number(note) <= 5)
|
||||
.sort((a, b) => Number(a) - Number(b));
|
||||
|
||||
ctx.notenScale =
|
||||
ordinary.length > 1
|
||||
? ordinary
|
||||
: usable
|
||||
.map((n) => n.note)
|
||||
.filter((n) => Number(n) !== 0)
|
||||
.sort((a, b) => Number(a) - Number(b));
|
||||
|
||||
expect(
|
||||
ctx.notenScale.length,
|
||||
"die Skala braucht zwei Noten, sonst ist keine Änderung prüfbar",
|
||||
).to.be.greaterThan(1);
|
||||
|
||||
// an administrative Note that the editor list leaves out ("intern angerechnet" / "nicht zugelassen")
|
||||
const notLehre = noten.find((n) => n.lehre === false);
|
||||
|
||||
// as a Zeugnisnote, this Note locks the LV-Note, in the client and in the server
|
||||
const notUeberschreibbar = noten.find((n) => n.lkt_ueberschreibbar === false);
|
||||
|
||||
// an Anrechnung blocks every Pruefung of the LV; the Zeugnisnote decides
|
||||
const angerechnet = byBezeichnung("angerechnet");
|
||||
const internAngerechnet = byBezeichnung("intern angerechnet");
|
||||
|
||||
// A test picks a Note by meaning, not by index: a positive Note closes the Antritt chain,
|
||||
// so a repeat after "Sehr Gut" is not a test case but an impossible flow.
|
||||
const abschliessend = (ctx.cisConfig.NOTEN_ABSCHLIESSEND || []).map(String);
|
||||
const inScale = (n) => Number(n.note) >= 1 && Number(n.note) <= 5;
|
||||
const negativNoten = usable.filter((n) => !n.positiv && inScale(n)).map((n) => n.note);
|
||||
const positivNoten = usable.filter((n) => n.positiv && inScale(n)).map((n) => n.note);
|
||||
const verbesserbar = positivNoten.filter((n) => !abschliessend.includes(String(n)));
|
||||
|
||||
expect(negativNoten.length, "ohne negative Note ist keine Antrittskette möglich").to.be.greaterThan(0);
|
||||
|
||||
ctx.noten = {
|
||||
// the only Note that allows a repeat
|
||||
negativ: negativNoten[0],
|
||||
// positive but not final: allows a repeat only with CIS_GESAMTNOTE_NOTENVERBESSERUNG
|
||||
positiv: verbesserbar.length ? verbesserbar[0] : null,
|
||||
// always closes the chain (NOTEN_ABSCHLIESSEND)
|
||||
bestnote: positivNoten.find((n) => abschliessend.includes(String(n))) || null,
|
||||
entschuldigt: entschuldigt.note,
|
||||
nochNichtEingetragen: nochNicht.note,
|
||||
notLehre: notLehre ? notLehre.note : null,
|
||||
notUeberschreibbar: notUeberschreibbar ? notUeberschreibbar.note : null,
|
||||
angerechnet: angerechnet ? angerechnet.note : null,
|
||||
internAngerechnet: internAngerechnet ? internAngerechnet.note : null,
|
||||
};
|
||||
|
||||
// the UI specs select and read a Note by its Bezeichnung, not by its id
|
||||
ctx.notenOptions = noten;
|
||||
|
||||
return notenApi.getStudentenNoten(ctx.lvId, ctx.semKurzbz);
|
||||
})
|
||||
.then((response) => {
|
||||
const data = expectNotenSuccess(response, `getStudentenNoten(${ctx.lvId})`);
|
||||
const students = data.students;
|
||||
|
||||
expect(
|
||||
students.length,
|
||||
`LV ${ctx.lvId} hat ${students.length} Studierende, die Suite braucht ${MIN_STUDENTS}. Starte: npm run noten:check`,
|
||||
).to.be.at.least(MIN_STUDENTS);
|
||||
students.forEach((s) => expect(s.lehreinheit_id, `lehreinheit_id von ${s.uid}`).to.exist);
|
||||
|
||||
ctx.students = students;
|
||||
ctx.studentUids = students.map((s) => s.uid);
|
||||
|
||||
cachedCtx = ctx;
|
||||
return ctx;
|
||||
});
|
||||
};
|
||||
|
||||
// --- database ---
|
||||
|
||||
let dbConnection = null;
|
||||
|
||||
/** Fails the test with the reason if the suite cannot reach the database. Call it in before or beforeEach. */
|
||||
export const requireDbReset = () => {
|
||||
const status = dbConnection
|
||||
? cy.wrap(dbConnection, { log: false })
|
||||
: cy.task("noten:db:checkConnection", null, { log: false }).then((result) => (dbConnection = result));
|
||||
|
||||
return status.then((result) => {
|
||||
expect(
|
||||
result.available,
|
||||
`Fixture-Reset nicht verfügbar: ${result.reason}\n\n` +
|
||||
"Die Suite braucht eine Datenbankverbindung: NOTEN_DB_* in tests/cypress/suites/.env.",
|
||||
).to.be.true;
|
||||
});
|
||||
};
|
||||
|
||||
let cachedAuthUid = null;
|
||||
|
||||
/**
|
||||
* The uid of NOTEN_LEKTOR_USER as the database stores it, for mitarbeiter_uid / freigabevon_uid.
|
||||
* Not Cypress.env("NOTEN_LEKTOR_USER"): LDAP accepts "Demolektor1", but tbl_benutzer.uid is
|
||||
* "demolektor1", and the foreign keys are case sensitive.
|
||||
*/
|
||||
export const readAuthUid = () => {
|
||||
if (cachedAuthUid) return cy.wrap(cachedAuthUid, { log: false });
|
||||
|
||||
return cy
|
||||
.request({
|
||||
method: "GET",
|
||||
url: "/index.ci.php/api/frontend/v1/AuthInfo/getAuthUID",
|
||||
auth: lektorAuth(),
|
||||
failOnStatusCode: false,
|
||||
})
|
||||
.then((response) => {
|
||||
const uid = response.body?.data?.uid;
|
||||
expect(uid, "uid von AuthInfo/getAuthUID").to.be.a("string").and.not.be.empty;
|
||||
cachedAuthUid = uid;
|
||||
return uid;
|
||||
});
|
||||
};
|
||||
|
||||
/** Runs a seed task in the test LV, with NOTEN_LEKTOR_USER as mitarbeiter_uid. */
|
||||
const seedTask = (task, ctx, studentUid, payload) =>
|
||||
readAuthUid().then((uid) =>
|
||||
cy.task(task, { lvId: ctx.lvId, semKurzbz: ctx.semKurzbz, studentUid, mitarbeiterUid: uid, ...payload }),
|
||||
);
|
||||
|
||||
/** Deletes the LV-Noten, Pruefungen and Zeugnisnoten of the students in the test LV. */
|
||||
export const resetNotenState = (ctx, studentUids) =>
|
||||
cy.task("noten:db:reset", {
|
||||
lvId: ctx.lvId,
|
||||
semKurzbz: ctx.semKurzbz,
|
||||
studentUids: studentUids || ctx.studentUids,
|
||||
});
|
||||
|
||||
/**
|
||||
* The baseline for Antritt 1: an LV-Note PLUS its Pruefung row, the same as a Freigabe creates.
|
||||
* Without that row the LV-Note counts only while no Pruefung exists, and the next added Pruefung
|
||||
* would replace Antritt 1 instead of becoming Antritt 2.
|
||||
*
|
||||
* Options: note, punkte, benotungsdatum, freigegeben, freigabedatum, erstantritt.
|
||||
* `erstantritt: false` leaves out the Pruefung row: the LV-Note then has no Antritt 1 of its own,
|
||||
* which is the state of an old Note and of a Note before its Freigabe.
|
||||
*/
|
||||
export const seedBaseline = (ctx, student, options = {}) => {
|
||||
expect(student, "seedBaseline braucht das Studierenden-Objekt aus ctx.students, nicht die uid").to.be.an("object");
|
||||
|
||||
// A NEGATIVE Note by default: only a negative Note allows another Antritt.
|
||||
// Pass ctx.noten.bestnote to close the chain.
|
||||
const note = options.note !== undefined ? options.note : ctx.noten.negativ;
|
||||
// With CIS_GESAMTNOTE_FREIGABE_FINAL a freigegeben Note is final. So the baseline leaves the Note
|
||||
// open by default, and a test can still add Pruefungen.
|
||||
const freigegeben =
|
||||
options.freigegeben !== undefined ? options.freigegeben : ctx.cisConfig.CIS_GESAMTNOTE_FREIGABE_FINAL !== true;
|
||||
const erstantritt = options.erstantritt !== undefined ? options.erstantritt : true;
|
||||
|
||||
return seedTask("noten:db:seedLvGesamtnote", ctx, student.uid, {
|
||||
note,
|
||||
punkte: options.punkte !== undefined ? options.punkte : null,
|
||||
benotungsdatum: options.benotungsdatum || baselineBenotungsdatum(ctx),
|
||||
freigegeben,
|
||||
freigabedatum: options.freigabedatum || null,
|
||||
}).then((seeded) => {
|
||||
if (!erstantritt) return cy.wrap(seeded, { log: false });
|
||||
|
||||
return seedTask("noten:db:seedPruefung", ctx, student.uid, {
|
||||
lehreinheitId: student.lehreinheit_id,
|
||||
note,
|
||||
datum: baselineDate(ctx),
|
||||
type: "Termin1", // legacy Pruefungstyp of Antritt 1; no rule reads it
|
||||
}).then(() => seeded);
|
||||
});
|
||||
};
|
||||
|
||||
/** -> { pruefungId, note, datum, type }. For Pruefung states that the API cannot build. */
|
||||
export const seedPruefung = (ctx, student, { note, datum, type }) =>
|
||||
seedTask("noten:db:seedPruefung", ctx, student.uid, {
|
||||
lehreinheitId: student.lehreinheit_id,
|
||||
note,
|
||||
datum,
|
||||
type,
|
||||
});
|
||||
|
||||
/** Sets the Zeugnisnote. Only the StV enters it; the Benotungstool never writes it. */
|
||||
export const seedZeugnisnote = (ctx, student, note) => seedTask("noten:db:seedZeugnisnote", ctx, student.uid, { note });
|
||||
|
||||
/**
|
||||
* Reads the LV-Note from the database: what is STORED, without the freigabedatum filter
|
||||
* of getLvGesamtNoten. The counterpart is readStateViaApi.
|
||||
*/
|
||||
export const readLvGesamtnoteViaDb = (ctx, student) =>
|
||||
cy.task("noten:db:readLvGesamtnote", { lvId: ctx.lvId, semKurzbz: ctx.semKurzbz, studentUid: student.uid });
|
||||
|
||||
/** The anchor date. Every Antritt date comes after it, so the order is known. */
|
||||
export const baselineBenotungsdatum = (ctx) => `${Number(ctx.semKurzbz.slice(2, 6))}-01-10 08:00:00`;
|
||||
|
||||
export const baselineDate = (ctx) => baselineBenotungsdatum(ctx).slice(0, 10);
|
||||
|
||||
/** antrittDate(ctx, 1) < antrittDate(ctx, 2) < ..., all after the baseline, 30 days apart. */
|
||||
export const antrittDate = (ctx, index) => shiftDate(baselineDate(ctx), 30 * index);
|
||||
@@ -0,0 +1,406 @@
|
||||
import { waitForOk } from "../helpers/network";
|
||||
|
||||
/**
|
||||
* Page object for the Benotungstool page.
|
||||
*
|
||||
* Selectors are the data-cy attributes from Benotungstool.js. The table is a Tabulator table: a row has
|
||||
* data-cy='student-row-<uid>', a cell has the attribute tabulator-field, and the Freigabe state is the
|
||||
* data-state attribute of the cell content, not the icon.
|
||||
*
|
||||
* After each write action a method waits for its request (waitForOk), not for the DOM. Otherwise a spec
|
||||
* checks the state before the response. The submit* methods do not wait: a test uses them to check
|
||||
* that the server rejects the input.
|
||||
*/
|
||||
|
||||
const TABLE_TIMEOUT = 60_000;
|
||||
const API = "**/api/frontend/v1/Noten";
|
||||
|
||||
/**
|
||||
* Exact text match for option lists. The Bezeichnungen contain each other:
|
||||
* contains("Gut") finds "Sehr Gut" first, and contains("Genügend") finds "Nicht Genügend" first.
|
||||
*/
|
||||
const exactText = (text) => new RegExp(`^\\s*${String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`);
|
||||
|
||||
class BenotungstoolPage {
|
||||
selectors = {
|
||||
table: "[data-cy='benotungstool-table']",
|
||||
row: "[data-cy^='student-row-']",
|
||||
};
|
||||
|
||||
setupIntercepts = () => {
|
||||
cy.intercept({ method: "GET", url: `${API}/getStudentenNoten*` }).as("getStudentenNoten");
|
||||
cy.intercept({ method: "GET", url: `${API}/getCisConfig*` }).as("getCisConfig");
|
||||
cy.intercept({ method: "POST", url: `${API}/saveLvNote` }).as("saveLvNote");
|
||||
cy.intercept({ method: "POST", url: `${API}/importLvNoten` }).as("importLvNoten");
|
||||
cy.intercept({ method: "POST", url: `${API}/savePruefung` }).as("savePruefung");
|
||||
cy.intercept({ method: "POST", url: `${API}/createPruefungen` }).as("createPruefungen");
|
||||
cy.intercept({ method: "POST", url: `${API}/importPruefungen` }).as("importPruefungen");
|
||||
cy.intercept({ method: "POST", url: `${API}/saveFreigabe` }).as("saveFreigabe");
|
||||
cy.intercept({ method: "POST", url: `${API}/getNoteByPunkte` }).as("getNoteByPunkte");
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens the page by deep link to semester and LV, without the four dropdowns.
|
||||
* The column layout goes into localStorage in onBeforeLoad: the component reads it at start,
|
||||
* and before the first visit localStorage belongs to about:blank.
|
||||
*/
|
||||
visit = (ctx, { columns = "antritt" } = {}) =>
|
||||
cy.visit(`/cis.php/Cis/Benotungstool/${ctx.semKurzbz}/${ctx.lvId}`, {
|
||||
onBeforeLoad(win) {
|
||||
win.localStorage.setItem("notenToolPruefungsspalten", JSON.stringify(columns));
|
||||
win.localStorage.removeItem("notenToolStickyCols");
|
||||
},
|
||||
});
|
||||
|
||||
/** Needs the session of loginAsLektor() from the beforeEach of the spec. */
|
||||
visitAndWaitForTable = (ctx, options) => {
|
||||
this.setupIntercepts();
|
||||
this.visit(ctx, options);
|
||||
|
||||
waitForOk("@getStudentenNoten");
|
||||
this.getTable().should("be.visible");
|
||||
this.getRows().should("have.length.greaterThan", 0);
|
||||
};
|
||||
|
||||
// --- Elements --------------------------------------------------------------------------------
|
||||
|
||||
getTable = () => cy.get(this.selectors.table, { timeout: TABLE_TIMEOUT });
|
||||
getRows = () => cy.get(this.selectors.row, { timeout: TABLE_TIMEOUT });
|
||||
getRow = (uid) => cy.get(`[data-cy='student-row-${uid}']`, { timeout: TABLE_TIMEOUT });
|
||||
getCell = (uid, field) => this.getRow(uid).find(`[tabulator-field='${field}']`);
|
||||
|
||||
getFreigabeState = (uid) => this.getCell(uid, "freigabe_state").find("[data-cy='freigabe-state']");
|
||||
getPruefungCell = (uid, column) => this.getCell(uid, column).find("[data-cy='pruefung-cell']");
|
||||
getApplyProposalButton = (uid) => this.getCell(uid, "apply_proposal").find("[data-cy='btn-apply-proposal']");
|
||||
getPruefungAddButton = (uid, column) => this.getCell(uid, column).find("[data-cy='btn-pruefung-add']");
|
||||
getPruefungEditButton = (uid, column) => this.getCell(uid, column).find("[data-cy='btn-pruefung-edit']");
|
||||
getBestandenHint = (uid, column) => this.getCell(uid, column).find("[data-cy='pruefung-bestanden']");
|
||||
getRowCheckbox = (uid) => this.getCell(uid, "selectCol").find("input");
|
||||
|
||||
getPruefungModal = () => cy.get("[data-cy='modal-pruefung']");
|
||||
getFreigabeModal = () => cy.get("[data-cy='modal-freigabe']");
|
||||
getApplyProposalModal = () => cy.get("[data-cy='modal-apply-proposal']");
|
||||
getLvNotenImportModal = () => cy.get("[data-cy='modal-lvnoten-import']");
|
||||
getPruefungImportModal = () => cy.get("[data-cy='modal-pruefung-import']");
|
||||
getFreigabeSummaryRow = (uid) => cy.get(`[data-cy='freigabe-row-${uid}']`);
|
||||
|
||||
// --- Assertions ------------------------------------------------------------------------------
|
||||
|
||||
/** open | changed | freigegeben */
|
||||
expectFreigabeState = (uid, state) => this.getFreigabeState(uid).should("have.attr", "data-state", state);
|
||||
|
||||
expectLvNote = (uid, bezeichnung) => this.getCell(uid, "lv_note").should("contain.text", bezeichnung);
|
||||
|
||||
expectProposal = (uid, bezeichnung) => this.getCell(uid, "proposed_note").should("contain.text", bezeichnung);
|
||||
|
||||
/** The column shows verlauf.antrittCount, a 0 included. */
|
||||
expectAntrittCount = (uid, count) =>
|
||||
this.getCell(uid, "verlauf.antrittCount").invoke("text").invoke("trim").should("eq", String(count));
|
||||
|
||||
expectPruefung = (uid, column, { note, antritt } = {}) => {
|
||||
// query again for each assertion, so Cypress retries after a re-render of the cell
|
||||
if (note !== undefined) {
|
||||
this.getPruefungCell(uid, column).should("have.attr", "data-note", String(note));
|
||||
}
|
||||
if (antritt !== undefined) {
|
||||
this.getPruefungCell(uid, column).should("have.attr", "data-antritt", String(antritt));
|
||||
}
|
||||
};
|
||||
|
||||
expectNoPruefung = (uid, column) => this.getCell(uid, column).find("[data-cy='pruefung-cell']").should("not.exist");
|
||||
|
||||
expectNoAntrittColumn = (nr) => cy.get(`[tabulator-field='antritt_${nr}']`).should("not.exist");
|
||||
|
||||
/** The table holds the selection: the row class and the checkbox in the first column. */
|
||||
expectRowSelected = (uid, selected) => {
|
||||
this.getRow(uid).should(selected ? "have.class" : "not.have.class", "tabulator-selected");
|
||||
this.getRowCheckbox(uid).should(selected ? "be.checked" : "not.be.checked");
|
||||
};
|
||||
|
||||
/**
|
||||
* The server rejects the request, and a toast shows its message. The message comes from the
|
||||
* response, so the test needs no phrase.
|
||||
*/
|
||||
expectRejected = (alias) =>
|
||||
cy.wait(alias).then(({ response }) => {
|
||||
expect(response.statusCode, `${alias} lehnt ab`).to.not.eq(200);
|
||||
const errorMessage = (response.body?.errors ?? [])[0]?.message;
|
||||
expect(errorMessage, `Meldung von ${alias}`).to.be.a("string").and.not.be.empty;
|
||||
cy.get(".p-toast-message", { timeout: TABLE_TIMEOUT }).should("contain.text", errorMessage);
|
||||
});
|
||||
|
||||
/** A bulk endpoint rejects one row: HTTP 200, the message is in data[uid].error and in the toast. */
|
||||
expectRowRejected = (alias, uid) =>
|
||||
cy.wait(alias).then(({ response }) => {
|
||||
expect(response.statusCode, `${alias} antwortet`).to.eq(200);
|
||||
const errorMessage = response.body?.data?.[uid]?.error?.message;
|
||||
expect(errorMessage, `Ablehnung der Zeile ${uid}`).to.be.a("string").and.not.be.empty;
|
||||
cy.get(".p-toast-message", { timeout: TABLE_TIMEOUT }).should("contain.text", errorMessage);
|
||||
});
|
||||
|
||||
/** After a fully rejected action the page shows no success toast. */
|
||||
expectNoSuccess = () => cy.get(".p-toast-message-success").should("not.exist");
|
||||
|
||||
/** A warning from the client. Warnings stay open until closed. */
|
||||
expectWarning = (text) => cy.get(".p-toast-message-warn", { timeout: TABLE_TIMEOUT }).should("contain.text", text);
|
||||
|
||||
expectWarnings = (minCount) =>
|
||||
cy.get(".p-toast-message-warn", { timeout: TABLE_TIMEOUT }).should("have.length.at.least", minCount);
|
||||
|
||||
/** The uids that the last bulk request sent. bodyField: "lv_noten" or "pruefungen". */
|
||||
sentUids = (alias, bodyField) =>
|
||||
cy
|
||||
.get(alias)
|
||||
.its(`request.body.${bodyField}`)
|
||||
.then((sentRows) => sentRows.map((z) => z.uid));
|
||||
|
||||
// --- Proposal --------------------------------------------------------------------------------
|
||||
|
||||
/** Opens the list editor of the proposal column and selects the Bezeichnung. */
|
||||
setProposal = (uid, bezeichnung) => {
|
||||
this.getCell(uid, "proposed_note").click();
|
||||
cy.get(".tabulator-edit-list-item").contains(bezeichnung).click();
|
||||
};
|
||||
|
||||
/** The apply button first asks for the Benotungsdatum. Without `datum` the dialog keeps its default. */
|
||||
submitApplyProposal = (uid, { datum } = {}) => {
|
||||
this.closeToasts();
|
||||
this.getApplyProposalButton(uid).click();
|
||||
this.getApplyProposalModal().should("be.visible");
|
||||
|
||||
if (datum) this.setDate("apply-proposal-datum", datum);
|
||||
|
||||
cy.get("[data-cy='apply-proposal-submit']").click();
|
||||
};
|
||||
|
||||
applyProposal = (uid, options) => {
|
||||
this.submitApplyProposal(uid, options);
|
||||
waitForOk("@saveLvNote");
|
||||
this.getApplyProposalModal().should("not.be.visible");
|
||||
};
|
||||
|
||||
// --- Pruefungen ------------------------------------------------------------------------------
|
||||
|
||||
/** PrimeVue attaches its panels to `body`, so do not search inside the modal. */
|
||||
selectDropdownOption = (dataCy, label) => {
|
||||
cy.get(`[data-cy='${dataCy}']`).click();
|
||||
cy.contains(".p-dropdown-panel .p-dropdown-item", exactText(label)).click();
|
||||
};
|
||||
|
||||
setDate = (dataCy, ddmmyyyy) => cy.get(`[data-cy='${dataCy}'] input`).first().clear().type(`${ddmmyyyy}{enter}`);
|
||||
|
||||
/**
|
||||
* After each save the page shows a success toast that stays open. It lies above the modal
|
||||
* (z-index 100001) and covers its buttons, so the test closes it with its close button.
|
||||
*/
|
||||
closeToasts = () =>
|
||||
cy
|
||||
.get("body")
|
||||
.then(($body) => {
|
||||
const buttons = $body.find(".p-toast-message .p-toast-icon-close");
|
||||
if (buttons.length) cy.wrap(buttons).click({ multiple: true });
|
||||
})
|
||||
.then(() => cy.get(".p-toast-message").should("not.exist"));
|
||||
|
||||
/** The dialog of a table cell: a new Pruefung for ONE student. */
|
||||
submitPruefungInCell = (uid, column, { note, datum } = {}) => {
|
||||
this.closeToasts();
|
||||
this.getPruefungAddButton(uid, column).click();
|
||||
this.getPruefungModal().should("be.visible");
|
||||
|
||||
if (datum) this.setDate("pruefung-datum", datum);
|
||||
if (note) this.selectDropdownOption("pruefung-note", note);
|
||||
|
||||
cy.get("[data-cy='pruefung-submit']").click();
|
||||
};
|
||||
|
||||
addPruefungInCell = (uid, column, options) => {
|
||||
this.submitPruefungInCell(uid, column, options);
|
||||
waitForOk("@savePruefung");
|
||||
this.getPruefungModal().should("not.be.visible");
|
||||
};
|
||||
|
||||
/** Edits an existing Pruefung. Without `note` the Note stays, for example for a date correction. */
|
||||
editPruefungInCell = (uid, column, { note, datum } = {}) => {
|
||||
this.closeToasts();
|
||||
this.getPruefungEditButton(uid, column).click();
|
||||
this.getPruefungModal().should("be.visible");
|
||||
|
||||
if (datum) this.setDate("pruefung-datum", datum);
|
||||
if (note) this.selectDropdownOption("pruefung-note", note);
|
||||
|
||||
cy.get("[data-cy='pruefung-submit']").click();
|
||||
waitForOk("@savePruefung");
|
||||
this.getPruefungModal().should("not.be.visible");
|
||||
};
|
||||
|
||||
openPruefungModalForEdit = (uid, column) => {
|
||||
this.closeToasts();
|
||||
this.getPruefungEditButton(uid, column).click();
|
||||
this.getPruefungModal().should("be.visible");
|
||||
};
|
||||
|
||||
/** Opens the Pruefung dialog for the selection ("new Pruefung"). */
|
||||
openCreatePruefungen = () => {
|
||||
this.closeToasts();
|
||||
cy.get("[data-cy='btn-new-pruefung']").click();
|
||||
this.getPruefungModal().should("be.visible");
|
||||
};
|
||||
|
||||
/** Clicks the students in the multiselect of the dialog. A click selects or deselects the row in the table. */
|
||||
toggleStudentsInDialog = (uids) => {
|
||||
cy.get("[data-cy='pruefung-students']").click();
|
||||
// the label is "uid – Nachname Vorname – Antritte: n"
|
||||
uids.forEach((uid) => cy.contains(".p-multiselect-panel .p-multiselect-item", uid).click());
|
||||
|
||||
// Do NOT press Escape: it also closes the Bootstrap modal. The open panel can cover the trigger,
|
||||
// so use the close button of the panel.
|
||||
cy.get(".p-multiselect-panel .p-multiselect-close").click();
|
||||
cy.get(".p-multiselect-panel").should("not.exist");
|
||||
};
|
||||
|
||||
/** The multiselect shows the selected students by their label, which starts with the uid. */
|
||||
expectStudentsInDialog = (uids) =>
|
||||
uids.forEach((uid) => cy.get("[data-cy='pruefung-students']").should("contain.text", uid));
|
||||
|
||||
/** Sends the Pruefung dialog for several students ("new Pruefung"). Does not wait for the response. */
|
||||
submitCreatePruefungen = ({ uids, note, punkte, datum }) => {
|
||||
this.openCreatePruefungen();
|
||||
|
||||
if (datum) this.setDate("pruefung-datum", datum);
|
||||
if (note) this.selectDropdownOption("pruefung-note", note);
|
||||
if (punkte !== undefined) this.setPruefungPunkte(punkte);
|
||||
|
||||
this.toggleStudentsInDialog(uids);
|
||||
|
||||
cy.get("[data-cy='pruefung-submit']").click();
|
||||
};
|
||||
|
||||
/** Adds a Pruefung for several students. */
|
||||
createPruefungen = (options) => {
|
||||
this.submitCreatePruefungen(options);
|
||||
waitForOk("@createPruefungen");
|
||||
this.getPruefungModal().should("not.be.visible");
|
||||
};
|
||||
|
||||
// --- Freigabe --------------------------------------------------------------------------------
|
||||
|
||||
openFreigabeModal = () => {
|
||||
cy.get("[data-cy='btn-freigabe']").click();
|
||||
this.getFreigabeModal().should("be.visible");
|
||||
};
|
||||
|
||||
expectFreigabeSummaryRow = (uid, lvNoteBezeichnung) =>
|
||||
this.getFreigabeSummaryRow(uid)
|
||||
.find("[data-cy='freigabe-row-lvnote']")
|
||||
.should("contain.text", lvNoteBezeichnung);
|
||||
|
||||
/** Without a changed LV-Note the dialog shows a hint, and its button stays disabled. */
|
||||
expectFreigabeEmpty = () => {
|
||||
cy.get("[data-cy='freigabe-summary-empty']").should("be.visible");
|
||||
cy.get("[data-cy='freigabe-submit']").should("be.disabled");
|
||||
};
|
||||
|
||||
typeFreigabePassword = (password) => cy.get("[data-cy='freigabe-password'] input").type(password, { log: false });
|
||||
|
||||
submitFreigabe = () => cy.get("[data-cy='freigabe-submit']").click();
|
||||
|
||||
saveFreigabe = (password) => {
|
||||
this.typeFreigabePassword(password);
|
||||
this.submitFreigabe();
|
||||
waitForOk("@saveFreigabe");
|
||||
this.getFreigabeModal().should("not.be.visible");
|
||||
};
|
||||
|
||||
// --- Import ----------------------------------------------------------------------------------
|
||||
|
||||
/** rows: [[uid, note], ...] -> "uid<TAB>note" per row */
|
||||
submitLvNotenImport = (rows) => {
|
||||
this.closeToasts();
|
||||
cy.get("[data-cy='btn-lvnoten-import']").click();
|
||||
this.getLvNotenImportModal().should("be.visible");
|
||||
|
||||
cy.get("[data-cy='lvnoten-import-text']").type(rows.map((r) => r.join("\t")).join("\n"));
|
||||
cy.get("[data-cy='lvnoten-import-submit']").click();
|
||||
};
|
||||
|
||||
importLvNoten = (rows) => {
|
||||
this.submitLvNotenImport(rows);
|
||||
waitForOk("@importLvNoten");
|
||||
this.getLvNotenImportModal().should("not.be.visible");
|
||||
};
|
||||
|
||||
/** rows: [[uid, "dd.MM.yyyy", note], ...] */
|
||||
submitPruefungImport = (rows) => {
|
||||
this.closeToasts();
|
||||
cy.get("[data-cy='btn-pruefung-import']").click();
|
||||
this.getPruefungImportModal().should("be.visible");
|
||||
|
||||
cy.get("[data-cy='pruefung-import-text']").type(rows.map((r) => r.join("\t")).join("\n"));
|
||||
cy.get("[data-cy='pruefung-import-submit']").click();
|
||||
};
|
||||
|
||||
importPruefungen = (rows) => {
|
||||
this.submitPruefungImport(rows);
|
||||
waitForOk("@importPruefungen");
|
||||
this.getPruefungImportModal().should("not.be.visible");
|
||||
};
|
||||
|
||||
// --- Punkte mode ------------------------------------------------------------------------------
|
||||
// The Punkte column and the Punkte field of the Pruefung dialog exist only with CIS_GESAMTNOTE_PUNKTE.
|
||||
// The Note then comes from the Notenschluessel; nobody selects it.
|
||||
|
||||
getPunkteCell = (uid) => this.getCell(uid, "proposed_punkte");
|
||||
|
||||
/**
|
||||
* Types Punkte into the cell. The client then gets the Note through getNoteByPunkte (debounced).
|
||||
*
|
||||
* Uses cy.focused(): the liveNumberEditor focuses its input in onRendered, and the click formats
|
||||
* the row again (cellClick -> undoSelection), so a second query through the cell can find nothing.
|
||||
*/
|
||||
setPunkteInCell = (uid, punkte) => {
|
||||
this.getPunkteCell(uid).click();
|
||||
|
||||
// Enter commits the value (success() in the editor). The render after getNoteByPunkte
|
||||
// closes the editor, so a blur after that would find nothing.
|
||||
cy.focused().should("have.attr", "type", "number").clear().type(`${punkte}{enter}`);
|
||||
waitForOk("@getNoteByPunkte");
|
||||
};
|
||||
|
||||
expectPunkte = (uid, punkte) => this.getPunkteCell(uid).should("contain.text", String(punkte));
|
||||
|
||||
/** After a repeat the Punkte column is locked: the Note belongs to the Pruefung. */
|
||||
expectPunkteCellLocked = (uid) => {
|
||||
this.getPunkteCell(uid).click();
|
||||
this.getPunkteCell(uid).find("input").should("not.exist");
|
||||
};
|
||||
|
||||
/** In the Punkte mode the proposal column is locked: the Note comes from the Punkte. */
|
||||
expectProposalLocked = (uid) => {
|
||||
this.getCell(uid, "proposed_note").click();
|
||||
cy.get(".tabulator-edit-list").should("not.exist");
|
||||
};
|
||||
|
||||
expectNoteFieldLocked = (dataCy) => cy.get(`[data-cy='${dataCy}']`).should("have.class", "p-disabled");
|
||||
|
||||
/** The Punkte field of the Pruefung dialog. It triggers the Note lookup. */
|
||||
setPruefungPunkte = (punkte) => {
|
||||
cy.get("[data-cy='pruefung-punkte'] input").clear().type(String(punkte));
|
||||
waitForOk("@getNoteByPunkte");
|
||||
};
|
||||
|
||||
// --- Helpers ---------------------------------------------------------------------------------
|
||||
|
||||
/** The Bezeichnung of a Note id, as the dropdowns and cells show it. */
|
||||
bezeichnungOf = (ctx, note) => (ctx.notenOptions ?? []).find((n) => String(n.note) === String(note))?.bezeichnung;
|
||||
|
||||
toDDMMYYYY = (isoDate) => {
|
||||
const [y, m, d] = isoDate.split("-");
|
||||
return `${d}.${m}.${y}`;
|
||||
};
|
||||
|
||||
/** Date in the configured import format; kept apart from toDDMMYYYY, which the dialog uses. */
|
||||
importDate = (isoDate, format) => (format === "yyyy-MM-dd" ? isoDate : this.toDDMMYYYY(isoDate));
|
||||
}
|
||||
|
||||
export const benotungstoolPage = new BenotungstoolPage();
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* The Postgres connection for the cy.task functions: pool, write guard, teardown.
|
||||
* What the suite deletes or seeds is in notenDb.js. Settings: NOTEN_DB_* in tests/cypress/suites/.env.
|
||||
*/
|
||||
|
||||
const env = (key) => process.env[`NOTEN_DB_${key}`];
|
||||
|
||||
const dbConfigured = () => Boolean(env("HOST") && env("NAME") && env("USER"));
|
||||
const writesAllowed = () => String(env("ALLOW_WRITES")).toLowerCase() === "true";
|
||||
|
||||
let pool = null;
|
||||
|
||||
const getPool = () => {
|
||||
if (pool) return pool;
|
||||
|
||||
// lazy require so the suite loads without pg when no DB task is used
|
||||
const { Pool } = require("pg");
|
||||
|
||||
pool = new Pool({
|
||||
host: env("HOST"),
|
||||
port: Number(env("PORT") || 5432),
|
||||
database: env("NAME"),
|
||||
user: env("USER"),
|
||||
password: env("PASSWORD"),
|
||||
max: 4,
|
||||
// tells these connections apart from the web application in pg_stat_activity
|
||||
application_name: "cypress",
|
||||
connectionTimeoutMillis: 15000,
|
||||
// the server ends a stuck statement or transaction before the 60 s task timeout, so no lock wait holds a connection
|
||||
statement_timeout: 30000,
|
||||
idle_in_transaction_session_timeout: 30000,
|
||||
ssl: String(env("SSL")).toLowerCase() === "true" ? { rejectUnauthorized: false } : undefined,
|
||||
});
|
||||
// an idle client that loses its connection emits here; without a listener the config process crashes
|
||||
pool.on("error", (error) => console.warn(`[db] idle connection lost: ${error.message}`));
|
||||
return pool;
|
||||
};
|
||||
|
||||
const assertWritable = () => {
|
||||
if (!dbConfigured()) {
|
||||
throw new Error("db refused - set NOTEN_DB_HOST / NOTEN_DB_NAME / NOTEN_DB_USER.");
|
||||
}
|
||||
if (!writesAllowed()) {
|
||||
throw new Error("db refused - set NOTEN_DB_ALLOW_WRITES=true (test databases only).");
|
||||
}
|
||||
};
|
||||
|
||||
const withClient = async (fn) => {
|
||||
const client = await getPool().connect();
|
||||
try {
|
||||
return await fn(client);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
const inTransaction = (fn) =>
|
||||
withClient(async (client) => {
|
||||
await client.query("BEGIN");
|
||||
try {
|
||||
const result = await fn(client);
|
||||
await client.query("COMMIT");
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
const explainFailure = (error) => {
|
||||
const msg = String(error && error.message ? error.message : error);
|
||||
|
||||
if (/no pg_hba\.conf entry/i.test(msg)) {
|
||||
const host = (msg.match(/for host "([^"]+)"/) || [])[1] || "this machine";
|
||||
return (
|
||||
`the server refused the connection from ${host} - pg_hba.conf has no rule allowing it. ` +
|
||||
`Run from a host the database admits, or have an entry added. Details: ${msg}`
|
||||
);
|
||||
}
|
||||
if (/authentication failed/i.test(msg)) return `credentials rejected. Details: ${msg}`;
|
||||
if (/database .* does not exist/i.test(msg)) return `NOTEN_DB_NAME does not exist. Details: ${msg}`;
|
||||
if (/ECONNREFUSED/i.test(msg)) return `nothing listening on NOTEN_DB_HOST:NOTEN_DB_PORT. Details: ${msg}`;
|
||||
if (/ETIMEDOUT|EHOSTUNREACH|ENETUNREACH/i.test(msg)) return `host unreachable (firewall/VPN?). Details: ${msg}`;
|
||||
return msg;
|
||||
};
|
||||
|
||||
/** Proves the connection works. */
|
||||
const checkConnection = async ({ requireWrites = true } = {}) => {
|
||||
if (!dbConfigured()) return { available: false, reason: "not-configured" };
|
||||
if (requireWrites && !writesAllowed()) return { available: false, reason: "writes-disabled" };
|
||||
|
||||
try {
|
||||
await withClient((client) => client.query("SELECT 1"));
|
||||
return { available: true };
|
||||
} catch (error) {
|
||||
// a failed pool is poisoned - drop it so a retry reconnects cleanly
|
||||
if (pool) {
|
||||
const failed = pool;
|
||||
pool = null;
|
||||
try {
|
||||
await failed.end();
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return { available: false, reason: explainFailure(error) };
|
||||
}
|
||||
};
|
||||
|
||||
const closeDb = async () => {
|
||||
// drop the reference first: a later task must not get a pool that is ending
|
||||
const open = pool;
|
||||
pool = null;
|
||||
if (open) await open.end();
|
||||
return null;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
env,
|
||||
dbConfigured,
|
||||
writesAllowed,
|
||||
assertWritable,
|
||||
withClient,
|
||||
inTransaction,
|
||||
checkConnection,
|
||||
closeDb,
|
||||
};
|
||||
@@ -0,0 +1,423 @@
|
||||
/**
|
||||
* The cy.task functions of the noten suite. Connection and guards come from
|
||||
* db.js; this file only knows which rows the specs write and how to undo them.
|
||||
*
|
||||
* Needed because the Noten API has no delete endpoint, while every test writes
|
||||
* campus.tbl_lvgesamtnote and lehre.tbl_pruefung. Without a reset the suite is single-shot.
|
||||
*
|
||||
* Every statement is scoped by an explicit student_uid list plus the test LV and semester.
|
||||
* No unscoped DELETE in this file.
|
||||
*/
|
||||
|
||||
const { assertWritable, dbConfigured, withClient, inTransaction, checkConnection } = require("./db");
|
||||
|
||||
const REQUIRED_SCOPE = ["lvId", "semKurzbz", "studentUids"];
|
||||
|
||||
const assertScope = (scope, keys = REQUIRED_SCOPE) => {
|
||||
if (!scope || typeof scope !== "object") throw new Error("noten:db scope object is required");
|
||||
|
||||
keys.forEach((key) => {
|
||||
if (scope[key] === undefined || scope[key] === null || scope[key] === "") {
|
||||
throw new Error(`noten:db refused - missing scope key "${key}"`);
|
||||
}
|
||||
});
|
||||
|
||||
if (!keys.includes("studentUids")) return;
|
||||
|
||||
if (!Array.isArray(scope.studentUids) || scope.studentUids.length === 0) {
|
||||
throw new Error("noten:db refused - studentUids must be a non-empty array");
|
||||
}
|
||||
if (scope.studentUids.some((uid) => typeof uid !== "string" || uid.trim() === "")) {
|
||||
throw new Error("noten:db refused - studentUids must all be non-empty strings");
|
||||
}
|
||||
};
|
||||
|
||||
const reset = async (scope) => {
|
||||
assertWritable();
|
||||
assertScope(scope);
|
||||
|
||||
const { lvId, semKurzbz, studentUids } = scope;
|
||||
|
||||
return inTransaction(async (client) => {
|
||||
// pruefungen hang off lehreinheiten, so scope through the LV's lehreinheiten in this semester
|
||||
const pruefungen = await client.query(
|
||||
`DELETE FROM lehre.tbl_pruefung
|
||||
WHERE student_uid = ANY($1::varchar[])
|
||||
AND lehreinheit_id IN (
|
||||
SELECT lehreinheit_id FROM lehre.tbl_lehreinheit
|
||||
WHERE lehrveranstaltung_id = $2 AND studiensemester_kurzbz = $3)`,
|
||||
[studentUids, lvId, semKurzbz],
|
||||
);
|
||||
|
||||
const noten = await client.query(
|
||||
`DELETE FROM campus.tbl_lvgesamtnote
|
||||
WHERE student_uid = ANY($1::varchar[])
|
||||
AND lehrveranstaltung_id = $2 AND studiensemester_kurzbz = $3`,
|
||||
[studentUids, lvId, semKurzbz],
|
||||
);
|
||||
|
||||
// a seeded Zeugnisnote can lock the LV note (lkt_ueberschreibbar), so the reset owns it too
|
||||
const zeugnisnoten = await client.query(
|
||||
`DELETE FROM lehre.tbl_zeugnisnote
|
||||
WHERE student_uid = ANY($1::varchar[])
|
||||
AND lehrveranstaltung_id = $2 AND studiensemester_kurzbz = $3`,
|
||||
[studentUids, lvId, semKurzbz],
|
||||
);
|
||||
|
||||
return {
|
||||
deletedPruefungen: pruefungen.rowCount,
|
||||
deletedLvGesamtnoten: noten.rowCount,
|
||||
deletedZeugnisnoten: zeugnisnoten.rowCount,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Seeds the LV-Note of the Antritt-1 baseline. `freigegeben` sets the freigabedatum, and the
|
||||
* Freigabe state compares it with the benotungsdatum.
|
||||
*/
|
||||
const seedLvGesamtnote = async (scope) => {
|
||||
assertWritable();
|
||||
assertScope({ ...scope, studentUids: [scope.studentUid] });
|
||||
|
||||
const {
|
||||
lvId,
|
||||
semKurzbz,
|
||||
studentUid,
|
||||
note,
|
||||
punkte = null,
|
||||
mitarbeiterUid,
|
||||
benotungsdatum,
|
||||
freigegeben = false,
|
||||
freigabedatum = null,
|
||||
} = scope;
|
||||
|
||||
if (note === undefined || note === null) throw new Error("noten:db seedLvGesamtnote - note is required");
|
||||
if (!benotungsdatum) throw new Error("noten:db seedLvGesamtnote - benotungsdatum is required");
|
||||
if (!mitarbeiterUid) throw new Error("noten:db seedLvGesamtnote - mitarbeiterUid is required");
|
||||
|
||||
// default the Freigabe to the benotungsdatum: earlier would read as "changed"
|
||||
const resolvedFreigabe = freigegeben ? freigabedatum || benotungsdatum : null;
|
||||
|
||||
return withClient(async (client) => {
|
||||
await client.query(
|
||||
`DELETE FROM campus.tbl_lvgesamtnote
|
||||
WHERE student_uid = $1 AND lehrveranstaltung_id = $2 AND studiensemester_kurzbz = $3`,
|
||||
[studentUid, lvId, semKurzbz],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO campus.tbl_lvgesamtnote
|
||||
(student_uid, lehrveranstaltung_id, studiensemester_kurzbz, note, punkte,
|
||||
mitarbeiter_uid, benotungsdatum, freigabedatum, freigabevon_uid, insertamum, insertvon)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), $6)`,
|
||||
[
|
||||
studentUid,
|
||||
lvId,
|
||||
semKurzbz,
|
||||
note,
|
||||
punkte,
|
||||
mitarbeiterUid,
|
||||
benotungsdatum,
|
||||
resolvedFreigabe,
|
||||
resolvedFreigabe ? mitarbeiterUid : null,
|
||||
],
|
||||
);
|
||||
|
||||
return { studentUid, note, benotungsdatum, freigabedatum: resolvedFreigabe };
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Seeds one Pruefung row, for states that no endpoint can produce: an entschuldigt and a real
|
||||
* Termin2 side by side, or a kommPruef (entered in a different tool).
|
||||
*/
|
||||
const seedPruefung = async (scope) => {
|
||||
assertWritable();
|
||||
assertScope({ ...scope, studentUids: [scope.studentUid] });
|
||||
|
||||
const { lvId, semKurzbz, studentUid, lehreinheitId, note, datum, type, mitarbeiterUid } = scope;
|
||||
|
||||
if (note === undefined || note === null) throw new Error("noten:db seedPruefung - note is required");
|
||||
if (!datum) throw new Error("noten:db seedPruefung - datum is required");
|
||||
if (!type) throw new Error("noten:db seedPruefung - type is required");
|
||||
if (!lehreinheitId) throw new Error("noten:db seedPruefung - lehreinheitId is required");
|
||||
if (!mitarbeiterUid) throw new Error("noten:db seedPruefung - mitarbeiterUid is required");
|
||||
|
||||
return withClient(async (client) => {
|
||||
// reset scopes through the LV's lehreinheiten, so a row outside them would be unreclaimable
|
||||
const owned = await client.query(
|
||||
`SELECT 1 FROM lehre.tbl_lehreinheit
|
||||
WHERE lehreinheit_id = $1 AND lehrveranstaltung_id = $2 AND studiensemester_kurzbz = $3`,
|
||||
[lehreinheitId, lvId, semKurzbz],
|
||||
);
|
||||
if (!owned.rowCount) {
|
||||
throw new Error(
|
||||
`noten:db seedPruefung refused - lehreinheit ${lehreinheitId} is outside ${lvId}/${semKurzbz}`,
|
||||
);
|
||||
}
|
||||
|
||||
const res = await client.query(
|
||||
`INSERT INTO lehre.tbl_pruefung
|
||||
(lehreinheit_id, student_uid, mitarbeiter_uid, note, pruefungstyp_kurzbz, datum,
|
||||
anmerkung, insertamum, insertvon)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, '', NOW(), $3)
|
||||
RETURNING pruefung_id`,
|
||||
[lehreinheitId, studentUid, mitarbeiterUid, note, type, datum],
|
||||
);
|
||||
|
||||
return { pruefungId: res.rows[0].pruefung_id, note, datum, type };
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Seeds the Zeugnisnote. Only the StV writes it; no endpoint of the Benotungstool does.
|
||||
* It decides two rules: an Anrechnung blocks every Pruefung, and lkt_ueberschreibbar locks the LV-Note.
|
||||
*/
|
||||
const seedZeugnisnote = async (scope) => {
|
||||
assertWritable();
|
||||
assertScope({ ...scope, studentUids: [scope.studentUid] });
|
||||
|
||||
const { lvId, semKurzbz, studentUid, note, mitarbeiterUid } = scope;
|
||||
|
||||
if (note === undefined || note === null) throw new Error("noten:db seedZeugnisnote - note is required");
|
||||
if (!mitarbeiterUid) throw new Error("noten:db seedZeugnisnote - mitarbeiterUid is required");
|
||||
|
||||
return withClient(async (client) => {
|
||||
await client.query(
|
||||
`DELETE FROM lehre.tbl_zeugnisnote
|
||||
WHERE student_uid = $1 AND lehrveranstaltung_id = $2 AND studiensemester_kurzbz = $3`,
|
||||
[studentUid, lvId, semKurzbz],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO lehre.tbl_zeugnisnote
|
||||
(student_uid, lehrveranstaltung_id, studiensemester_kurzbz, note, benotungsdatum,
|
||||
insertamum, insertvon)
|
||||
VALUES ($1, $2, $3, $4, NOW(), NOW(), $5)`,
|
||||
[studentUid, lvId, semKurzbz, note, mitarbeiterUid],
|
||||
);
|
||||
|
||||
return { studentUid, note };
|
||||
});
|
||||
};
|
||||
|
||||
/** Raw row, without the `freigabedatum < NOW()` filter getLvGesamtNoten applies. */
|
||||
const readLvGesamtnote = async ({ lvId, semKurzbz, studentUid }) => {
|
||||
if (!dbConfigured()) throw new Error("noten:db refused - database is not configured");
|
||||
|
||||
return withClient(async (client) => {
|
||||
const res = await client.query(
|
||||
`SELECT * FROM campus.tbl_lvgesamtnote
|
||||
WHERE student_uid = $1 AND lehrveranstaltung_id = $2 AND studiensemester_kurzbz = $3`,
|
||||
[studentUid, lvId, semKurzbz],
|
||||
);
|
||||
return res.rows[0] || null;
|
||||
});
|
||||
};
|
||||
|
||||
const LV_SCOPE = ["lvId", "semKurzbz"];
|
||||
|
||||
const assertReadable = () => {
|
||||
if (!dbConfigured()) throw new Error("noten:db refused - database is not configured");
|
||||
};
|
||||
|
||||
const readRows = async (sql, params) => withClient(async (client) => (await client.query(sql, params)).rows);
|
||||
|
||||
/** Same query as Lehrveranstaltung_model::getLeIdsByStudent. Empty = no participant. */
|
||||
const readLehreinheitenOfStudent = async (scope) => {
|
||||
assertReadable();
|
||||
assertScope({ ...scope, studentUids: [scope.studentUid] });
|
||||
|
||||
const rows = await readRows(
|
||||
`SELECT DISTINCT lehreinheit_id FROM campus.vw_student_lehrveranstaltung
|
||||
WHERE uid = $1 AND lehrveranstaltung_id = $2 AND studiensemester_kurzbz = $3
|
||||
ORDER BY lehreinheit_id`,
|
||||
[scope.studentUid, scope.lvId, scope.semKurzbz],
|
||||
);
|
||||
return rows.map((r) => r.lehreinheit_id);
|
||||
};
|
||||
|
||||
/** Same joins as Lehreinheit_model::getLehreinheitenForLv. */
|
||||
const readLehreinheitenOfLv = async (scope) => {
|
||||
assertReadable();
|
||||
assertScope(scope, LV_SCOPE);
|
||||
|
||||
const rows = await readRows(
|
||||
`SELECT DISTINCT le.lehreinheit_id FROM lehre.tbl_lehreinheit le
|
||||
JOIN lehre.tbl_lehreinheitgruppe leg USING (lehreinheit_id)
|
||||
JOIN public.tbl_studiengang stg ON stg.studiengang_kz = leg.studiengang_kz
|
||||
WHERE le.lehrveranstaltung_id = $1 AND le.studiensemester_kurzbz = $2
|
||||
ORDER BY 1`,
|
||||
[scope.lvId, scope.semKurzbz],
|
||||
);
|
||||
return rows.map((r) => r.lehreinheit_id);
|
||||
};
|
||||
|
||||
/** A Lehreinheit of another LV in the same semester, or null. */
|
||||
const readForeignLehreinheit = async (scope) => {
|
||||
assertReadable();
|
||||
assertScope(scope, LV_SCOPE);
|
||||
|
||||
const rows = await readRows(
|
||||
`SELECT lehreinheit_id, lehrveranstaltung_id FROM lehre.tbl_lehreinheit
|
||||
WHERE studiensemester_kurzbz = $1 AND lehrveranstaltung_id <> $2
|
||||
ORDER BY lehreinheit_id LIMIT 1`,
|
||||
[scope.semKurzbz, scope.lvId],
|
||||
);
|
||||
return rows[0] || null;
|
||||
};
|
||||
|
||||
/** An LV of the semester that the uid does not teach, or null. Same condition as getLektorIsTeachingLva. */
|
||||
const readForeignLv = async ({ semKurzbz, uid } = {}) => {
|
||||
assertReadable();
|
||||
if (!semKurzbz || !uid) throw new Error("noten:db readForeignLv - semKurzbz and uid are required");
|
||||
|
||||
const rows = await readRows(
|
||||
`SELECT le.lehrveranstaltung_id FROM lehre.tbl_lehreinheit le
|
||||
WHERE le.studiensemester_kurzbz = $1
|
||||
AND le.lehrveranstaltung_id NOT IN (
|
||||
SELECT t.lehrveranstaltung_id FROM lehre.tbl_lehreinheit t
|
||||
JOIN lehre.tbl_lehreinheitmitarbeiter m USING (lehreinheit_id)
|
||||
WHERE t.studiensemester_kurzbz = $1 AND m.mitarbeiter_uid = $2)
|
||||
ORDER BY 1 LIMIT 1`,
|
||||
[semKurzbz, uid],
|
||||
);
|
||||
return rows[0] ? rows[0].lehrveranstaltung_id : null;
|
||||
};
|
||||
|
||||
/** An LV and semester that the uid teaches and that has no student, or null. */
|
||||
const readLvWithoutStudents = async ({ uid } = {}) => {
|
||||
assertReadable();
|
||||
if (!uid) throw new Error("noten:db readLvWithoutStudents - uid is required");
|
||||
|
||||
const rows = await readRows(
|
||||
`SELECT le.lehrveranstaltung_id, le.studiensemester_kurzbz FROM lehre.tbl_lehreinheit le
|
||||
JOIN lehre.tbl_lehreinheitmitarbeiter m USING (lehreinheit_id)
|
||||
WHERE m.mitarbeiter_uid = $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM campus.vw_student_lehrveranstaltung v
|
||||
WHERE v.lehrveranstaltung_id = le.lehrveranstaltung_id
|
||||
AND v.studiensemester_kurzbz = le.studiensemester_kurzbz)
|
||||
ORDER BY le.studiensemester_kurzbz DESC LIMIT 1`,
|
||||
[uid],
|
||||
);
|
||||
return rows[0] ? { lvId: rows[0].lehrveranstaltung_id, semKurzbz: rows[0].studiensemester_kurzbz } : null;
|
||||
};
|
||||
|
||||
/** An active student without any Lehreinheit in the LV, or null. */
|
||||
const readNonParticipant = async (scope) => {
|
||||
assertReadable();
|
||||
assertScope(scope, LV_SCOPE);
|
||||
|
||||
const rows = await readRows(
|
||||
`SELECT s.student_uid FROM public.tbl_student s
|
||||
JOIN public.tbl_benutzer b ON b.uid = s.student_uid
|
||||
WHERE b.aktiv AND NOT EXISTS (
|
||||
SELECT 1 FROM campus.vw_student_lehrveranstaltung v
|
||||
WHERE v.uid = s.student_uid AND v.lehrveranstaltung_id = $1 AND v.studiensemester_kurzbz = $2)
|
||||
ORDER BY s.student_uid LIMIT 1`,
|
||||
[scope.lvId, scope.semKurzbz],
|
||||
);
|
||||
return rows[0] ? rows[0].student_uid : null;
|
||||
};
|
||||
|
||||
/** The LV-Noten of the student in OTHER LVs of the semester; reset never reaches them. */
|
||||
const countOtherLvNoten = async (scope) => {
|
||||
assertReadable();
|
||||
assertScope({ ...scope, studentUids: [scope.studentUid] });
|
||||
|
||||
const rows = await readRows(
|
||||
`SELECT count(*)::int AS total FROM campus.tbl_lvgesamtnote
|
||||
WHERE student_uid = $1 AND studiensemester_kurzbz = $2 AND lehrveranstaltung_id <> $3`,
|
||||
[scope.studentUid, scope.semKurzbz, scope.lvId],
|
||||
);
|
||||
return rows[0].total;
|
||||
};
|
||||
|
||||
const hasPruefungstyp = async ({ type } = {}) => {
|
||||
assertReadable();
|
||||
if (!type) throw new Error("noten:db hasPruefungstyp - type is required");
|
||||
|
||||
const rows = await readRows("SELECT 1 FROM lehre.tbl_pruefungstyp WHERE pruefungstyp_kurzbz = $1", [type]);
|
||||
return rows.length > 0;
|
||||
};
|
||||
|
||||
/** Deletes one Pruefung outside the LV scope, for example one that old code wrote into a foreign Lehreinheit. */
|
||||
const deletePruefung = async ({ pruefungId, studentUids } = {}) => {
|
||||
assertWritable();
|
||||
if (!pruefungId) throw new Error("noten:db deletePruefung - pruefungId is required");
|
||||
assertScope({ studentUids }, ["studentUids"]);
|
||||
|
||||
return withClient(async (client) => {
|
||||
const res = await client.query(
|
||||
"DELETE FROM lehre.tbl_pruefung WHERE pruefung_id = $1 AND student_uid = ANY($2::varchar[])",
|
||||
[pruefungId, studentUids],
|
||||
);
|
||||
return res.rowCount;
|
||||
});
|
||||
};
|
||||
|
||||
/** Active text of each Vorlage, newest version. The suite cannot read the mail itself. */
|
||||
const readVorlagen = async ({ kurzbz } = {}) => {
|
||||
assertReadable();
|
||||
if (!Array.isArray(kurzbz) || !kurzbz.length) throw new Error("noten:db readVorlagen - kurzbz list is required");
|
||||
|
||||
return readRows(
|
||||
`SELECT DISTINCT ON (vorlage_kurzbz) vorlage_kurzbz, text FROM public.tbl_vorlagestudiengang
|
||||
WHERE vorlage_kurzbz = ANY($1::varchar[]) AND aktiv
|
||||
ORDER BY vorlage_kurzbz, version DESC`,
|
||||
[kurzbz],
|
||||
);
|
||||
};
|
||||
|
||||
/** Lektoren of a Lehreinheit, sorted by uid like Noten::lektorenOfLehreinheit (strcmp). */
|
||||
const readLektorenOfLehreinheit = async ({ lehreinheitId } = {}) => {
|
||||
assertReadable();
|
||||
if (!lehreinheitId) throw new Error("noten:db readLektorenOfLehreinheit - lehreinheitId is required");
|
||||
|
||||
const rows = await readRows(
|
||||
`SELECT mitarbeiter_uid FROM lehre.tbl_lehreinheitmitarbeiter
|
||||
WHERE lehreinheit_id = $1 ORDER BY mitarbeiter_uid COLLATE "C"`,
|
||||
[lehreinheitId],
|
||||
);
|
||||
return rows.map((r) => r.mitarbeiter_uid);
|
||||
};
|
||||
|
||||
/** The mitarbeiter_uid of one Pruefung row, or null. */
|
||||
const readPruefungMitarbeiter = async ({ pruefungId, studentUid } = {}) => {
|
||||
assertReadable();
|
||||
if (!pruefungId || !studentUid)
|
||||
throw new Error("noten:db readPruefungMitarbeiter - pruefungId and studentUid are required");
|
||||
|
||||
const rows = await readRows(
|
||||
"SELECT mitarbeiter_uid FROM lehre.tbl_pruefung WHERE pruefung_id = $1 AND student_uid = $2",
|
||||
[pruefungId, studentUid],
|
||||
);
|
||||
return rows[0] ? rows[0].mitarbeiter_uid : null;
|
||||
};
|
||||
|
||||
const registerNotenDbTasks = (on) => {
|
||||
on("task", {
|
||||
"noten:db:checkConnection": () => checkConnection(),
|
||||
"noten:db:reset": reset,
|
||||
"noten:db:seedLvGesamtnote": seedLvGesamtnote,
|
||||
"noten:db:seedPruefung": seedPruefung,
|
||||
"noten:db:seedZeugnisnote": seedZeugnisnote,
|
||||
"noten:db:readLvGesamtnote": readLvGesamtnote,
|
||||
"noten:db:readLehreinheitenOfStudent": readLehreinheitenOfStudent,
|
||||
"noten:db:readLehreinheitenOfLv": readLehreinheitenOfLv,
|
||||
"noten:db:readForeignLehreinheit": readForeignLehreinheit,
|
||||
"noten:db:readForeignLv": readForeignLv,
|
||||
"noten:db:readLvWithoutStudents": readLvWithoutStudents,
|
||||
"noten:db:readNonParticipant": readNonParticipant,
|
||||
"noten:db:countOtherLvNoten": countOtherLvNoten,
|
||||
"noten:db:hasPruefungstyp": hasPruefungstyp,
|
||||
"noten:db:deletePruefung": deletePruefung,
|
||||
"noten:db:readVorlagen": readVorlagen,
|
||||
"noten:db:readLektorenOfLehreinheit": readLektorenOfLehreinheit,
|
||||
"noten:db:readPruefungMitarbeiter": readPruefungMitarbeiter,
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = { registerNotenDbTasks };
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* The test data that the noten suite needs. Data only: dbCheck.js runs the checks.
|
||||
*
|
||||
* The seeder builds the fixture: demolektor1 teaches LV 5221 (12 students), demoassistenz is Assistenz
|
||||
* for stg5. suites/.env.example uses the same values.
|
||||
*
|
||||
* Every precondition of the chain
|
||||
* getBenotungstoolContext -> getStudentenNoten -> savePruefung
|
||||
* is its own query, so a failure names the exact missing link instead of "no students".
|
||||
*/
|
||||
|
||||
const path = require("path");
|
||||
|
||||
// tests/cypress/tools/checks -> repo root is four levels up
|
||||
const SEEDER = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"system",
|
||||
"seeders",
|
||||
"TST60873_000_GesamtnoteneingabeCis4.sql",
|
||||
);
|
||||
|
||||
/** Test semester: the running one, else the last started. Same rule as the suite and seeder group benotungstool_noten. */
|
||||
const SEM = `(SELECT studiensemester_kurzbz FROM public.tbl_studiensemester
|
||||
WHERE start <= now() ORDER BY (ende >= now()) DESC, start DESC LIMIT 1)`;
|
||||
|
||||
module.exports = {
|
||||
title: "Gesamtnoteneingabe",
|
||||
|
||||
sqlFiles: {
|
||||
// the idempotent fixture groups; studiengang_5 inserts without ON CONFLICT and needs a rebuild
|
||||
seed: ["benotungstool_noten", "benotungstool_berechtigungen", "benotungstool_fixture_erweitert"].map(
|
||||
(group) => ({ file: SEEDER, group }),
|
||||
),
|
||||
},
|
||||
|
||||
checks: [
|
||||
{
|
||||
label: "Test semester (latest started)",
|
||||
sql: `SELECT ${SEM} AS value`,
|
||||
ok: (r) => Boolean(r.value),
|
||||
hint: "No studiensemester with start <= now(). Apply the base dump + BS003_kompetenzfeld.sql.",
|
||||
},
|
||||
{
|
||||
label: "demolektor1 exists",
|
||||
sql: `SELECT COUNT(*)::int AS value FROM public.tbl_mitarbeiter WHERE mitarbeiter_uid = 'demolektor1'`,
|
||||
ok: (r) => r.value > 0,
|
||||
hint: "Apply BS002_mitarbeiter.sql.",
|
||||
},
|
||||
{
|
||||
label: "STG5 Lehrveranstaltungen (5121 / 5221)",
|
||||
sql: `SELECT COUNT(*)::int AS value FROM lehre.tbl_lehrveranstaltung
|
||||
WHERE lehrveranstaltung_id IN (5121, 5221)`,
|
||||
ok: (r) => r.value === 2,
|
||||
hint: "Rebuild the database. Seeder group studiengang_5 creates them.",
|
||||
},
|
||||
{
|
||||
label: "Lehreinheiten of demolektor1 in the test semester",
|
||||
sql: `SELECT COUNT(*)::int AS value
|
||||
FROM lehre.tbl_lehreinheit le
|
||||
JOIN lehre.tbl_lehreinheitmitarbeiter USING (lehreinheit_id)
|
||||
WHERE mitarbeiter_uid = 'demolektor1' AND le.studiensemester_kurzbz = ${SEM}`,
|
||||
ok: (r) => r.value > 0,
|
||||
hint:
|
||||
"demolektor1 teaches nothing in the CURRENT test semester. Seeder group studiengang_5 pins its" +
|
||||
"Lehreinheiten to the semester that was active when it ran. Apply seeder group benotungstool_noten.",
|
||||
},
|
||||
{
|
||||
label: "Lehreinheitgruppe rows with a non-NULL gruppe_kurzbz",
|
||||
sql: `SELECT COUNT(*)::int AS value FROM lehre.tbl_lehreinheitgruppe
|
||||
WHERE gruppe_kurzbz IS NOT NULL`,
|
||||
ok: (r) => r.value > 0,
|
||||
hint:
|
||||
"campus.vw_student_lehrveranstaltung joins ONLY on gruppe_kurzbz. Lehrverband-style rows " +
|
||||
"(verband set, gruppe_kurzbz NULL) never match. Apply seeder group benotungstool_noten.",
|
||||
},
|
||||
{
|
||||
label: "Group memberships carrying a studiensemester",
|
||||
sql: `SELECT COUNT(*)::int AS value FROM public.tbl_benutzergruppe
|
||||
WHERE studiensemester_kurzbz IS NOT NULL`,
|
||||
ok: (r) => r.value > 0,
|
||||
hint:
|
||||
"tbl_benutzergruppe.studiensemester_kurzbz must equal tbl_lehreinheit.studiensemester_kurzbz. " +
|
||||
"NULL never matches. Apply seeder group benotungstool_noten.",
|
||||
},
|
||||
{
|
||||
label: "Students visible in LV 5221 (the test LV)",
|
||||
sql: `SELECT COUNT(*)::int AS value FROM campus.vw_student_lehrveranstaltung
|
||||
WHERE lehrveranstaltung_id = 5221 AND studiensemester_kurzbz = ${SEM}`,
|
||||
ok: (r) => r.value >= 7,
|
||||
hint: "The suite needs 7 students (MIN_STUDENTS in notenTestData.js). Apply seeder group benotungstool_noten.",
|
||||
},
|
||||
{
|
||||
label: "tbl_note 'entschuldigt' (resolved by Bezeichnung)",
|
||||
sql: `SELECT COALESCE((SELECT note::text FROM lehre.tbl_note
|
||||
WHERE bezeichnung = 'entschuldigt'), '') AS value`,
|
||||
ok: (r) => r.value !== "",
|
||||
hint: "Noten.php resolves this by Bezeichnung, not by the config PK. Apply the base inserts.",
|
||||
},
|
||||
{
|
||||
label: "tbl_note 'Noch nicht eingetragen'",
|
||||
sql: `SELECT COALESCE((SELECT note::text FROM lehre.tbl_note
|
||||
WHERE bezeichnung = 'Noch nicht eingetragen'), '') AS value`,
|
||||
ok: (r) => r.value !== "",
|
||||
hint: "Missing from the base dump. Rebuild the database with db_setup.sh.",
|
||||
},
|
||||
{
|
||||
label: "Pruefungstypen Termin1/Termin2/Termin3/kommPruef",
|
||||
sql: `SELECT COALESCE(string_agg(pruefungstyp_kurzbz, ',' ORDER BY pruefungstyp_kurzbz), '') AS value
|
||||
FROM lehre.tbl_pruefungstyp
|
||||
WHERE pruefungstyp_kurzbz IN ('Termin1','Termin2','Termin3','kommPruef')`,
|
||||
ok: (r) => r.value.includes("Termin1") && r.value.includes("Termin2"),
|
||||
hint:
|
||||
"Termin3 is absent from the base dump. Harmless while CIS_GESAMTNOTE_PRUEFUNG_TERMIN3 is " +
|
||||
"off, but a Termin3 insert would hit a foreign key error. Seeder group benotungstool_noten adds it.",
|
||||
},
|
||||
{
|
||||
label: "Notenschluessel assigned to LV 5221",
|
||||
sql: `SELECT COUNT(*)::int AS value
|
||||
FROM lehre.tbl_notenschluesselzuordnung z
|
||||
JOIN lehre.tbl_notenschluesselaufteilung a USING (notenschluessel_kurzbz)
|
||||
WHERE z.lehrveranstaltung_id = 5221`,
|
||||
ok: (r) => r.value > 0,
|
||||
hint:
|
||||
"The Notenschluessel tables are empty in the base dump, so getNoteByPunkte always returns " +
|
||||
"null. Apply seeder group benotungstool_noten.",
|
||||
},
|
||||
{
|
||||
label: "Students in the second Lehreinheit of LV 5221",
|
||||
sql: `SELECT COUNT(*)::int AS value FROM campus.vw_student_lehrveranstaltung
|
||||
WHERE lehreinheit_id = 51103 AND studiensemester_kurzbz = ${SEM}`,
|
||||
ok: (r) => r.value > 0,
|
||||
hint: "The Lehreinheit check of the write paths needs it. Apply seeder group benotungstool_fixture_erweitert.",
|
||||
},
|
||||
{
|
||||
label: "Lektoren of Lehreinheit 51103",
|
||||
sql: "SELECT COUNT(*)::int AS value FROM lehre.tbl_lehreinheitmitarbeiter WHERE lehreinheit_id = 51103",
|
||||
ok: (r) => r.value >= 2,
|
||||
hint: "The grader rules need two Lektoren. Apply seeder group benotungstool_fixture_erweitert.",
|
||||
},
|
||||
{
|
||||
label: "Sommersemester after its Frist, taught by demolektor1",
|
||||
sql: `SELECT COALESCE((SELECT le.studiensemester_kurzbz
|
||||
FROM lehre.tbl_lehreinheit le
|
||||
JOIN lehre.tbl_lehreinheitmitarbeiter USING (lehreinheit_id)
|
||||
WHERE mitarbeiter_uid = 'demolektor1'
|
||||
AND le.studiensemester_kurzbz ~ '^SS[0-9]{4}$'
|
||||
AND make_date(substring(le.studiensemester_kurzbz FROM 3 FOR 4)::int, 11, 15) < current_date
|
||||
ORDER BY le.studiensemester_kurzbz DESC LIMIT 1), '') AS value`,
|
||||
ok: (r) => r.value !== "",
|
||||
hint: "The Frist tests need it. Apply seeder group benotungstool_fixture_erweitert.",
|
||||
},
|
||||
{
|
||||
label: "Texts of the Vorlagen Notenfreigabe and Sancho_Mail_Template",
|
||||
sql: `SELECT COUNT(DISTINCT vorlage_kurzbz)::int AS value FROM public.tbl_vorlagestudiengang
|
||||
WHERE vorlage_kurzbz IN ('Notenfreigabe', 'Sancho_Mail_Template') AND aktiv
|
||||
AND COALESCE(text, '') <> ''`,
|
||||
ok: (r) => r.value === 2,
|
||||
hint: "Without both texts every Freigabe mail has an empty body. Apply seeder group benotungstool_fixture_erweitert.",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Diagnose or seed the database fixture a Cypress suite needs. Suite-agnostic runner.
|
||||
*
|
||||
* node tests/cypress/tools/dbCheck.js noten report what is present and what is missing
|
||||
* node tests/cypress/tools/dbCheck.js noten seed apply the suite's seeder, then re-check
|
||||
* node tests/cypress/tools/dbCheck.js noten env print the .env block to use
|
||||
*
|
||||
* A suite contributes tools/checks/<suite>.js exporting { title, checks, sqlFiles }. Connection
|
||||
* and output are shared and live here. A sqlFiles entry is a path or { file, group }; a
|
||||
* "-- ==== <group> ====" line starts a group.
|
||||
*
|
||||
* `check` is read-only; `seed` needs NOTEN_DB_ALLOW_WRITES=true and the owner role.
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
try {
|
||||
require("dotenv").config({ path: path.join(__dirname, "..", "suites", ".env") });
|
||||
} catch (e) {
|
||||
// dotenv missing -> fall back to the ambient environment
|
||||
}
|
||||
|
||||
const db = require("../tasks/db");
|
||||
|
||||
const COL = 48;
|
||||
const CHECKS_DIR = path.join(__dirname, "checks");
|
||||
|
||||
const usage = (suite, mode) => `node tests/cypress/tools/dbCheck.js ${suite} ${mode}`;
|
||||
|
||||
const loadSuite = (name) => {
|
||||
const file = path.join(CHECKS_DIR, `${name}.js`);
|
||||
if (!fs.existsSync(file)) {
|
||||
const available = fs.existsSync(CHECKS_DIR)
|
||||
? fs
|
||||
.readdirSync(CHECKS_DIR)
|
||||
.map((f) => f.replace(/\.js$/, ""))
|
||||
.join(", ")
|
||||
: "(none)";
|
||||
console.error(`\nUnknown suite "${name}". Available: ${available}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
return require(file);
|
||||
};
|
||||
|
||||
const runChecks = async (client, suite) => {
|
||||
console.log(`\n${suite.title} fixture check\n${"=".repeat(70)}`);
|
||||
|
||||
const failures = [];
|
||||
for (const check of suite.checks) {
|
||||
let row;
|
||||
try {
|
||||
row = (await client.query(check.sql)).rows[0] || {};
|
||||
} catch (error) {
|
||||
console.log(` [ERR ] ${check.label}\n ${error.message}`);
|
||||
failures.push(check);
|
||||
continue;
|
||||
}
|
||||
|
||||
const passed = check.ok(row);
|
||||
const value = row.value === "" ? "(none)" : String(row.value);
|
||||
console.log(` [${passed ? "PASS" : "FAIL"}] ${check.label.padEnd(COL)} ${value}`);
|
||||
if (!passed) failures.push(check);
|
||||
}
|
||||
|
||||
console.log("=".repeat(70));
|
||||
|
||||
if (!failures.length) {
|
||||
console.log("\nAll preconditions met.\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log(`\n${failures.length} problem(s):\n`);
|
||||
failures.forEach((f) => console.log(` - ${f.label}\n ${f.hint}\n`));
|
||||
console.log(`Fix with: ${usage(suite.name, "seed")}\n`);
|
||||
return 1;
|
||||
};
|
||||
|
||||
/** The SQL of a file, or of one group in it. A group ends at the next group header. */
|
||||
const readSql = (file, group) => {
|
||||
const sql = fs.readFileSync(file, "utf8");
|
||||
if (!group) return sql;
|
||||
const parts = sql.split(/^-- ==== (.+?) ====[ \t]*\r?$/m);
|
||||
for (let i = 1; i < parts.length; i += 2) if (parts[i] === group) return parts[i + 1];
|
||||
return null;
|
||||
};
|
||||
|
||||
const applySqlFile = async (client, entry) => {
|
||||
const { file, group } = typeof entry === "string" ? { file: entry } : entry;
|
||||
const label = path.relative(process.cwd(), file) + (group ? ` [${group}]` : "");
|
||||
if (!db.writesAllowed()) {
|
||||
console.error("\nRefusing to write: set NOTEN_DB_ALLOW_WRITES=true first.\n");
|
||||
return 2;
|
||||
}
|
||||
if (!fs.existsSync(file)) {
|
||||
console.error(`\nFile not found: ${file}\n`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
const sql = readSql(file, group);
|
||||
if (sql === null) {
|
||||
console.error(`\nGroup "${group}" not found in ${file}\n`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
console.log(`\nApplying ${label} ...\n`);
|
||||
|
||||
// One connection, one call: the seeders rely on running as a unit (temp tables, DO blocks and a
|
||||
// closing verification query).
|
||||
let result;
|
||||
try {
|
||||
result = await client.query(sql);
|
||||
} catch (error) {
|
||||
// `check` gets by as the application role; seeding creates and grants objects.
|
||||
if (/permission denied/i.test(error.message)) {
|
||||
console.error(`${error.message}\n`);
|
||||
console.error(`Seeding needs the database owner, but NOTEN_DB_USER is "${db.env("USER")}".`);
|
||||
console.error("Point it at the owner role, or rebuild the database with db_setup.sh.\n");
|
||||
return 2;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Print whatever the last statement returned, whatever its shape.
|
||||
const results = Array.isArray(result) ? result : [result];
|
||||
const last = results.filter((r) => r && r.rows && r.rows.length).pop();
|
||||
if (last) {
|
||||
console.log("Verification\n" + "-".repeat(50));
|
||||
last.rows.forEach((row) => Object.entries(row).forEach(([k, v]) => console.log(` ${k.padEnd(24)} ${v}`)));
|
||||
}
|
||||
|
||||
console.log(`\nDone: ${label}\n`);
|
||||
return 0;
|
||||
};
|
||||
|
||||
const printEnv = () => {
|
||||
console.log(`
|
||||
Add to tests/cypress/suites/.env (gitignored):
|
||||
|
||||
NOTEN_DB_HOST=<postgres host>
|
||||
NOTEN_DB_PORT=5432
|
||||
NOTEN_DB_NAME=<test database>
|
||||
NOTEN_DB_USER=<user>
|
||||
NOTEN_DB_PASSWORD=<password>
|
||||
NOTEN_DB_ALLOW_WRITES=true
|
||||
`);
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
const suiteName = process.argv[2];
|
||||
const mode = process.argv[3] || "check";
|
||||
|
||||
if (!suiteName || suiteName.startsWith("-")) {
|
||||
console.error(`\n ${usage("<suite>", "[check|seed|env]")}\n`);
|
||||
return 2;
|
||||
}
|
||||
if (mode === "env") {
|
||||
printEnv();
|
||||
return 0;
|
||||
}
|
||||
if (!["check", "seed"].includes(mode)) {
|
||||
console.error(`Unknown mode "${mode}". Use: check | seed | env`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
const suite = loadSuite(suiteName);
|
||||
suite.name = suiteName;
|
||||
|
||||
if (mode !== "check" && !(suite.sqlFiles && suite.sqlFiles[mode])) {
|
||||
console.error(`Suite "${suiteName}" defines no SQL file for "${mode}".`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
const status = await db.checkConnection({ requireWrites: mode !== "check" });
|
||||
if (!status.available) {
|
||||
console.error(`\nCould not connect: ${status.reason}\n`);
|
||||
if (status.reason === "not-configured") printEnv();
|
||||
return 2;
|
||||
}
|
||||
|
||||
try {
|
||||
return await db.withClient(async (client) => {
|
||||
client.on("notice", (n) => console.log(` NOTICE: ${n.message}`));
|
||||
|
||||
if (mode !== "check") {
|
||||
// a suite can name several files or groups; they run in this order
|
||||
for (const file of [].concat(suite.sqlFiles[mode])) {
|
||||
const code = await applySqlFile(client, file);
|
||||
if (code !== 0) return code;
|
||||
}
|
||||
}
|
||||
return runChecks(client, suite);
|
||||
});
|
||||
} finally {
|
||||
await db.closeDb();
|
||||
}
|
||||
};
|
||||
|
||||
main()
|
||||
.then((code) => process.exit(code))
|
||||
.catch((error) => {
|
||||
console.error(`\n${error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user