diff --git a/public/js/components/searchbar/searchbar.js b/public/js/components/searchbar/searchbar.js
index 3b73c44f2..8fb4a5cd4 100644
--- a/public/js/components/searchbar/searchbar.js
+++ b/public/js/components/searchbar/searchbar.js
@@ -40,7 +40,7 @@ export default {
query: Vue.computed(() => this.lastQuery),
};
},
- inject: ["isMobile"],
+ inject: { isMobile: { default: false } },
data: function () {
return {
searchtimer: null,
diff --git a/public/js/routers/Cis/CisRouter.js b/public/js/routers/Cis/CisRouter.js
index caa2e8924..734eb7aa7 100644
--- a/public/js/routers/Cis/CisRouter.js
+++ b/public/js/routers/Cis/CisRouter.js
@@ -19,6 +19,7 @@ import PaabgabeUebersicht from "../../components/Cis/ProjektabgabeUebersicht/Pro
import Benotungstool from "../../components/Cis/Benotungstool/Benotungstool.js";
import Zeitsperren from "../../components/Cis/Zeitsperren/Zeitsperren.js";
import Compat from "../../components/Cis/Compat.js";
+import ZeitsperrenMa from "../../components/Cis/ZeitsperrenMitarbeiter/ZeitsperrenMa.js";
const ciPath = FHC_JS_DATA_STORAGE_OBJECT.app_root.replace(/(https:|)(^|\/\/)(.*?\/)/g, '') + FHC_JS_DATA_STORAGE_OBJECT.ci_router;
const isMobile = window.matchMedia("(max-width: 767px)").matches;
@@ -252,6 +253,23 @@ const router = VueRouter.createRouter({
component: FhcDashboard,
props: {dashboard: 'CIS'},
},
+ {
+ path: '/Cis/Zeitsperrenma/:type(all|fix|lector|ma|oe|stg|ass)?/:id?/:days?',
+ name: 'ZeitsperrenMa',
+ component: ZeitsperrenMa,
+ props: route => {
+ //console.log('ROUTE PARAMS', route.params);
+ return {
+ propsViewData: route.params
+ };
+ }
+ },
+ {
+ path: `/Cis/Zeitsperren`,
+ name: 'Zeitsperren',
+ component: Zeitsperren,
+ props: true
+ },
/*
* TODO(ma0080): 2026-07-06: commented out => check if needed
*
@@ -270,15 +288,9 @@ const router = VueRouter.createRouter({
},
},
*/
- {
- path: `/Cis/Zeitsperren`,
- name: 'Zeitsperren',
- component: Zeitsperren,
- props: true
- },
]
});
export {
router
-}
\ No newline at end of file
+}
diff --git a/public/vendor b/public/vendor
new file mode 120000
index 000000000..9c39cc9f8
--- /dev/null
+++ b/public/vendor
@@ -0,0 +1 @@
+../vendor
\ No newline at end of file
diff --git a/system/checksystem.php b/system/checksystem.php
index 1f7968282..8d95d3ce5 100644
--- a/system/checksystem.php
+++ b/system/checksystem.php
@@ -151,6 +151,7 @@ $berechtigungen = array(
array('lehre/abgabetool','Projektabgabetool, Studentenansicht'),
array('lehre/abgabetool:download','Download von Projektarbeitsabgaben'),
array('lehre/benotungstool','Cis4 Gesamtnoteneingabe'),
+ array('lehre/benotungstool_assistenz','Cis4 Gesamtnoteneingabe für Assistenz'),
array('lehre/freifach','Freifachverwaltung'),
array('lehre/lehrfach','Lehrfachverwaltung'),
array('lehre/lehrfach:begrenzt','Lehrfachverwaltung - nur aktiv aenderbar, nur aktive LF werden angezeigt'),
diff --git a/system/dbupdate_3.4.php b/system/dbupdate_3.4.php
index c57f88984..ec813f15d 100644
--- a/system/dbupdate_3.4.php
+++ b/system/dbupdate_3.4.php
@@ -100,6 +100,8 @@ require_once('dbupdate_3.4/68530_Dashboard_Cleanup.php');
require_once('dbupdate_3.4/75959_StudVw_Automatische_Tags.php');
require_once('dbupdate_3.4/76160_lvv_favorites.php');
require_once('dbupdate_3.4/77080_tabulator_presets_table.php');
+require_once('dbupdate_3.4/78292_gehaltsanpassungtyp.php');
+require_once('dbupdate_3.4/77048_cis4_mitarbeiter_studstatus_widget.php');
// *** Pruefung und hinzufuegen der neuen Attribute und Tabellen
echo '
Pruefe Tabellen und Attribute!
';
diff --git a/system/dbupdate_3.4/77048_cis4_mitarbeiter_studstatus_widget.php b/system/dbupdate_3.4/77048_cis4_mitarbeiter_studstatus_widget.php
new file mode 100644
index 000000000..4dd78cc28
--- /dev/null
+++ b/system/dbupdate_3.4/77048_cis4_mitarbeiter_studstatus_widget.php
@@ -0,0 +1,63 @@
+db_query("SELECT 1 FROM dashboard.tbl_widget WHERE widget_kurzbz= 'studstatus';"))
+{
+ if($db->db_num_rows($result) == 0)
+ {
+ $qry = "
+ INSERT INTO dashboard.tbl_widget (
+ widget_id,
+ widget_kurzbz,
+ beschreibung,
+ arguments,
+ setup
+ )
+ VALUES (
+ 9,
+ 'studstatus',
+ 'Widget Todos Studierendenstatus',
+ '{
+ \"css\": \"d-flex justify-content-center align-items-center h-100\",
+ \"title\": \"Tasks Studstatus\"
+ }'::jsonb,
+ '{
+ \"file\": \"public/js/components/DashboardWidget/StudStatus.js\",
+ \"icon\": \"/skin/images/fh_technikum_wien_illustration_klein.png\",
+ \"name\": \"Studierendenstatus\",
+ \"width\": {
+ \"max\": 2,
+ \"min\": 1
+ },
+ \"height\": {
+ \"max\": 2,
+ \"min\": 1
+ },
+ \"hideFooter\": true
+ }'::jsonb
+ );";
+
+ if(!$db->db_query($qry))
+ echo '
dashboard.tbl_widget: '.$db->db_last_error().'';
+ else
+ echo '
dashboard.tbl_widget: Widget studstatus hinzugefuegt!
';
+ }
+}
+
+// neuer eintrag in dashboard.tbl_dashboard_widget
+if($result = @$db->db_query("SELECT 1 FROM dashboard.tbl_dashboard_widget WHERE dashboard_id= 1 AND widget_id = 9;"))
+{
+ if($db->db_num_rows($result) == 0)
+ {
+ $qry = "
+ INSERT INTO
+ dashboard.tbl_dashboard_widget (dashboard_id, widget_id)
+ VALUES
+ (1, 9);
+ ";
+ }
+
+ if(!$db->db_query($qry))
+ echo '
dashboard.tbl_dashboard_widget: '.$db->db_last_error().'';
+ else
+ echo '
dashboard.tbl_dashboard_widget: Widget studstatus hinzugefuegt!
';
+}
\ No newline at end of file
diff --git a/system/dbupdate_3.4/78292_gehaltsanpassungtyp.php b/system/dbupdate_3.4/78292_gehaltsanpassungtyp.php
new file mode 100644
index 000000000..4bd866d43
--- /dev/null
+++ b/system/dbupdate_3.4/78292_gehaltsanpassungtyp.php
@@ -0,0 +1,47 @@
+db_query("SELECT * FROM information_schema.tables WHERE table_name='tbl_gehaltsanpassungtyp' AND table_schema='hr'"))
+{
+ if ($db->db_num_rows($result) == 0)
+ {
+ $qry = "
+
+ ALTER TABLE
+ hr.tbl_gehaltsbestandteil
+ ADD COLUMN IF NOT EXISTS
+ gehaltsanpassungtyp_kurzbz character varying(32);
+
+ COMMENT ON COLUMN hr.tbl_gehaltsbestandteil.gehaltsanpassungtyp_kurzbz IS E'Ersteinstufung, indiv. Erhöhung, etc.';
+
+ CREATE TABLE IF NOT EXISTS hr.tbl_gehaltsanpassungtyp
+ (
+ gehaltsanpassungtyp_kurzbz character varying(32) NOT NULL,
+ bezeichnung varchar(256),
+ sort smallint,
+ aktiv boolean NOT NULL DEFAULT true,
+ CONSTRAINT tbl_gehaltsanpassungtyp_pk PRIMARY KEY (gehaltsanpassungtyp_kurzbz)
+ );
+
+ COMMENT ON TABLE hr.tbl_gehaltsanpassungtyp IS E'Key-Table of Salary Adaption Types';
+
+ ALTER TABLE hr.tbl_gehaltsbestandteil DROP CONSTRAINT IF EXISTS tbl_gehaltsanpassungtyp_fk;
+ ALTER TABLE hr.tbl_gehaltsbestandteil ADD CONSTRAINT tbl_gehaltsanpassungtyp_fk FOREIGN KEY (gehaltsanpassungtyp_kurzbz)
+ REFERENCES hr.tbl_gehaltsanpassungtyp (gehaltsanpassungtyp_kurzbz) MATCH FULL
+ ON DELETE RESTRICT ON UPDATE CASCADE;
+
+ GRANT SELECT, UPDATE, INSERT, DELETE ON hr.tbl_gehaltsanpassungtyp TO vilesci;
+
+ INSERT INTO hr.tbl_gehaltsanpassungtyp(gehaltsanpassungtyp_kurzbz, bezeichnung, sort, aktiv) VALUES('ersteinstufung','Ersteinstufung', 1, true);
+ INSERT INTO hr.tbl_gehaltsanpassungtyp(gehaltsanpassungtyp_kurzbz, bezeichnung, sort, aktiv) VALUES('individuelle_erhoehung','Indiv. Gehaltserhöhung', 2, true);
+ INSERT INTO hr.tbl_gehaltsanpassungtyp(gehaltsanpassungtyp_kurzbz, bezeichnung, sort, aktiv) VALUES('strukturelle_anpassung','Strukturelle Anpassung', 3, true);
+ INSERT INTO hr.tbl_gehaltsanpassungtyp(gehaltsanpassungtyp_kurzbz, bezeichnung, sort, aktiv) VALUES('funktionsaenderung','Funktionsänderung', 4, true);
+
+ ";
+
+ if (! $db->db_query($qry))
+ echo '
Gehaltsanpassungtyp: ' . $db->db_last_error() . '';
+ else
+ echo 'Tabelle und Feld für Gehaltsanpassungtyp wurden neu erstellt
';
+ }
+}
diff --git a/system/phrasesupdate.php b/system/phrasesupdate.php
index 4f29de1d6..8d39d2fcc 100644
--- a/system/phrasesupdate.php
+++ b/system/phrasesupdate.php
@@ -29742,6 +29742,46 @@ array(
)
)
),
+ array(
+ 'app' => 'core',
+ 'category' => 'profil',
+ 'phrase' => 'name_title_clipboard_copy_confirmation',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Name mit Titel in die Zwischenablage kopiert.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Full name with title copied to clipboard.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'profil',
+ 'phrase' => 'name_title_copy',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Name mit Titel kopieren',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Copy full name with title',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
array(
'app' => 'core',
'category' => 'profil',
@@ -60932,6 +60972,26 @@ I have been informed that I am under no obligation to consent to the transmissio
)
)
),
+ array(
+ 'app' => 'core',
+ 'category' => 'LvPlan',
+ 'phrase' => 'copy',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Kopieren',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Copy',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
// ### LvPlanStgOrg END ###
// DMS-Link Phrasen Start
array(
@@ -63794,6 +63854,106 @@ I have been informed that I am under no obligation to consent to the transmissio
)
)
),
+ array(
+ 'app' => 'core',
+ 'category' => 'benotungstool',
+ 'phrase' => 'maxAntritteReached',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Für Studierenden {0} sind bereits die maximal zulässigen Prüfungsantritte ({1}) vergeben. Es wurde keine Prüfung angelegt.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Student {0} has already used the maximum number of exam attempts ({1}). No exam was created.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'benotungstool',
+ 'phrase' => 'pruefungDatumBeforeExisting',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Das Prüfungsdatum für Studierenden {0} darf nicht vor oder am selben Tag wie ein bereits bestehender Prüfungstermin liegen.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'The exam date for student {0} must not be on or before an existing exam date.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'benotungstool',
+ 'phrase' => 'noteOccuranceLimitReached',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Die gewählte Note darf für Studierenden {0} nicht so oft vergeben werden. Es wurde keine Prüfung angelegt.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'The selected grade may not be assigned that many times for student {0}. No exam was created.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'benotungstool',
+ 'phrase' => 'noteneintragungsfristVorbei',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Die Noteneintragungsfrist ({0}) für dieses Studiensemester ist abgelaufen. Es können keine Noten oder Prüfungen mehr eingetragen werden.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'The grade entry deadline ({0}) for this study semester has passed. Grades and exams can no longer be entered.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'benotungstool',
+ 'phrase' => 'keineBerechtigungNoten',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Keine Berechtigung, um in der Lehrveranstaltung "{0}" im Semester {1} Noten einzutragen.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'No permission to enter grades for the course "{0}" in semester {1}.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
array(
'app' => 'core',
'category' => 'benotungstool',
@@ -64140,6 +64300,146 @@ I have been informed that I am under no obligation to consent to the transmissio
)
)
),
+ array(
+ 'app' => 'core',
+ 'category' => 'benotungstool',
+ 'phrase' => 'c4notenvorschlagUebernehmenConfirm',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Möchten Sie wirklich die Note "{0}" für den Studenten {1} {2} ({3}) mit Benotungsdatum {4} eintragen?',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Do you really want to enter the grade "{0}" for the student {1} {2} ({3}) with grading date {4}?',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'benotungstool',
+ 'phrase' => 'c4freigabeSummaryHeading',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Folgende Noten werden freigegeben ({0}):',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'The following grades will be approved ({0}):',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'benotungstool',
+ 'phrase' => 'c4student',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Student',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Student',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'benotungstool',
+ 'phrase' => 'c4freigabeSummaryCurrent',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Bisher im Zeugnis',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Currently on transcript',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'benotungstool',
+ 'phrase' => 'c4freigabeSummaryReleased',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Wird freigegeben',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Will be approved',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'benotungstool',
+ 'phrase' => 'c4freigabeSummaryLegend',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Grün markierte Noten weichen von der bisher im Zeugnis eingetragenen Note ab.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Grades highlighted in green differ from the grade currently on the transcript.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'benotungstool',
+ 'phrase' => 'c4freigabeSummaryEmpty',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Es sind keine geänderten Noten zur Freigabe vorhanden.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'There are no changed grades to approve.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
array(
'app' => 'core',
'category' => 'benotungstool',
@@ -64280,6 +64580,286 @@ I have been informed that I am under no obligation to consent to the transmissio
)
)
),
+ array(
+ 'app' => 'core',
+ 'category' => 'benotungstool',
+ 'phrase' => 'c4antrittNr',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => '{0}. Antritt',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Attempt {0}',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'benotungstool',
+ 'phrase' => 'c4spaltenAntritt',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Antritte',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Attempts',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'benotungstool',
+ 'phrase' => 'c4spaltenAntrittHint',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Eine Spalte je Antritt, das Prüfungsdatum steht in der Zelle.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'One column per attempt, the exam date is shown in the cell.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'benotungstool',
+ 'phrase' => 'c4spaltenDatum',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Datum',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Date',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'benotungstool',
+ 'phrase' => 'c4spaltenDatumHint',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Eine Spalte je Prüfungsdatum.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'One column per exam date.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'benotungstool',
+ 'phrase' => 'c4lvNoteWirdAngelegtHinweis',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Es ist noch keine LV-Note eingetragen. Sie wird mit der Note dieser Prüfung angelegt und kann anschließend wie gewohnt bearbeitet und freigegeben werden.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'There is no course grade yet. It will be created from the grade of this exam and can then be edited and approved as usual.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'benotungstool',
+ 'phrase' => 'c4angerechnetKeinePruefung',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Für {0} ist die Lehrveranstaltung angerechnet. Dafür können keine Prüfungen eingetragen werden.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'The course is credited for {0}. No exams can be recorded for it.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'benotungstool',
+ 'phrase' => 'c4pruefungNichtGespeichert',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Die Prüfung für {0} konnte nicht gespeichert werden.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'The exam for {0} could not be saved.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'benotungstool',
+ 'phrase' => 'c4lektor',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Lektor/in',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Lecturer',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'benotungstool',
+ 'phrase' => 'pruefungsdatumNachFrist',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Das Prüfungsdatum für Studierenden {0} liegt nach dem Ende der Noteneintragungsfrist ({1}). Es wurde keine Prüfung angelegt.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'The exam date for student {0} is after the grade entry deadline ({1}). No exam was created.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'benotungstool',
+ 'phrase' => 'c4punkteKeineNoteErmittelt',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Für Studierenden {0} konnte aus den Punkten keine Note ermittelt werden. Die Zeile wurde übersprungen.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'No grade could be derived from the points for student {0}. The row was skipped.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'benotungstool',
+ 'phrase' => 'c4noteNichtInLehre',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Die gewählte Note ist für eine Lehrveranstaltung nicht zugelassen. Die LV-Note für {0} bleibt unverändert.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'The selected grade is not permitted for a course. The course grade for {0} stays unchanged.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'benotungstool',
+ 'phrase' => 'c4notenvorschlagGesperrt',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Für {0} existiert bereits eine Prüfung. Ändern Sie die LV-Note über den Prüfungstermin.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'An exam already exists for {0}. Change the course grade through the exam.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'benotungstool',
+ 'phrase' => 'c4zeugnisnoteGesperrt',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Die Zeugnisnote von {0} ist für Lehrende gesperrt. Die LV-Note bleibt unverändert.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'The transcript grade of {0} is locked for teachers. The course grade stays unchanged.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
// CIS4 GESAMTNOTENEINGABE ENDE ------------------------------------------------------------------------------------
//**************************** Pruefungsprotokolle start
array(
@@ -64323,6 +64903,348 @@ I have been informed that I am under no obligation to consent to the transmissio
)
),
//**************************** Pruefungsprotokolle end
+ // PHRASES ZEITSPERREN START
+ array(
+ 'app' => 'core',
+ 'category' => 'zeitsperren',
+ 'phrase' => 'btn_all_current',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Aktuelle Zeitsperren',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Current Timelocks',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'zeitsperren',
+ 'phrase' => 'btn_all_fix',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Alle Fixangestellten',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'All permanent employees',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'zeitsperren',
+ 'phrase' => 'btn_fix_lect',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Alle fixen Lektor*innen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'All permanent lecturers',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'zeitsperren',
+ 'phrase' => 'btn_oes',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Nach Organisationseinheit',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'By organizational unit',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'zeitsperren',
+ 'phrase' => 'btn_all_ass',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Alle Assistent*innen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'All Assistants',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'zeitsperren',
+ 'phrase' => 'btn_lect_stg',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Lektor*innen nach Stg',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Lecturers by study program',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'zeitsperren',
+ 'phrase' => 'title_allMa',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Mitarbeiter*innen mit aktuellen Zeitsperren',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Employees with current time blocks',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'zeitsperren',
+ 'phrase' => 'title_fix',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Zeitsperren Fixangestellte',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Time blocks permanent employees',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'zeitsperren',
+ 'phrase' => 'title_fixLecturers',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Zeitsperren aller fixer Lektoren',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Time blocks for all regular lecturers',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'zeitsperren',
+ 'phrase' => 'title_byOe',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Zeitsperren nach Organisationseinheit',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Time blocks by organizational unit',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'zeitsperren',
+ 'phrase' => 'title_lectStg',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Zeitsperren Lektor*innen nach Studiengang',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Time blocks lecturers by study program',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'zeitsperren',
+ 'phrase' => 'title_ass',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Zeitsperren aller Assistent*innen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Time blocks for all assistants',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'zeitsperren',
+ 'phrase' => 'lektor_innen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Lektor*innen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Lecturers',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'zeitsperren',
+ 'phrase' => 'abwesend',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'abwesend',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'absent',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'zeitsperren',
+ 'phrase' => 'anzahlTage',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Anzahl Tage',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Number of days',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'zeitsperren',
+ 'phrase' => 'zeitsperrenVon',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Zeitsperren von',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Timelocks from',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'ui',
+ 'phrase' => 'error_paramNoNumber',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Parameter {param} muss eine Zahl sein',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Parameter {param} has to be a number',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ // PHRASES ZEITSPERREN END
//// ### ZEITSPERREN START
array(
'app' => 'core',
@@ -64778,13 +65700,115 @@ I have been informed that I am under no obligation to consent to the transmissio
),
array(
'sprache' => 'English',
- 'text' => 'Time lock of type {type} exists',
+ 'text' => 'Time lock of type {typ} exists',
'description' => '',
'insertvon' => 'system'
)
)
),
/// ### ZEITSPERREN END
+ // ### Phrases Dashboard Studstatus Widget START
+ array(
+ 'app' => 'core',
+ 'category' => 'dashboard',
+ 'phrase' => 'antraegeToEdit',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'zu bearbeitende Studierendenanträge',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Student applications to be processed',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'dashboard',
+ 'phrase' => 'antraegeOpen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'offene Studierendenanträge',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'open student applications',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'dashboard',
+ 'phrase' => 'antraegeNoOpen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Keine offenen Studierendenanträge',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'No open student applications',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'dashboard',
+ 'phrase' => 'linkVerwaltungStudstatus',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Zur Verwaltung des Studierendenstatus',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Link to the administration of the student status',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'dashboard',
+ 'phrase' => 'noPermissionStudstatus',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Keine Berechtigung für Bearbeitung von Studierendenanträgen.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'No permission to process student applications.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ // ### Phrases Dashboard Studstatus Widget END
);
diff --git a/system/seeders/000_fixmissing.sql b/system/seeders/000_fixmissing.sql
new file mode 100644
index 000000000..31becbf65
--- /dev/null
+++ b/system/seeders/000_fixmissing.sql
@@ -0,0 +1,28 @@
+UPDATE public.tbl_funktion SET funktion_kurzbz='Student' WHERE funktion_kurzbz='stud';
+
+UPDATE public.tbl_benutzer SET uid='demoadmin' WHERE uid='admin';
+
+-- Add Missing functions
+INSERT INTO public.tbl_funktion (funktion_kurzbz, beschreibung, aktiv, fachbereich, semester, hrrelevant, vertragsrelevant) VALUES('hsv', 'Hochschulvertretung', true, false, false, false, false);
+INSERT INTO public.tbl_funktion (funktion_kurzbz, beschreibung, aktiv, fachbereich, semester, hrrelevant, vertragsrelevant) VALUES('jgv', 'Jahrgangsvertretung', true, false, true, false, false);
+INSERT INTO public.tbl_funktion (funktion_kurzbz, beschreibung, aktiv, fachbereich, semester, hrrelevant, vertragsrelevant) VALUES('oezuordnung', 'Diszpl-Zuordnung', true, false, false, true, true);
+INSERT INTO public.tbl_funktion (funktion_kurzbz, beschreibung, aktiv, fachbereich, semester, hrrelevant, vertragsrelevant) VALUES('fachzuordnung', 'Fachliche Zuordnung', true, false, false, true, true);
+
+-- Add Permissions to Admin Role
+INSERT INTO "system".tbl_rolleberechtigung (berechtigung_kurzbz, rolle_kurzbz, art, anmerkung, insertamum, insertvon) VALUES('basis/cis', 'admin', 'suid', 'Zugriff auf CIS', now(), 'demoadmin');
+
+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
+(NULL, 'dashboard/benutzer', NULL, 'Mitarbeiter', NULL, 'suid', NULL, NULL, NULL, false, NULL, NULL, now(), 'demoadmin', NULL, NULL),
+(NULL, 'dashboard/benutzer', NULL, 'Student', NULL, 'suid', NULL, NULL, NULL, false, NULL, NULL, now(), 'demoadmin', NULL, NULL)
+;
+
+INSERT INTO "system".tbl_webservicetyp (webservicetyp_kurzbz, beschreibung) VALUES('content', 'CIS-Content ID');
+
+SELECT setval('campus.seq_contentsprache', (select max(contentsprache_id) from campus.tbl_contentsprache), true);
+SELECT setval('campus.seq_contentchild', (select max(contentchild_id) from campus.tbl_contentchild), true);
+
+
+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
+('MA', 0, NULL, 'MA', 'MA', false, false, true, NULL, false, false, now(), 'demoadmin', now(), 'demoadmin', NULL, NULL, true, false, false, false, false),
+('STUD', 0, NULL, 'STUD', 'STUD', false, false, true, NULL, false, false, now(), 'demoadmin', now(), 'demoadmin', NULL, NULL, true, false, false, false, false)
+;
diff --git a/system/seeders/001_helper_functions.sql b/system/seeders/001_helper_functions.sql
new file mode 100644
index 000000000..b1ed6d484
--- /dev/null
+++ b/system/seeders/001_helper_functions.sql
@@ -0,0 +1,36 @@
+CREATE OR REPLACE FUNCTION NearestWintersemester(var_entfernung integer) RETURNS varchar(32) AS $$
+DECLARE res varchar(32);
+ BEGIN
+ WITH nearestws AS
+ (
+ SELECT studiensemester_kurzbz, start FROM public.tbl_studiensemester WHERE studiensemester_kurzbz LIKE 'W%' AND ende>=now() ORDER BY start DESC LIMIT 1
+ )
+ SELECT studiensemester_kurzbz into res
+ FROM
+ (
+ SELECT studiensemester_kurzbz, 0 as "entfernung" FROM nearestws
+ UNION
+ SELECT studiensemester_kurzbz, rank() over(order by start desc) as "entfernung" FROM public.tbl_studiensemester where start>(SELECT start FROM nearestws)
+ UNION
+ SELECT studiensemester_kurzbz, (-1 * rank() over(order by start desc)) as "entfernung" FROM public.tbl_studiensemester WHERE start<(SELECT start FROM nearestws)
+ ) x where entfernung=var_entfernung;
+
+ return res;
+ END;
+$$ LANGUAGE plpgsql;
+
+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;
diff --git a/system/seeders/002_mitarbeiter.sql b/system/seeders/002_mitarbeiter.sql
new file mode 100644
index 000000000..03ac5cd5f
--- /dev/null
+++ b/system/seeders/002_mitarbeiter.sql
@@ -0,0 +1,40 @@
+-- Demodaten fuer Mitarbeiter
+-- Personen
+INSERT INTO public.tbl_person (person_id, staatsbuergerschaft,geburtsnation,sprache,anrede,titelpost,titelpre,vorname,nachname,vornamen,gebdatum,gebort,gebzeit,foto,anmerkung,homepage,svnr,ersatzkennzeichen,familienstand,geschlecht,anzahlkinder,aktiv,insertamum,insertvon,updateamum,updatevon,ext_id,bundesland_code,kompetenzen,kurzbeschreibung,zugangscode,foto_sperre,udf_values,bpk,matr_aktiv,matr_nr,zugangscode_timestamp,wahlname,unruly) VALUES
+(21,'A','A',NULL,'Herr',NULL,NULL,'Demo','Lektor 1',NULL,'1980-01-15',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c21',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(22,'A','A',NULL,'Herr',NULL,NULL,'Demo','Lektor 2',NULL,'1980-01-15',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c22',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(23,'A','A',NULL,'Herr',NULL,NULL,'Demo','Lektor 3',NULL,'1980-01-15',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c23',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(24,'A','A',NULL,'Herr',NULL,NULL,'Demo','Lektor 4',NULL,'1980-01-15',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c24',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(25,'A','A',NULL,'Herr',NULL,NULL,'Demo','Lektor 5',NULL,'1980-01-15',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c25',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(26,'A','A',NULL,'Frau',NULL,NULL,'Demo','Assistenz',NULL,'1985-01-15',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'w',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c26',false,NULL,NULL,false,NULL,NULL,NULL,false)
+;
+
+
+INSERT INTO public.tbl_benutzer (person_id, uid, aktiv, alias, insertamum, insertvon, updateamum, updatevon, updateaktivam, updateaktivvon, aktivierungscode) VALUES
+('21', 'demolektor1' , true, null, now(), 'auto', null, null, null, false, null),
+('22', 'demolektor2' , true, null, now(), 'auto', null, null, null, false, null),
+('23', 'demolektor3' , true, null, now(), 'auto', null, null, null, false, null),
+('24', 'demolektor4' , true, null, now(), 'auto', null, null, null, false, null),
+('25', 'demolektor5' , true, null, now(), 'auto', null, null, null, false, null),
+('26', 'demoassistenz' , true, null, now(), 'auto', null, null, null, false, null)
+;
+
+INSERT INTO public.tbl_mitarbeiter (mitarbeiter_uid, personalnummer, telefonklappe, kurzbz, lektor, fixangestellt, stundensatz, ausbildungcode, ort_kurzbz, insertamum, insertvon, bismelden) VALUES
+('demolektor1', 21, null, 'DemoLKT1', true, true, 80, null, null, now(), 'auto', true),
+('demolektor2', 22, null, 'DemoLKT2', true, true, 80, null, null, now(), 'auto', true),
+('demolektor3', 23, null, 'DemoLKT3', true, true, 80, null, null, now(), 'auto', true),
+('demolektor4', 24, null, 'DemoLKT4', true, true, 80, null, null, now(), 'auto', true),
+('demolektor5', 25, null, 'DemoLKT5', true, true, 80, null, null, now(), 'auto', true),
+('demoassistenz', 26, null, 'DemoAss', true, true, 80, null, null, now(), 'auto', true)
+;
+
+
+INSERT INTO public.tbl_benutzergruppe (uid, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id, studiensemester_kurzbz) VALUES
+('demoadmin', 'MA', now(), 'demoadmin', now(), 'demoadmin', NULL, NULL),
+('demolektor1', 'MA', now(), 'demoadmin', now(), 'demoadmin', NULL, NULL),
+('demolektor2', 'MA', now(), 'demoadmin', now(), 'demoadmin', NULL, NULL),
+('demolektor3', 'MA', now(), 'demoadmin', now(), 'demoadmin', NULL, NULL),
+('demolektor4', 'MA', now(), 'demoadmin', now(), 'demoadmin', NULL, NULL),
+('demolektor5', 'MA', now(), 'demoadmin', now(), 'demoadmin', NULL, NULL),
+('demoassistenz', 'MA', now(), 'demoadmin', now(), 'demoadmin', NULL, NULL)
+;
diff --git a/system/seeders/003_kompetenzfeld.sql b/system/seeders/003_kompetenzfeld.sql
new file mode 100644
index 000000000..1bcd9ed5d
--- /dev/null
+++ b/system/seeders/003_kompetenzfeld.sql
@@ -0,0 +1,27 @@
+-- Kompetenzfedler
+INSERT INTO public.tbl_organisationseinheittyp (organisationseinheittyp_kurzbz, bezeichnung, beschreibung) VALUES('Kompetenzfeld', 'Kompetenzfeld', 'Kompetenzfeld');
+
+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
+ ('dep', 'etw', 'Fachabteilung', 'Abteilung', true, false, NULL, NULL, true, NULL, NULL, NULL, NULL),
+ ('kfSprachen', 'dep', 'Sprachen', 'Kompetenzfeld', true, false, NULL, NULL, true, NULL, NULL, NULL, NULL),
+ ('kfMath', 'dep', 'Mathematik', 'Kompetenzfeld', true, false, NULL, NULL, true, NULL, NULL, NULL, NULL),
+ ('kfTech', 'dep', 'Technik', 'Kompetenzfeld', true, false, NULL, NULL, true, NULL, NULL, NULL, NULL);
+
+ 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, fts_bezeichnung)
+VALUES('Lehrgaenge', 'etw', 'Lehrgänge', 'Abteilung', true, false, NULL, NULL, false, NULL, NULL, NULL, NULL, '''abteilung'':1 ''lehrgänge'':2'::tsvector);
+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, fts_bezeichnung)
+VALUES('studiengaenge', 'etw', 'Studiengänge', 'Abteilung', true, false, NULL, NULL, false, NULL, NULL, NULL, NULL, '''abteilung'':1 ''studiengänge'':2'::tsvector);
+
+INSERT INTO public.tbl_studienjahr (studienjahr_kurzbz, bezeichnung) VALUES
+('2026/27', 'Studienjahr 2026/27'),
+('2027/28', 'Studienjahr 2027/28')
+;
+
+INSERT INTO public.tbl_studiensemester (studiensemester_kurzbz, "start", ende, ext_id, bezeichnung, studienjahr_kurzbz, beschreibung, onlinebewerbung) VALUES
+('WS2026', '2026-09-01', '2027-01-31', NULL, 'Wintersemester 2026/27', '2026/27', NULL, false),
+('SS2027', '2027-02-01', '2027-07-01', NULL, 'Sommersemester 2027', '2026/27', NULL, false),
+('WS2027', '2027-09-01', '2028-01-31', NULL, 'Wintersemester 2027/28', '2027/28', NULL, false),
+('SS2028', '2028-02-01', '2028-07-01', NULL, 'Sommersemester 2028', '2027/28', NULL, false)
+;
diff --git a/system/seeders/004_ort.sql b/system/seeders/004_ort.sql
new file mode 100644
index 000000000..5c811cb09
--- /dev/null
+++ b/system/seeders/004_ort.sql
@@ -0,0 +1,25 @@
+INSERT INTO public.tbl_ort
+(ort_kurzbz, bezeichnung, planbezeichnung, max_person, lehre, reservieren, aktiv, lageplan, dislozierung, kosten, ausstattung, updateamum, updatevon, insertamum, insertvon, ext_id, stockwerk, telefonklappe, standort_id, content_id, m2, gebteil, oe_kurzbz, arbeitsplaetze)
+VALUES('EG03', 'Besprechungsruam EG03', 'EG03', 8, true, true, true, NULL, NULL, NULL, NULL, '2023-08-24 14:18:50.000', 'oesi', '2023-08-24 14:17:30.000', 'oesi', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
+INSERT INTO public.tbl_ort
+(ort_kurzbz, bezeichnung, planbezeichnung, max_person, lehre, reservieren, aktiv, lageplan, dislozierung, kosten, ausstattung, updateamum, updatevon, insertamum, insertvon, ext_id, stockwerk, telefonklappe, standort_id, content_id, m2, gebteil, oe_kurzbz, arbeitsplaetze)
+VALUES('EG01', 'Seminarraum EG01', 'EG01', 10, true, true, true, NULL, NULL, NULL, NULL, '2023-08-24 14:18:52.000', 'oesi', '2023-08-24 14:16:47.000', 'oesi', NULL, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
+INSERT INTO public.tbl_ort
+(ort_kurzbz, bezeichnung, planbezeichnung, max_person, lehre, reservieren, aktiv, lageplan, dislozierung, kosten, ausstattung, updateamum, updatevon, insertamum, insertvon, ext_id, stockwerk, telefonklappe, standort_id, content_id, m2, gebteil, oe_kurzbz, arbeitsplaetze)
+VALUES('EG02', 'Seminarraum EG02', 'EG02', 10, true, true, true, NULL, NULL, NULL, NULL, '2023-08-24 14:18:54.000', 'oesi', '2023-08-24 14:16:56.000', 'oesi', NULL, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
+INSERT INTO public.tbl_ort
+(ort_kurzbz, bezeichnung, planbezeichnung, max_person, lehre, reservieren, aktiv, lageplan, dislozierung, kosten, ausstattung, updateamum, updatevon, insertamum, insertvon, ext_id, stockwerk, telefonklappe, standort_id, content_id, m2, gebteil, oe_kurzbz, arbeitsplaetze)
+VALUES('EG04', 'EDV EG04', 'EG04', 20, true, true, true, NULL, NULL, NULL, NULL, '2023-08-24 14:18:55.000', 'oesi', '2023-08-24 14:17:49.000', 'oesi', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
+
+INSERT INTO public.tbl_ortraumtyp
+(ort_kurzbz, hierarchie, raumtyp_kurzbz)
+VALUES('EG01', 1, 'SEM');
+INSERT INTO public.tbl_ortraumtyp
+(ort_kurzbz, hierarchie, raumtyp_kurzbz)
+VALUES('EG02', 1, 'SEM');
+INSERT INTO public.tbl_ortraumtyp
+(ort_kurzbz, hierarchie, raumtyp_kurzbz)
+VALUES('EG04', 1, 'EDV2');
+INSERT INTO public.tbl_ortraumtyp
+(ort_kurzbz, hierarchie, raumtyp_kurzbz)
+VALUES('EG04', 2, 'EDV5/6');
diff --git a/system/seeders/005_studenten_stg_1.sql b/system/seeders/005_studenten_stg_1.sql
new file mode 100644
index 000000000..e94249826
--- /dev/null
+++ b/system/seeders/005_studenten_stg_1.sql
@@ -0,0 +1,114 @@
+-- Demodaten fuer Studierende im Studiengang 1
+
+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(1, 1, '1', 'WS2023', NULL, 'BS1-WS2023', 180.00, 'Studiengang 1', 'Studiengang 1', 'STG1', NULL, '2023-08-24 14:25:41.728', 'oesi', 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(1, 1, 'VZ', '1', 'BS1-WS2023-VZ', 6, NULL, true, 15, true, '2023-08-24 14:26:12.751', 'oesi', NULL, NULL, NULL, 120.00, NULL, NULL, true);
+
+INSERT INTO lehre.tbl_studienplan_semester
+( studienplan_id, studiensemester_kurzbz, semester)
+VALUES(1, 'WS2025', 1);
+INSERT INTO lehre.tbl_studienplan_semester
+(studienplan_id, studiensemester_kurzbz, semester)
+VALUES(1, 'SS2026', 1);
+
+-- Personen
+INSERT INTO public.tbl_person (person_id, staatsbuergerschaft,geburtsnation,sprache,anrede,titelpost,titelpre,vorname,nachname,vornamen,gebdatum,gebort,gebzeit,foto,anmerkung,homepage,svnr,ersatzkennzeichen,familienstand,geschlecht,anzahlkinder,aktiv,insertamum,insertvon,updateamum,updatevon,ext_id,bundesland_code,kompetenzen,kurzbeschreibung,zugangscode,foto_sperre,udf_values,bpk,matr_aktiv,matr_nr,zugangscode_timestamp,wahlname,unruly) VALUES
+
+(101,'A','A',NULL,'Herr',NULL,NULL,'Leonardo','DiCaprio',NULL,'1974-11-11',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c101',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(102,'A','A',NULL,'Herr',NULL,NULL,'Robert','Downey Jr.',NULL,'1965-04-04',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c102',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(103,'A','A',NULL,'Herr',NULL,NULL,'Denzel','Washington',NULL,'1954-12-28',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c103',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(104,'A','A',NULL,'Herr',NULL,NULL,'Al','Pacino',NULL,'1940-04-25',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c104',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(105,'A','A',NULL,'Herr',NULL,NULL,'Morgan','Freeman',NULL,'1937-06-01',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c105',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(106,'A','A',NULL,'Herr',NULL,NULL,'Daniel','Day-Lewis',NULL,'1957-04-29',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c106',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(107,'A','A',NULL,'Herr',NULL,NULL,'Marlon','Brando',NULL,'1924-04-03',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c107',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(108,'A','A',NULL,'Herr',NULL,NULL,'Tom','Hanks',NULL,'1956-07-09',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c108',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(109,'A','A',NULL,'Herr',NULL,NULL,'Johnny','Depp',NULL,'1963-06-09',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c109',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(110,'A','A',NULL,'Herr',NULL,NULL,'Brad','Pitt',NULL,'1963-12-18',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c8110',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(111,'A','A',NULL,'Herr',NULL,NULL,'Will','Smith',NULL,'1968-09-25',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c111',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(112,'A','A',NULL,'Herr',NULL,NULL,'Keanu','Reeves',NULL,'1964-09-02',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c112',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(113,'A','A',NULL,'Herr',NULL,NULL,'Robert','De Niro',NULL,'1943-08-17',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647113',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(114,'A','A',NULL,'Herr',NULL,NULL,'Harrison','Ford',NULL,'1942-07-13',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c114',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(115,'A','A',NULL,'Herr',NULL,NULL,'Samuel L.','Jackson',NULL,'1948-12-21',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c115',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(116,'A','A',NULL,'Herr',NULL,NULL,'Christian','Bale',NULL,'1974-01-30',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c116',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(117,'A','A',NULL,'Herr',NULL,NULL,'Tom','Cruise',NULL,'1962-07-03',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c1117',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(118,'A','A',NULL,'Herr',NULL,NULL,'Arnold','Schwarzenegger',NULL,'1947-07-30',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c118',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(119,'A','A',NULL,'Herr',NULL,NULL,'Sylvester','Stallone',NULL,'1946-07-06',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c119',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(120,'A','A',NULL,'Herr',NULL,NULL,'Karl','Klammer',NULL,'1963-06-09',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c120',false,NULL,NULL,false,NULL,NULL,NULL,false)
+;
+SELECT setval('public.tbl_person_person_id_seq', 1000,true);
+
+-- Prestudenten
+INSERT INTO public.tbl_prestudent (prestudent_id, person_id,studiengang_kz,aufmerksamdurch_kurzbz,berufstaetigkeit_code,ausbildungcode,zgv_code,zgvort,zgvdatum,zgvmas_code,zgvmaort,zgvmadatum,aufnahmeschluessel,facheinschlberuf,reihungstest_id,anmeldungreihungstest,reihungstestangetreten,rt_gesamtpunkte,bismelden,insertamum,insertvon,updateamum,updatevon,ext_id,anmerkung,dual,rt_punkte1,rt_punkte2,ausstellungsstaat,rt_punkte3,udf_values,priorisierung,zgvdoktor_code,zgvdoktorort,zgvdoktordatum,mentor,zgvnation,zgvmanation,zgvdoktornation,gsstudientyp_kurzbz,aufnahmegruppe_kurzbz,foerderrelevant,standort_code,zgv_erfuellt,zgvmas_erfuellt,zgvdoktor_erfuellt) VALUES
+ (1101,101,1,'k.A.',NULL,NULL,9,NULL,'2020-06-01',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (1102,102,1,'k.A.',NULL,NULL,9,NULL,'2020-06-02',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (1103,103,1,'k.A.',NULL,NULL,9,NULL,'2020-06-03',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (1104,104,1,'k.A.',NULL,NULL,9,NULL,'2020-06-04',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (1105,105,1,'k.A.',NULL,NULL,9,NULL,'2020-06-05',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (1106,106,1,'k.A.',NULL,NULL,9,NULL,'2020-06-06',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (1107,107,1,'k.A.',NULL,NULL,9,NULL,'2020-06-07',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (1108,108,1,'k.A.',NULL,NULL,9,NULL,'2020-06-08',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (1109,109,1,'k.A.',NULL,NULL,9,NULL,'2020-06-09',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (1110,110,1,'k.A.',NULL,NULL,9,NULL,'2020-06-10',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (1111,111,1,'k.A.',NULL,NULL,9,NULL,'2020-06-01',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (1112,112,1,'k.A.',NULL,NULL,9,NULL,'2020-06-02',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (1113,113,1,'k.A.',NULL,NULL,9,NULL,'2020-06-03',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (1114,114,1,'k.A.',NULL,NULL,9,NULL,'2020-06-04',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (1115,115,1,'k.A.',NULL,NULL,9,NULL,'2020-06-05',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (1116,116,1,'k.A.',NULL,NULL,9,NULL,'2020-06-06',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (1117,117,1,'k.A.',NULL,NULL,9,NULL,'2020-06-07',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (1118,118,1,'k.A.',NULL,NULL,9,NULL,'2020-06-08',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (1119,119,1,'k.A.',NULL,NULL,9,NULL,'2020-06-09',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (1120,120,1,'k.A.',NULL,NULL,9,NULL,'2020-06-10',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false)
+;
+SELECT setval('tbl_prestudent_prestudent_id_seq', 2000,true);
+
+-- Prestudent Status
+
+WITH status AS (
+ SELECT * FROM (VALUES
+ ('Interessent','WS2025',1,'2025-01-15','2023-08-24 14:56:04','auto',NULL,NULL,NULL,1,'2023-08-24','demoadmin',NULL,false,NULL,NULL,NULL,NULL),
+ ('Bewerber','WS2025',1,'2025-02-20','2023-08-24 14:58:11','auto',NULL,NULL,NULL,1,'2023-08-24','demoadmin',NULL,false,NULL,NULL,NULL,NULL),
+ ('Aufgenommener','WS2025',1,'2025-05-01','2023-08-24 14:58:14','auto',NULL,NULL,NULL,1,'2023-08-24','demoadmin',NULL,false,NULL,NULL,NULL,NULL),
+ ('Student','WS2025',1,'2025-08-24','2023-08-24 14:58:16','auto',NULL,NULL,NULL,1,'2023-08-24','demoadmin',NULL,false,NULL,NULL,NULL,NULL)
+ ) AS t(status_kurzbz,studiensemester_kurzbz,ausbildungssemester,datum,insertamum,insertvon,updateamum,updatevon,orgform_kurzbz,studienplan_id,bestaetigtam,bestaetigtvon,fgm,faktiv,anmerkung,bewerbung_abgeschicktamum,rt_stufe,statusgrund_id)
+),
+inserts AS (
+ SELECT generate_series(1101, 1120) AS prestudent_id,*
+ FROM status
+)
+INSERT INTO public.tbl_prestudentstatus (prestudent_id, status_kurzbz,studiensemester_kurzbz,ausbildungssemester,datum,insertamum,insertvon,updateamum,updatevon,orgform_kurzbz,studienplan_id,bestaetigtam,bestaetigtvon,fgm,faktiv,anmerkung,bewerbung_abgeschicktamum,rt_stufe,statusgrund_id)
+SELECT prestudent_id, status_kurzbz, studiensemester_kurzbz, ausbildungssemester, datum::date, insertamum::timestamp, insertvon, updateamum::timestamp, updatevon,orgform_kurzbz,studienplan_id,bestaetigtam::date,bestaetigtvon,fgm::smallint,faktiv,anmerkung,bewerbung_abgeschicktamum::timestamp,rt_stufe::smallint,statusgrund_id::int
+FROM inserts;
+
+-- tbl_benutzer
+WITH persons AS (
+ SELECT generate_series(101, 120) AS person_id
+)
+INSERT INTO public.tbl_benutzer (person_id, uid, aktiv, alias, insertamum, insertvon, updateamum, updatevon, updateaktivam, updateaktivvon, aktivierungscode)
+SELECT person_id, 's125b' || person_id, true, null, now(), 'auto', null, null, null, false, null
+FROM persons;
+
+-- tbl_student
+WITH persons AS (
+ SELECT generate_series(101, 120) AS person_id
+)
+INSERT INTO public.tbl_student (student_uid, matrikelnr, prestudent_id, studiengang_kz, semester, verband, gruppe, updateamum, updatevon, insertamum, insertvon)
+SELECT 's125b' || person_id, '2510001' || person_id, ('1' || person_id)::integer, '1', '1', '','', null, null, now(), 'auto'
+FROM persons;
+
+-- tbl_studentlehrverband
+WITH persons AS (
+ SELECT generate_series(101, 120) AS person_id
+),
+students as (
+ SELECT ('s125b' || person_id) as student_uid, person_id
+ FROM persons
+)
+INSERT INTO public.tbl_studentlehrverband (student_uid, studiensemester_kurzbz, studiengang_kz, semester, verband, gruppe, updateamum, updatevon, insertamum, insertvon)
+SELECT student_uid, 'WS2025', '1','1','','', null, null, now(), 'auto'
+FROM students;
+
diff --git a/system/seeders/006_studiengang_3.sql b/system/seeders/006_studiengang_3.sql
new file mode 100644
index 000000000..087b741b2
--- /dev/null
+++ b/system/seeders/006_studiengang_3.sql
@@ -0,0 +1,161 @@
+-- Demodaten fuer Studiengang 3: 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('stg3', 'studiengaenge', 'Studiengang 3', '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
+ (3,'S3','STG3','b','Studiengang 3','Studiengang 3',NULL,'invalid@example.com',NULL,6,'B','2',5,NULL,NULL,NULL,NULL,NULL,NULL,true,NULL,'VZ','',true,'stg3','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, 'stg3', '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
+ (3, 1, '','', true, null, null, null),
+ (3, 1, 'A','', true, null, null, null),
+ (3, 1, 'B','', true, null, null, null),
+ (3, 2, '','', true, null, null, null),
+ (3, 2, 'A','', true, null, null, null),
+ (3, 2, 'B','', true, null, null, null),
+ (3, 3, '','', true, null, null, null),
+ (3, 3, 'A','', true, null, null, null),
+ (3, 3, 'B','', true, null, null, null),
+ (3, 4, '','', true, null, null, null),
+ (3, 4, 'A','', true, null, null, null),
+ (3, 4, 'B','', true, null, null, null),
+ (3, 5, '','', true, null, null, null),
+ (3, 5, 'A','', true, null, null, null),
+ (3, 5, 'B','', true, null, null, null),
+ (3, 6, '','', true, null, null, null),
+ (3, 6, 'A','', true, null, null, null),
+ (3, 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
+ (301, 3,'1','WS2023',NULL,'BS3-WS2023',180.00,'Studiengang 3','Studiengang 3','STG3',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
+ (3011, 301,'VZ','1','BS3-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
+ (311, 'MOD1.1','Sport',3,1,'German',8.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','oesi','2023-08-24 14:28:39','oesi',NULL,NULL,true,NULL,false,'MOD','Communication','VZ',5,'regulaer','modul','stg3',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
+ (312, 'MOD1.2','Mathematik',3,1,'German',8.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','oesi','2023-08-24 14:28:39','oesi',NULL,NULL,true,NULL,false,'MOD','Mathematics','VZ',5,'regulaer','modul','stg3',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
+ (313, 'MOD1.3','Grundlagen',3,1,'German',8.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','oesi','2023-08-24 14:28:39','oesi',NULL,NULL,true,NULL,false,'MOD','Basics','VZ',5,'regulaer','modul','stg3',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
+ (314, 'MOD1.4','Sprachen',3,1,'English',6.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','oesi','2023-08-24 14:28:39','oesi',NULL,NULL,true,NULL,false,'MOD','Communication','VZ',5,'regulaer','modul','stg3',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
+ (321, 'MOD2.1','Labor',3,2,'German',10.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','oesi','2023-08-24 14:28:39','oesi',NULL,NULL,true,NULL,false,'MOD','Laboratory','VZ',5,'regulaer','modul','stg3',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
+ (322, 'MOD2.2','Mathematik',3,2,'German',10.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','oesi','2023-08-24 14:28:39','oesi',NULL,NULL,true,NULL,false,'MOD','Mathematics','VZ',5,'regulaer','modul','stg3',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
+ (323, 'MOD2.3','Sprachen',3,2,'English',10.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','oesi','2023-08-24 14:28:39','oesi',NULL,NULL,true,NULL,false,'MOD','Communication','VZ',5,'regulaer','modul','stg3',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
+ (331, 'MOD3.1','Mathematik',3,3,'German',15.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','oesi','2023-08-24 14:28:39','oesi',NULL,NULL,true,NULL,false,'MOD','Mathematics','VZ',5,'regulaer','modul','stg3',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
+ (332, 'MOD3.2','Elektrische Signale',3,3,'German',15.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','oesi','2023-08-24 14:28:39','oesi',NULL,NULL,true,NULL,false,'MOD','Electric Signals','VZ',5,'regulaer','modul','stg3',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
+ (341, 'MOD4.1','Messtechnik',3,4,'German',15.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','oesi','2023-08-24 14:28:39','oesi',NULL,NULL,true,NULL,false,'MOD','Measurement Technology','VZ',5,'regulaer','modul','stg3',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
+ (342, 'MOD4.2','Forschung',3,4,'German',15.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','oesi','2023-08-24 14:28:39','oesi',NULL,NULL,true,NULL,false,'MOD','Research','VZ',5,'regulaer','modul','stg3',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
+ (351, 'MOD5.1','Communication',3,5,'English',15.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','oesi','2023-08-24 14:28:39','oesi',NULL,NULL,true,NULL,false,'MOD','Communication','VZ',5,'regulaer','modul','stg3',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
+ (352, 'MOD5.2','Regelungstechnik',3,5,'German',15.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','oesi','2023-08-24 14:28:39','oesi',NULL,NULL,true,NULL,false,'MOD','Control Engineering','VZ',5,'regulaer','modul','stg3',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
+ (361, 'MOD6.1','Bachelorarbeit',3,6,'German',30.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','oesi','2023-08-24 14:28:39','oesi',NULL,NULL,true,NULL,false,'MOD','Bachelor Thesis','VZ',5,'regulaer','modul','stg3',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
+ (3111, 'LS','Leistungssport',3,1,'German',8.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','oesi','2023-08-24 14:28:39','oesi',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),
+ (3121, 'MAT','Mathematik 1',3,1,'German',8.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','oesi','2023-08-24 14:28:39','oesi',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),
+ (3131, 'GL','Grundlagen der Programmierung',3,1,'German',8.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','oesi','2023-08-24 14:28:39','oesi',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),
+ (3141, 'ENG','Englisch 1',3,1,'English',6.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','oesi','2023-08-24 14:28:39','oesi',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),
+ (3211, 'EL','Elektronik Labor',3,2,'German',10.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','oesi','2023-08-24 14:28:39','oesi',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),
+ (3221, 'MAT','Mathematik 2',3,2,'German',10.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','oesi','2023-08-24 14:28:39','oesi',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),
+ (3231, 'ENG','Communcation Englisch',3,2,'English',10.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','oesi','2023-08-24 14:28:39','oesi',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),
+ (3311, 'MAT','Mathematik 3',3,3,'German',15.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','oesi','2023-08-24 14:28:39','oesi',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),
+ (3321, 'ES','Elektrische Signale',3,3,'German',15.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','oesi','2023-08-24 14:28:39','oesi',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),
+ (3411, 'MT','Messtechnik',3,4,'German',15.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','oesi','2023-08-24 14:28:39','oesi',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),
+ (3421, 'BAN','Bewegungsanalyse',3,4,'German',15.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','oesi','2023-08-24 14:28:39','oesi',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),
+ (3511, 'ENG','Communication',3,5,'English',15.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','oesi','2023-08-24 14:28:39','oesi',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),
+ (3521, 'RT','Regelungstechnik',3,5,'German',15.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','oesi','2023-08-24 14:28:39','oesi',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),
+ (3611, 'BA','Bachelorarbeit',3,6,'German',30.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,'2023-08-24 14:28:39','oesi','2023-08-24 14:28:39','oesi',NULL,NULL,true,NULL,false,'SE','Bachelor Thesis','VZ',5,'regulaer','lv','stg3',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
+ -- MODULE im Studienplan
+ (301, 3011,311,1,NULL,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (302, 3011,312,1,NULL,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (303, 3011,313,1,NULL,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (304, 3011,314,1,NULL,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (305, 3011,321,2,NULL,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (306, 3011,322,2,NULL,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (307, 3011,323,2,NULL,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (308, 3011,331,3,NULL,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (309, 3011,332,3,NULL,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (310, 3011,341,4,NULL,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (311, 3011,342,4,NULL,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (312, 3011,351,5,NULL,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (313, 3011,352,5,NULL,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (314, 3011,361,6,NULL,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+
+ -- LVS zu Modulen
+ (315, 3011,3111,1,301,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (316, 3011,3121,1,302,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (317, 3011,3131,1,303,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (318, 3011,3141,1,304,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (319, 3011,3211,2,305,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (320, 3011,3221,2,306,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (321, 3011,3231,2,307,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (322, 3011,3311,3,308,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (323, 3011,3321,3,309,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (324, 3011,3411,4,310,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (325, 3011,3421,4,311,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (326, 3011,3511,5,312,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (327, 3011,3521,5,313,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (328, 3011,3611,6,314,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true)
+;
+
+INSERT INTO lehre.tbl_studienplan_semester (studienplan_id, studiensemester_kurzbz, semester) VALUES
+(3011, 'WS2023', 1),
+(3011, 'SS2024', 2),
+(3011, 'WS2024', 1),
+(3011, 'WS2024', 3),
+(3011, 'SS2025', 2),
+(3011, 'SS2025', 4),
+(3011, 'WS2025', 1),
+(3011, 'WS2025', 3),
+(3011, 'WS2025', 5),
+(3011, 'SS2026', 2),
+(3011, 'SS2026', 4),
+(3011, '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(31001, 3131, 'WS2025', NULL, 'VO', 2, 1, NULL, 'Dummy', 'Dummy', 'German', true, NULL, 3131, NULL, '2023-08-24 14:54:43.000', 'oesi', '2023-08-24 14:54:43.000', 'oesi', NULL, 3131, 1);
+
+INSERT INTO lehre.tbl_lehreinheitgruppe
+(lehreinheit_id, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id)
+VALUES(31001, 3, 1, 'A', NULL, NULL, NULL, NULL, '2023-08-24 14:58:26.000', 'oesi', 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(31001, 'demolektor1', 'Lektor', 0, NULL, 1.00, NULL, true, NULL, NULL, '2023-08-24 14:58:40.000', 'oesi', 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(31002, 3131, 'WS2025', NULL, 'VO', 2, 1, NULL, 'Dummy', 'Dummy', 'German', true, NULL, 3131, NULL, '2023-08-24 14:54:43.000', 'oesi', '2023-08-24 14:54:43.000', 'oesi', NULL, 3131, 1);
+
+INSERT INTO lehre.tbl_lehreinheitgruppe
+(lehreinheit_id, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id)
+VALUES(31002, 3, 1, 'B', NULL, NULL, NULL, NULL, '2023-08-24 14:58:26.000', 'oesi', 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(31002, 'demolektor1', 'Lektor', 0, NULL, 1.00, NULL, true, NULL, NULL, '2023-08-24 14:58:40.000', 'oesi', 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(310011, 31001, 31001, 3, 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(310011, 31001, 31001, 3, 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(310021, 31002, 31002, 3, 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(310021, 31002, 31002, 3, 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);
diff --git a/system/seeders/007_studenten_stg_3.sql b/system/seeders/007_studenten_stg_3.sql
new file mode 100644
index 000000000..89b0074bf
--- /dev/null
+++ b/system/seeders/007_studenten_stg_3.sql
@@ -0,0 +1,120 @@
+-- Demodaten fuer Studierende im Studiengang 3
+-- Personen
+INSERT INTO public.tbl_person (person_id, staatsbuergerschaft,geburtsnation,sprache,anrede,titelpost,titelpre,vorname,nachname,vornamen,gebdatum,gebort,gebzeit,foto,anmerkung,homepage,svnr,ersatzkennzeichen,familienstand,geschlecht,anzahlkinder,aktiv,insertamum,insertvon,updateamum,updatevon,ext_id,bundesland_code,kompetenzen,kurzbeschreibung,zugangscode,foto_sperre,udf_values,bpk,matr_aktiv,matr_nr,zugangscode_timestamp,wahlname,unruly) VALUES
+
+(301,'A','A',NULL,'Herr',NULL,NULL,'Elon','Musk',NULL,'1974-11-11',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c301',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(302,'A','A',NULL,'Herr',NULL,NULL,'Steve','Jobs',NULL,'1965-04-04',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c302',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(303,'A','A',NULL,'Herr',NULL,NULL,'Bill','Gates',NULL,'1954-12-28',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c303',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(304,'A','A',NULL,'Herr',NULL,NULL,'Linus','Torvalds',NULL,'1940-04-25',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c304',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(305,'A','A',NULL,'Herr',NULL,NULL,'Mark','Zuckerberg',NULL,'1937-06-01',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c305',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(306,'A','A',NULL,'Frau',NULL,NULL,'Ada','Lovelace',NULL,'1957-04-29',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'w',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c306',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(307,'A','A',NULL,'Herr',NULL,NULL,'Tim','Berners-Lee',NULL,'1924-04-03',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c307',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(308,'A','A',NULL,'Frau',NULL,NULL,'Grace','Hopper',NULL,'1956-07-09',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'w',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c308',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(309,'A','A',NULL,'Herr',NULL,NULL,'Jeff','Bezos',NULL,'1963-06-09',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c309',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(310,'A','A',NULL,'Herr',NULL,NULL,'Larry','Page',NULL,'1963-12-18',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c8310',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(311,'A','A',NULL,'Herr',NULL,NULL,'Sergey','Brin',NULL,'1968-09-25',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c311',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(312,'A','A',NULL,'Frau',NULL,NULL,'Katherine','Johnson',NULL,'1964-09-02',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'w',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c312',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(313,'A','A',NULL,'Herr',NULL,NULL,'Dennis','Ritchie',NULL,'1943-08-17',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647313',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(314,'A','A',NULL,'Frau',NULL,NULL,'Margaret','Hamilton',NULL,'1942-07-13',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'w',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c314',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(315,'A','A',NULL,'Herr',NULL,NULL,'Satya','Nadella',NULL,'1948-12-21',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c315',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(316,'A','A',NULL,'Frau',NULL,NULL,'Susan','Wojcicki',NULL,'1974-01-30',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'w',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c316',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(317,'A','A',NULL,'Frau',NULL,NULL,'Sheryl','Sandberg',NULL,'1962-07-03',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'w',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c317',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(318,'A','A',NULL,'Herr',NULL,NULL,'Jack','Dorsey',NULL,'1947-07-30',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c318',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(319,'A','A',NULL,'Frau',NULL,NULL,'Ginni','Rometty',NULL,'1946-07-06',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'w',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c319',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(320,'A','A',NULL,'Herr',NULL,NULL,'Sundar','Pichai',NULL,'1963-06-09',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c320',false,NULL,NULL,false,NULL,NULL,NULL,false)
+;
+SELECT setval('public.tbl_person_person_id_seq', 1000,true);
+
+-- Prestudenten
+INSERT INTO public.tbl_prestudent (prestudent_id, person_id,studiengang_kz,aufmerksamdurch_kurzbz,berufstaetigkeit_code,ausbildungcode,zgv_code,zgvort,zgvdatum,zgvmas_code,zgvmaort,zgvmadatum,aufnahmeschluessel,facheinschlberuf,reihungstest_id,anmeldungreihungstest,reihungstestangetreten,rt_gesamtpunkte,bismelden,insertamum,insertvon,updateamum,updatevon,ext_id,anmerkung,dual,rt_punkte1,rt_punkte2,ausstellungsstaat,rt_punkte3,udf_values,priorisierung,zgvdoktor_code,zgvdoktorort,zgvdoktordatum,mentor,zgvnation,zgvmanation,zgvdoktornation,gsstudientyp_kurzbz,aufnahmegruppe_kurzbz,foerderrelevant,standort_code,zgv_erfuellt,zgvmas_erfuellt,zgvdoktor_erfuellt) VALUES
+ (3301,301,3,'k.A.',NULL,NULL,9,NULL,'2020-06-01',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (3302,302,3,'k.A.',NULL,NULL,9,NULL,'2020-06-02',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (3303,303,3,'k.A.',NULL,NULL,9,NULL,'2020-06-03',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (3304,304,3,'k.A.',NULL,NULL,9,NULL,'2020-06-04',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (3305,305,3,'k.A.',NULL,NULL,9,NULL,'2020-06-05',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (3306,306,3,'k.A.',NULL,NULL,9,NULL,'2020-06-06',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (3307,307,3,'k.A.',NULL,NULL,9,NULL,'2020-06-07',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (3308,308,3,'k.A.',NULL,NULL,9,NULL,'2020-06-08',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (3309,309,3,'k.A.',NULL,NULL,9,NULL,'2020-06-09',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (3310,310,3,'k.A.',NULL,NULL,9,NULL,'2020-06-10',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (3311,311,3,'k.A.',NULL,NULL,9,NULL,'2020-06-01',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (3312,312,3,'k.A.',NULL,NULL,9,NULL,'2020-06-02',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (3313,313,3,'k.A.',NULL,NULL,9,NULL,'2020-06-03',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (3314,314,3,'k.A.',NULL,NULL,9,NULL,'2020-06-04',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (3315,315,3,'k.A.',NULL,NULL,9,NULL,'2020-06-05',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (3316,316,3,'k.A.',NULL,NULL,9,NULL,'2020-06-06',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (3317,317,3,'k.A.',NULL,NULL,9,NULL,'2020-06-07',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (3318,318,3,'k.A.',NULL,NULL,9,NULL,'2020-06-08',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (3319,319,3,'k.A.',NULL,NULL,9,NULL,'2020-06-09',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (3320,320,3,'k.A.',NULL,NULL,9,NULL,'2020-06-10',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false)
+;
+SELECT setval('tbl_prestudent_prestudent_id_seq', (SELECT max(prestudent_id) FROM public.tbl_prestudent),true);
+
+-- Prestudent Status
+
+WITH status AS (
+ SELECT * FROM (VALUES
+ ('Interessent','WS2025',1,'2025-01-15','2023-08-24 14:56:04','auto',NULL,NULL,NULL,3011,'2023-08-24','demoadmin',NULL,false,NULL,NULL,NULL,NULL),
+ ('Bewerber','WS2025',1,'2025-02-20','2023-08-24 14:58:11','auto',NULL,NULL,NULL,3011,'2023-08-24','demoadmin',NULL,false,NULL,NULL,NULL,NULL),
+ ('Aufgenommener','WS2025',1,'2025-05-01','2023-08-24 14:58:14','auto',NULL,NULL,NULL,3011,'2023-08-24','demoadmin',NULL,false,NULL,NULL,NULL,NULL),
+ ('Student','WS2025',1,'2025-08-24','2023-08-24 14:58:16','auto',NULL,NULL,NULL,3011,'2023-08-24','demoadmin',NULL,false,NULL,NULL,NULL,NULL)
+ ) AS t(status_kurzbz,studiensemester_kurzbz,ausbildungssemester,datum,insertamum,insertvon,updateamum,updatevon,orgform_kurzbz,studienplan_id,bestaetigtam,bestaetigtvon,fgm,faktiv,anmerkung,bewerbung_abgeschicktamum,rt_stufe,statusgrund_id)
+),
+inserts AS (
+ SELECT generate_series(3301, 3320) AS prestudent_id,*
+ FROM status
+)
+INSERT INTO public.tbl_prestudentstatus (prestudent_id, status_kurzbz,studiensemester_kurzbz,ausbildungssemester,datum,insertamum,insertvon,updateamum,updatevon,orgform_kurzbz,studienplan_id,bestaetigtam,bestaetigtvon,fgm,faktiv,anmerkung,bewerbung_abgeschicktamum,rt_stufe,statusgrund_id)
+SELECT prestudent_id, status_kurzbz, studiensemester_kurzbz, ausbildungssemester, datum::date, insertamum::timestamp, insertvon, updateamum::timestamp, updatevon,orgform_kurzbz,studienplan_id,bestaetigtam::date,bestaetigtvon,fgm::smallint,faktiv,anmerkung,bewerbung_abgeschicktamum::timestamp,rt_stufe::smallint,statusgrund_id::int
+FROM inserts;
+
+-- tbl_benutzer
+WITH persons AS (
+ SELECT generate_series(301, 320) AS person_id
+)
+INSERT INTO public.tbl_benutzer (person_id, uid, aktiv, alias, insertamum, insertvon, updateamum, updatevon, updateaktivam, updateaktivvon, aktivierungscode)
+SELECT person_id, 's325b' || person_id, true, null, now(), 'auto', null, null, null, false, null
+FROM persons;
+
+-- tbl_student
+-- A Verband
+WITH persons AS (
+ SELECT generate_series(301, 310) AS person_id
+)
+INSERT INTO public.tbl_student (student_uid, matrikelnr, prestudent_id, studiengang_kz, semester, verband, gruppe, updateamum, updatevon, insertamum, insertvon)
+SELECT 's325b' || person_id, '2510003' || person_id, ('3' || person_id)::integer, '3', '1', 'A','', null, null, now(), 'auto'
+FROM persons;
+
+-- B Verband
+WITH persons AS (
+ SELECT generate_series(311, 320) AS person_id
+)
+INSERT INTO public.tbl_student (student_uid, matrikelnr, prestudent_id, studiengang_kz, semester, verband, gruppe, updateamum, updatevon, insertamum, insertvon)
+SELECT 's325b' || person_id, '2510003' || person_id, ('3' || person_id)::integer, '3', '1', 'B','', null, null, now(), 'auto'
+FROM persons;
+
+-- tbl_studentlehrverband
+-- A Verband
+WITH persons AS (
+ SELECT generate_series(301, 310) AS person_id
+),
+students as (
+ SELECT ('s325b' || person_id) as student_uid, person_id
+ FROM persons
+)
+INSERT INTO public.tbl_studentlehrverband (student_uid, studiensemester_kurzbz, studiengang_kz, semester, verband, gruppe, updateamum, updatevon, insertamum, insertvon)
+SELECT student_uid, 'WS2025', '3','1','A','', null, null, now(), 'auto'
+FROM students;
+
+-- B Verband
+WITH persons AS (
+ SELECT generate_series(311, 320) AS person_id
+),
+students as (
+ SELECT ('s325b' || person_id) as student_uid, person_id
+ FROM persons
+)
+INSERT INTO public.tbl_studentlehrverband (student_uid, studiensemester_kurzbz, studiengang_kz, semester, verband, gruppe, updateamum, updatevon, insertamum, insertvon)
+SELECT student_uid, 'WS2025', '3','1','B','', null, null, now(), 'auto'
+FROM students;
+
diff --git a/system/seeders/008_studiengang_4.sql b/system/seeders/008_studiengang_4.sql
new file mode 100644
index 000000000..957e14c81
--- /dev/null
+++ b/system/seeders/008_studiengang_4.sql
@@ -0,0 +1,239 @@
+-- Demodaten fuer Studiengang 4: Organisationseinheit, Studienordnung, Studiengang, Lehrveranstaltungen, Lehreinheiten
+-- Testdaten fuer LV-Verwaltung, Tempus
+
+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('stg4', 'studiengaenge', 'Tempus Examples', '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
+ (4,'S4','STG4','b','Tempus Beispiele','Tempus Examples',NULL,'invalid@example.com',NULL,6,'B','2',5,NULL,NULL,NULL,NULL,NULL,NULL,true,NULL,'VZ','',true,'stg4','German',true,NULL,NULL,false,true,true,true,NULL,false,'0004')
+;
+
+INSERT INTO public.tbl_lehrverband (studiengang_kz, semester, verband, gruppe, aktiv, bezeichnung, ext_id, orgform_kurzbz) VALUES
+ (4, 1, '','', true, null, null, null),
+ (4, 1, 'A','', true, null, null, null),
+ (4, 1, 'B','', true, null, null, null),
+ (4, 2, '','', true, null, null, null),
+ (4, 2, 'A','', true, null, null, null),
+ (4, 2, 'B','', true, null, null, null),
+ (4, 3, '','', true, null, null, null),
+ (4, 3, 'A','', true, null, null, null),
+ (4, 3, 'B','', true, null, null, null),
+ (4, 4, '','', true, null, null, null),
+ (4, 4, 'A','', true, null, null, null),
+ (4, 4, 'B','', true, null, null, null),
+ (4, 5, '','', true, null, null, null),
+ (4, 5, 'A','', true, null, null, null),
+ (4, 5, 'B','', true, null, null, null),
+ (4, 6, '','', true, null, null, null),
+ (4, 6, 'A','', true, null, null, null),
+ (4, 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
+ (401, 4,'1',NearestWintersemester(-4),NULL,'BS4'||NearestWintersemester(-4),180.00,'Tempus Beispiele','Tempus Examples','STG4',NULL,now(),'anondata',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
+ (4011, 401,'VZ','1','BS4-'||NearestWintersemester(-4)||'-VZ',6,NULL,true,15,true,now(),'anondata',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
+ (411, 'MOD1.1','Standard',4,1,'German',8.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,now(),'anondata',now(),'anondata',NULL,NULL,true,NULL,false,'MOD','Standard','VZ',5,'regulaer','modul','stg4',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
+ (412, 'MOD1.2','Advanced',4,1,'German',8.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,now(),'anondata',now(),'anondata',NULL,NULL,true,NULL,false,'MOD','Advanced','VZ',5,'regulaer','modul','stg4',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
+ (413, 'MOD1.3','Wahlmodul',4,1,'German',8.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,now(),'anondata',now(),'anondata',NULL,NULL,true,NULL,false,'MOD','Wahlmodul','VZ',5,'regulaer','modul','stg4',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
+ (414, 'MOD1.4','Sonstiges',4,1,'English',6.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,now(),'anondata',now(),'anondata',NULL,NULL,true,NULL,false,'MOD','Sonstiges','VZ',5,'regulaer','modul','stg4',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
+
+ (461, 'MOD6.1','Bachelorarbeit',4,6,'German',8.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,now(),'anondata',now(),'anondata',NULL,NULL,true,NULL,false,'MOD','Bachelorarbeit','VZ',5,'regulaer','modul','stg4',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
+ (462, 'MOD6.2','International Skills',4,6,'English',5.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,now(),'anondata',now(),'anondata',NULL,NULL,true,NULL,false,'MOD','International Skills','VZ',5,'regulaer','modul','stg4',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,false,true,true,NULL),
+ (463, 'MOD6.3','Berufspraktikum',4,6,'German',17.00,NULL,NULL,true,'mod',true,NULL,NULL,NULL,NULL,now(),'anondata',now(),'anondata',NULL,NULL,true,NULL,false,'MOD','Berufspraktikum','VZ',5,'regulaer','modul','stg4',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
+ (4111, 'S1','Standardvorlesung',4,1,'German',8.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,now(),'anondata',now(),'anondata',NULL,NULL,true,NULL,false,'VO','Standardvorlesung','VZ',5,'regulaer','lv','kfSprachen',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,true,true,true,NULL),
+ (4121, 'S2','Vorlesung und Uebung',4,1,'German',8.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,now(),'anondata',now(),'anondata',NULL,NULL,true,NULL,false,'ILV','VorlesungUebung','VZ',5,'regulaer','lv','kfMath',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,true,true,true,NULL),
+ (4131, 'S3','Getrennte Gruppen',4,1,'German',8.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,now(),'anondata',now(),'anondata',NULL,NULL,true,NULL,false,'ILV','Splittet Groups','VZ',5,'regulaer','lv','kfTech',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,true,true,true,NULL),
+ (4141, 'A1','Mehrere Lektoren',4,1,'English',6.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,now(),'anondata',now(),'anondata',NULL,NULL,true,NULL,false,'VO','Multilektor','VZ',5,'regulaer','lv','kfSprachen',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,true,true,true,NULL),
+
+ (4611, 'BA','Bachelorarbeit',4,6,'German',8.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,now(),'anondata',now(),'anondata',NULL,NULL,true,NULL,false,'EL','Bachelorarbeit','VZ',5,'regulaer','lv','kfSprachen',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,true,true,true,NULL),
+ (4621, 'INT','International Skills',4,6,'English',5.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,now(),'anondata',now(),'anondata',NULL,NULL,true,NULL,false,'PRJ','International Skills','VZ',5,'regulaer','lv','kfSprachen',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,true,true,true,NULL),
+ (4631, 'BPRAK','Berufspraktikum',4,6,'German',17.00,NULL,NULL,true,'lv',true,NULL,NULL,NULL,NULL,now(),'anondata',now(),'anondata',NULL,NULL,true,NULL,false,'SO','Berufspraktikum','VZ',5,'regulaer','lv','kfSprachen',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
+ -- MODULE im Studienplan
+ (401, 4011,411,1,NULL,true,NULL,now(),'anondata',NULL,NULL,NULL,NULL,true,true,true),
+ (402, 4011,412,1,NULL,true,NULL,now(),'anondata',NULL,NULL,NULL,NULL,true,true,true),
+ (403, 4011,413,1,NULL,true,NULL,now(),'anondata',NULL,NULL,NULL,NULL,true,true,true),
+ (404, 4011,414,1,NULL,true,NULL,now(),'anondata',NULL,NULL,NULL,NULL,true,true,true),
+
+ (461, 4011,461,6,NULL,true,NULL,now(),'anondata',NULL,NULL,NULL,NULL,true,true,true),
+ (462, 4011,462,6,NULL,true,NULL,now(),'anondata',NULL,NULL,NULL,NULL,true,true,true),
+ (463, 4011,463,6,NULL,true,NULL,now(),'anondata',NULL,NULL,NULL,NULL,true,true,true),
+
+ -- LVS zu Modulen
+ (415, 4011,4111,1,401,true,NULL,now(),'anondata',NULL,NULL,NULL,NULL,true,true,true),
+ (416, 4011,4121,1,401,true,NULL,now(),'anondata',NULL,NULL,NULL,NULL,true,true,true),
+ (417, 4011,4131,1,401,true,NULL,now(),'anondata',NULL,NULL,NULL,NULL,true,true,true),
+ (418, 4011,4141,1,402,true,NULL,now(),'anondata',NULL,NULL,NULL,NULL,true,true,true),
+
+ (465, 4011,4611,6,461,true,NULL,now(),'anondata',NULL,NULL,NULL,NULL,true,true,true),
+ (466, 4011,4621,6,462,true,NULL,now(),'anondata',NULL,NULL,NULL,NULL,true,true,true),
+ (467, 4011,4631,6,463,true,NULL,now(),'anondata',NULL,NULL,NULL,NULL,true,true,true)
+;
+
+INSERT INTO lehre.tbl_studienplan_semester (studienplan_id, studiensemester_kurzbz, semester) VALUES
+(4011, NearestWintersemester(-4), 1),
+(4011, NearestWintersemester(-3), 2),
+(4011, NearestWintersemester(-2), 1),
+(4011, NearestWintersemester(-2), 3),
+(4011, NearestWintersemester(-1), 2),
+(4011, NearestWintersemester(-1), 4),
+(4011, NearestWintersemester(0), 1),
+(4011, NearestWintersemester(0), 3),
+(4011, NearestWintersemester(0), 5),
+(4011, NearestWintersemester(+1), 2),
+(4011, NearestWintersemester(+1), 4),
+(4011, NearestWintersemester(+1), 6);
+
+-- ===== Standardvorlesung
+INSERT INTO lehre.tbl_lehreinheit (lehreinheit_id, lehrveranstaltung_id, studiensemester_kurzbz, lehrfach_id_old, lehrform_kurzbz, stundenblockung, wochenrythmus, start_kw, raumtyp, raumtypalternativ, sprache, lehre, unr, lvnr, updateamum, updatevon, insertamum, insertvon, ext_id, lehrfach_id, gewicht, anmerkung)
+VALUES(41001, 4111, NearestWintersemester(0), NULL, 'VO', 2, 1, NULL, 'Dummy', 'Dummy', 'German', true, 4111, NULL, '2023-08-24 14:54:43.000', 'anondata', '2023-08-24 14:54:43.000', 'anondata', NULL, 4111, 1, 'Standard ein LV-Teil, ein Lektor, eine Gruppe');
+
+INSERT INTO lehre.tbl_lehreinheitgruppe
+(lehreinheit_id, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id)
+VALUES(41001, 4, '1', '', NULL, NULL, NULL, NULL, '2023-08-24 14:58:26.000', 'anondata', 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(41001, 'demolektor1', 'Lektor', 0, NULL, 1.00, NULL, true, NULL, NULL, '2023-08-24 14:58:40.000', 'anondata', NULL, 0.00, NULL, NULL);
+
+-- ===== VORLESUNG UND UEBUNG
+INSERT INTO lehre.tbl_lehreinheit (lehreinheit_id, lehrveranstaltung_id, studiensemester_kurzbz, lehrfach_id_old, lehrform_kurzbz, stundenblockung, wochenrythmus, start_kw, raumtyp, raumtypalternativ, sprache, lehre, unr, lvnr, updateamum, updatevon, insertamum, insertvon, ext_id, lehrfach_id, gewicht, anmerkung)
+VALUES(41002, 4121, NearestWintersemester(0), NULL, 'VO', 2, 1, NULL, 'Dummy', 'Dummy', 'German', true, 4121, NULL, '2023-08-24 14:54:43.000', 'anondata', '2023-08-24 14:54:43.000', 'anondata', NULL, 4121, 1, 'Vorlesung');
+
+INSERT INTO lehre.tbl_lehreinheitgruppe
+(lehreinheit_id, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id)
+VALUES(41002, 4, '1', '', NULL, NULL, NULL, NULL, '2023-08-24 14:58:26.000', 'anondata', 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(41002, 'demolektor1', 'Lektor', 0, NULL, 1.00, NULL, true, NULL, NULL, '2023-08-24 14:58:40.000', 'anondata', 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, unr, lvnr, updateamum, updatevon, insertamum, insertvon, ext_id, lehrfach_id, gewicht, anmerkung)
+VALUES(41003, 4121, NearestWintersemester(0), NULL, 'UE', 2, 1, NULL, 'Dummy', 'Dummy', 'German', true, 4121, NULL, '2023-08-24 14:54:43.000', 'anondata', '2023-08-24 14:54:43.000', 'anondata', NULL, 4121, 1, 'Uebung');
+
+INSERT INTO lehre.tbl_lehreinheitgruppe
+(lehreinheit_id, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id)
+VALUES(41003, 4, '1', '', NULL, NULL, NULL, NULL, '2023-08-24 14:58:26.000', 'anondata', 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(41003, 'demolektor1', 'Lektor', 0, NULL, 1.00, NULL, true, NULL, NULL, '2023-08-24 14:58:40.000', 'anondata', NULL, 0.00, NULL, NULL);
+
+
+-- ======= GETRENNTE GRUPPEN
+INSERT INTO lehre.tbl_lehreinheit (lehreinheit_id, lehrveranstaltung_id, studiensemester_kurzbz, lehrfach_id_old, lehrform_kurzbz, stundenblockung, wochenrythmus, start_kw, raumtyp, raumtypalternativ, sprache, lehre, unr, lvnr, updateamum, updatevon, insertamum, insertvon, ext_id, lehrfach_id, gewicht, anmerkung)
+VALUES(41004, 4131, NearestWintersemester(0), NULL, 'VO', 2, 1, NULL, 'Dummy', 'Dummy', 'German', true, 4131, NULL, '2023-08-24 14:54:43.000', 'anondata', '2023-08-24 14:54:43.000', 'anondata', NULL, 4131, 1, 'Vorlesung 1A');
+
+INSERT INTO lehre.tbl_lehreinheitgruppe
+(lehreinheit_id, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id)
+VALUES(41004, 4, '1', 'A', NULL, NULL, NULL, NULL, '2023-08-24 14:58:26.000', 'anondata', 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(41004, 'demolektor1', 'Lektor', 0, NULL, 1.00, NULL, true, NULL, NULL, '2023-08-24 14:58:40.000', 'anondata', 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, unr, lvnr, updateamum, updatevon, insertamum, insertvon, ext_id, lehrfach_id, gewicht, anmerkung)
+VALUES(41005, 4131, NearestWintersemester(0), NULL, 'VO', 2, 1, NULL, 'Dummy', 'Dummy', 'German', true, 4131, NULL, '2023-08-24 14:54:43.000', 'anondata', '2023-08-24 14:54:43.000', 'anondata', NULL, 4131, 1, 'Vorlesung 1B');
+
+INSERT INTO lehre.tbl_lehreinheitgruppe
+(lehreinheit_id, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id)
+VALUES(41005, 4, '1', 'B', NULL, NULL, NULL, NULL, '2023-08-24 14:58:26.000', 'anondata', 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(41005, 'demolektor1', 'Lektor', 0, NULL, 1.00, NULL, true, NULL, NULL, '2023-08-24 14:58:40.000', 'anondata', NULL, 0.00, NULL, NULL);
+
+-- ======= Mehrere Lektoren
+INSERT INTO lehre.tbl_lehreinheit (lehreinheit_id, lehrveranstaltung_id, studiensemester_kurzbz, lehrfach_id_old, lehrform_kurzbz, stundenblockung, wochenrythmus, start_kw, raumtyp, raumtypalternativ, sprache, lehre, unr, lvnr, updateamum, updatevon, insertamum, insertvon, ext_id, lehrfach_id, gewicht, anmerkung)
+VALUES(41006, 4141, NearestWintersemester(0), NULL, 'VO', 2, 1, NULL, 'Dummy', 'Dummy', 'German', true, 4141, NULL, '2023-08-24 14:54:43.000', 'anondata', '2023-08-24 14:54:43.000', 'anondata', NULL, 4141, 1, 'Vorlesung 1A und Lektor 2 und 3');
+
+INSERT INTO lehre.tbl_lehreinheitgruppe
+(lehreinheit_id, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id)
+VALUES(41006, 4, '1', 'A', NULL, NULL, NULL, NULL, '2023-08-24 14:58:26.000', 'anondata', 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(41006, 'demolektor2', 'Lektor', 8, NULL, 1.00, NULL, true, NULL, NULL, '2023-08-24 14:58:40.000', 'anondata', NULL, 8.00, NULL, 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(41006, 'demolektor3', 'Lektor', 8, NULL, 1.00, NULL, true, NULL, NULL, '2023-08-24 14:58:40.000', 'anondata', NULL, 8.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, unr, lvnr, updateamum, updatevon, insertamum, insertvon, ext_id, lehrfach_id, gewicht, anmerkung)
+VALUES(41007, 4141, NearestWintersemester(0), NULL, 'VO', 2, 1, NULL, 'Dummy', 'Dummy', 'German', true, 4141, NULL, '2023-08-24 14:54:43.000', 'anondata', '2023-08-24 14:54:43.000', 'anondata', NULL, 4141, 1, 'Vorlesung 1B und Lektor 4 und 5');
+
+INSERT INTO lehre.tbl_lehreinheitgruppe
+(lehreinheit_id, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id)
+VALUES(41007, 4, '1', 'B', NULL, NULL, NULL, NULL, '2023-08-24 14:58:26.000', 'anondata', 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(41007, 'demolektor4', 'Lektor', 8, NULL, 1.00, NULL, true, NULL, NULL, '2023-08-24 14:58:40.000', 'anondata', NULL, 8.00, NULL, 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(41007, 'demolektor5', 'Lektor', 8, NULL, 1.00, NULL, true, NULL, NULL, '2023-08-24 14:58:40.000', 'anondata', NULL, 8.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, unr, lvnr, updateamum, updatevon, insertamum, insertvon, ext_id, lehrfach_id, gewicht, anmerkung)
+VALUES(41008, 4141, NearestWintersemester(0), NULL, 'BE', 2, 1, NULL, 'Dummy', 'Dummy', 'German', true, 4141, NULL, '2023-08-24 14:54:43.000', 'anondata', '2023-08-24 14:54:43.000', 'anondata', NULL, 4141, 1, 'LV Leitung die nicht verplant wird');
+
+INSERT INTO lehre.tbl_lehreinheitgruppe
+(lehreinheit_id, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id)
+VALUES(41008, 4, '1', 'B', NULL, NULL, NULL, NULL, '2023-08-24 14:58:26.000', 'anondata', NULL);
+
+INSERT INTO lehre.tbl_lehreinheitgruppe
+(lehreinheit_id, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id)
+VALUES(41008, 4, '1', 'A', NULL, NULL, NULL, NULL, '2023-08-24 14:58:26.000', 'anondata', 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(41008, 'demolektor4', 'LV-Leitung', 0, NULL, 1.00, NULL, false, NULL, NULL, '2023-08-24 14:58:40.000', 'anondata', NULL, 5.00, NULL, NULL);
+
+
+-- ===== 6. Semester
+
+-- ===== Bachelorarbeit
+INSERT INTO lehre.tbl_lehreinheit (lehreinheit_id, lehrveranstaltung_id, studiensemester_kurzbz, lehrfach_id_old, lehrform_kurzbz, stundenblockung, wochenrythmus, start_kw, raumtyp, raumtypalternativ, sprache, lehre, unr, lvnr, updateamum, updatevon, insertamum, insertvon, ext_id, lehrfach_id, gewicht, anmerkung)
+VALUES(46111, 4611, NearestWintersemester(1), NULL, 'EL', 2, 1, NULL, 'Dummy', 'Dummy', 'German', true, 4111, NULL, '2023-08-24 14:54:43.000', 'anondata', '2023-08-24 14:54:43.000', 'anondata', NULL, 4611, 1, 'keine Verplanung');
+
+INSERT INTO lehre.tbl_lehreinheitgruppe
+(lehreinheit_id, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id)
+VALUES(46111, 4, '6', '', NULL, NULL, NULL, NULL, '2023-08-24 14:58:26.000', 'anondata', 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(46111, 'demolektor1', 'Lektor', 0, NULL, 1.00, NULL, true, NULL, NULL, '2023-08-24 14:58:40.000', 'anondata', NULL, 0.00, NULL, NULL);
+
+-- ===== Internation Skills
+INSERT INTO lehre.tbl_lehreinheit (lehreinheit_id, lehrveranstaltung_id, studiensemester_kurzbz, lehrfach_id_old, lehrform_kurzbz, stundenblockung, wochenrythmus, start_kw, raumtyp, raumtypalternativ, sprache, lehre, unr, lvnr, updateamum, updatevon, insertamum, insertvon, ext_id, lehrfach_id, gewicht, anmerkung)
+VALUES(46211, 4621, NearestWintersemester(1), NULL, 'EL', 2, 1, NULL, 'Dummy', 'Dummy', 'German', true, 4111, NULL, '2023-08-24 14:54:43.000', 'anondata', '2023-08-24 14:54:43.000', 'anondata', NULL, 4621, 1, 'nur für Zeugnis');
+
+INSERT INTO lehre.tbl_lehreinheitgruppe
+(lehreinheit_id, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id)
+VALUES(46211, 4, '6', '', NULL, NULL, NULL, NULL, '2023-08-24 14:58:26.000', 'anondata', 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(46211, 'demolektor1', 'Lektor', 0, NULL, 1.00, NULL, true, NULL, NULL, '2023-08-24 14:58:40.000', 'anondata', NULL, 0.00, NULL, NULL);
+
+-- ===== BPRAK
+INSERT INTO lehre.tbl_lehreinheit (lehreinheit_id, lehrveranstaltung_id, studiensemester_kurzbz, lehrfach_id_old, lehrform_kurzbz, stundenblockung, wochenrythmus, start_kw, raumtyp, raumtypalternativ, sprache, lehre, unr, lvnr, updateamum, updatevon, insertamum, insertvon, ext_id, lehrfach_id, gewicht, anmerkung)
+VALUES(46311, 4631, NearestWintersemester(1), NULL, 'EL', 2, 1, NULL, 'Dummy', 'Dummy', 'German', true, 4111, NULL, '2023-08-24 14:54:43.000', 'anondata', '2023-08-24 14:54:43.000', 'anondata', NULL, 4631, 1, 'keine Verplanung');
+
+INSERT INTO lehre.tbl_lehreinheitgruppe
+(lehreinheit_id, studiengang_kz, semester, verband, gruppe, gruppe_kurzbz, updateamum, updatevon, insertamum, insertvon, ext_id)
+VALUES(46311, 4, '6', '', NULL, NULL, NULL, NULL, '2023-08-24 14:58:26.000', 'anondata', 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(46311, 'demolektor1', 'Lektor', 0, NULL, 1.00, NULL, true, NULL, NULL, '2023-08-24 14:58:40.000', 'anondata', NULL, 0.00, NULL, NULL);
+
diff --git a/system/seeders/009_studenten_stg_4.sql b/system/seeders/009_studenten_stg_4.sql
new file mode 100644
index 000000000..4230e4d9e
--- /dev/null
+++ b/system/seeders/009_studenten_stg_4.sql
@@ -0,0 +1,266 @@
+-- Demodaten fuer Studierende im Studiengang 4
+-- Personen
+INSERT INTO public.tbl_person (person_id, staatsbuergerschaft,geburtsnation,sprache,anrede,titelpost,titelpre,vorname,nachname,vornamen,gebdatum,gebort,gebzeit,foto,anmerkung,homepage,svnr,ersatzkennzeichen,familienstand,geschlecht,anzahlkinder,aktiv,insertamum,insertvon,updateamum,updatevon,ext_id,bundesland_code,kompetenzen,kurzbeschreibung,zugangscode,foto_sperre,udf_values,bpk,matr_aktiv,matr_nr,zugangscode_timestamp,wahlname,unruly) VALUES
+
+(401,'A','A',NULL,'Herr',NULL,NULL,'Mario','Super',NULL,'1974-11-11',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c401',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(402,'A','A',NULL,'Herr',NULL,NULL,'Luigi','Green',NULL,'1965-04-04',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c402',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(403,'A','A',NULL,'Frau',NULL,NULL,'Peach','Princess',NULL,'1954-12-28',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'w',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c403',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(404,'A','A',NULL,'Herr',NULL,NULL,'Toad','Mushroom',NULL,'1940-04-25',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c404',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(405,'A','A',NULL,'Herr',NULL,NULL,'Bowser','Koopa',NULL,'1937-06-01',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c405',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(406,'A','A',NULL,'Frau',NULL,NULL,'Link','Hyrule',NULL,'1957-04-29',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'w',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c406',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(407,'A','A',NULL,'Frau',NULL,NULL,'Zelda','Hyrule',NULL,'1924-04-03',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'w',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c407',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(408,'A','A',NULL,'Herr',NULL,NULL,'Donkey','Kong',NULL,'1956-07-09',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c408',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(409,'A','A',NULL,'Herr',NULL,NULL,'Yoshi','Dinosaur',NULL,'1963-06-09',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c409',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(410,'A','A',NULL,'Frau',NULL,NULL,'Rosalina','Lumaire',NULL,'1963-12-18',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'w',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c8410',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(411,'A','A',NULL,'Herr',NULL,NULL,'Wario','Warioh',NULL,'1968-09-25',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c411',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(412,'A','A',NULL,'Frau',NULL,NULL,'Waluigi','Big',NULL,'1964-09-02',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'w',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c412',false,NULL,NULL,false,NULL,NULL,NULL,false),
+
+(413,'A','A',NULL,'Frau',NULL,NULL,'Melanie','Muster',NULL,'2010-04-02',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'w',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c413',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(414,'A','A',NULL,'Frau',NULL,NULL,'Tanja','Test',NULL,'2009-03-02',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'w',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c414',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(415,'A','A',NULL,'Frau',NULL,NULL,'Franziska','Fake',NULL,'2003-08-05',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'w',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c415',false,NULL,NULL,false,NULL,NULL,NULL,false),
+(416,'A','A',NULL,'Herr',NULL,NULL,'Simon','Simulator',NULL,'2007-07-07',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'w',NULL,true,'2023-08-24 14:56:04.509813','auto','2025-05-13 10:57:19.826386',NULL,NULL,NULL,NULL,NULL,'64e753647c416',false,NULL,NULL,false,NULL,NULL,NULL,false)
+;
+SELECT setval('public.tbl_person_person_id_seq', (SELECT max(person_id) FROM public.tbl_person),true);
+
+-- Prestudenten
+INSERT INTO public.tbl_prestudent (prestudent_id, person_id,studiengang_kz,aufmerksamdurch_kurzbz,berufstaetigkeit_code,ausbildungcode,zgv_code,zgvort,zgvdatum,zgvmas_code,zgvmaort,zgvmadatum,aufnahmeschluessel,facheinschlberuf,reihungstest_id,anmeldungreihungstest,reihungstestangetreten,rt_gesamtpunkte,bismelden,insertamum,insertvon,updateamum,updatevon,ext_id,anmerkung,dual,rt_punkte1,rt_punkte2,ausstellungsstaat,rt_punkte3,udf_values,priorisierung,zgvdoktor_code,zgvdoktorort,zgvdoktordatum,mentor,zgvnation,zgvmanation,zgvdoktornation,gsstudientyp_kurzbz,aufnahmegruppe_kurzbz,foerderrelevant,standort_code,zgv_erfuellt,zgvmas_erfuellt,zgvdoktor_erfuellt) VALUES
+ (4401,401,4,'k.A.',NULL,NULL,9,NULL,'2020-06-01',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (4402,402,4,'k.A.',NULL,NULL,9,NULL,'2020-06-02',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (4403,403,4,'k.A.',NULL,NULL,9,NULL,'2020-06-03',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (4404,404,4,'k.A.',NULL,NULL,9,NULL,'2020-06-04',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (4405,405,4,'k.A.',NULL,NULL,9,NULL,'2020-06-05',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (4406,406,4,'k.A.',NULL,NULL,9,NULL,'2020-06-06',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (4407,407,4,'k.A.',NULL,NULL,9,NULL,'2020-06-07',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (4408,408,4,'k.A.',NULL,NULL,9,NULL,'2020-06-08',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (4409,409,4,'k.A.',NULL,NULL,9,NULL,'2020-06-09',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (4410,410,4,'k.A.',NULL,NULL,9,NULL,'2020-06-10',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (4411,411,4,'k.A.',NULL,NULL,9,NULL,'2020-06-01',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (4412,412,4,'k.A.',NULL,NULL,9,NULL,'2020-06-02',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+
+ (4413,413,4,'k.A.',NULL,NULL,9,NULL,'2020-06-02',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (4414,414,4,'k.A.',NULL,NULL,9,NULL,'2020-06-02',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (4415,415,4,'k.A.',NULL,NULL,9,NULL,'2020-06-02',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false),
+ (4416,416,4,'k.A.',NULL,NULL,9,NULL,'2020-06-02',NULL,NULL,NULL,NULL,false,NULL,NULL,true,NULL,true,NULL,NULL,'2023-08-24 14:56:04.509813',NULL,NULL,NULL,false,NULL,NULL,'A',0.0000,NULL,NULL,NULL,NULL,NULL,NULL,'A',NULL,NULL,'Intern',NULL,NULL,NULL,false,false,false)
+;
+SELECT setval('tbl_prestudent_prestudent_id_seq', (SELECT max(prestudent_id) FROM public.tbl_prestudent),true);
+
+-- Prestudent Status Drittsemestrige
+
+WITH status AS (
+ SELECT * FROM (VALUES
+ ('Interessent',NearestWintersemester(-2),1,(SELECT start FROM public.tbl_studiensemester WHERE studiensemester_kurzbz=NearestWintersemester(-2)),'2023-08-24 14:56:04','auto',NULL,NULL,NULL,3011,'2023-08-24','demoadmin',NULL,false,NULL,NULL,NULL,NULL),
+ ('Bewerber',NearestWintersemester(-2),1,(SELECT start FROM public.tbl_studiensemester WHERE studiensemester_kurzbz=NearestWintersemester(-2)),'2023-08-24 14:58:11','auto',NULL,NULL,NULL,3011,'2023-08-24','demoadmin',NULL,false,NULL,NULL,NULL,NULL),
+ ('Aufgenommener',NearestWintersemester(-2),1,(SELECT start FROM public.tbl_studiensemester WHERE studiensemester_kurzbz=NearestWintersemester(-2)),'2023-08-24 14:58:14','auto',NULL,NULL,NULL,3011,'2023-08-24','demoadmin',NULL,false,NULL,NULL,NULL,NULL),
+ ('Student',NearestWintersemester(-2),1,(SELECT start FROM public.tbl_studiensemester WHERE studiensemester_kurzbz=NearestWintersemester(-2)),'2023-08-24 14:58:16','auto',NULL,NULL,NULL,3011,'2023-08-24','demoadmin',NULL,false,NULL,NULL,NULL,NULL),
+ ('Student',NearestWintersemester(-1),2,(SELECT start FROM public.tbl_studiensemester WHERE studiensemester_kurzbz=NearestWintersemester(-1)),'2023-08-24 14:58:16','auto',NULL,NULL,NULL,3011,'2023-08-24','demoadmin',NULL,false,NULL,NULL,NULL,NULL),
+ ('Student',NearestWintersemester(0),3,(SELECT start FROM public.tbl_studiensemester WHERE studiensemester_kurzbz=NearestWintersemester(0)),'2023-08-24 14:58:16','auto',NULL,NULL,NULL,3011,'2023-08-24','demoadmin',NULL,false,NULL,NULL,NULL,NULL)
+ ) AS t(status_kurzbz,studiensemester_kurzbz,ausbildungssemester,datum,insertamum,insertvon,updateamum,updatevon,orgform_kurzbz,studienplan_id,bestaetigtam,bestaetigtvon,fgm,faktiv,anmerkung,bewerbung_abgeschicktamum,rt_stufe,statusgrund_id)
+),
+inserts AS (
+ SELECT generate_series(4401, 4412) AS prestudent_id,*
+ FROM status
+)
+INSERT INTO public.tbl_prestudentstatus (prestudent_id, status_kurzbz,studiensemester_kurzbz,ausbildungssemester,datum,insertamum,insertvon,updateamum,updatevon,orgform_kurzbz,studienplan_id,bestaetigtam,bestaetigtvon,fgm,faktiv,anmerkung,bewerbung_abgeschicktamum,rt_stufe,statusgrund_id)
+SELECT prestudent_id, status_kurzbz, studiensemester_kurzbz, ausbildungssemester, datum::date, insertamum::timestamp, insertvon, updateamum::timestamp, updatevon,orgform_kurzbz,studienplan_id,bestaetigtam::date,bestaetigtvon,fgm::smallint,faktiv,anmerkung,bewerbung_abgeschicktamum::timestamp,rt_stufe::smallint,statusgrund_id::int
+FROM inserts;
+
+-- tbl_benutzer
+WITH persons AS (
+ SELECT generate_series(401, 412) AS person_id
+)
+INSERT INTO public.tbl_benutzer (person_id, uid, aktiv, alias, insertamum, insertvon, updateamum, updatevon, updateaktivam, updateaktivvon, aktivierungscode)
+SELECT person_id, 's425b' || person_id, true, null, now(), 'auto', null, null, null, false, null
+FROM persons;
+
+-- tbl_student
+-- A Verband
+WITH persons AS (
+ SELECT generate_series(401, 405) AS person_id
+)
+INSERT INTO public.tbl_student (student_uid, matrikelnr, prestudent_id, studiengang_kz, semester, verband, gruppe, updateamum, updatevon, insertamum, insertvon)
+SELECT 's425b' || person_id, '2510004' || person_id, ('4' || person_id)::integer, '4', '3', 'A','', null, null, now(), 'auto'
+FROM persons;
+
+-- B Verband
+WITH persons AS (
+ SELECT generate_series(406, 412) AS person_id
+)
+INSERT INTO public.tbl_student (student_uid, matrikelnr, prestudent_id, studiengang_kz, semester, verband, gruppe, updateamum, updatevon, insertamum, insertvon)
+SELECT 's425b' || person_id, '2510004' || person_id, ('4' || person_id)::integer, '4', '3', 'B','', null, null, now(), 'auto'
+FROM persons;
+
+-- tbl_studentlehrverband
+-- A Verband
+WITH persons AS (
+ SELECT generate_series(401, 405) AS person_id
+),
+students as (
+ SELECT ('s425b' || person_id) as student_uid, person_id
+ FROM persons
+)
+INSERT INTO public.tbl_studentlehrverband (student_uid, studiensemester_kurzbz, studiengang_kz, semester, verband, gruppe, updateamum, updatevon, insertamum, insertvon)
+SELECT student_uid, NearestWintersemester(-2), '4','1','A','', null, null, now(), 'auto'
+FROM students;
+WITH persons AS (
+ SELECT generate_series(401, 405) AS person_id
+),
+students as (
+ SELECT ('s425b' || person_id) as student_uid, person_id
+ FROM persons
+)
+INSERT INTO public.tbl_studentlehrverband (student_uid, studiensemester_kurzbz, studiengang_kz, semester, verband, gruppe, updateamum, updatevon, insertamum, insertvon)
+SELECT student_uid, NearestWintersemester(-1), '4','2','A','', null, null, now(), 'auto'
+FROM students;
+
+WITH persons AS (
+ SELECT generate_series(401, 405) AS person_id
+),
+students as (
+ SELECT ('s425b' || person_id) as student_uid, person_id
+ FROM persons
+)
+INSERT INTO public.tbl_studentlehrverband (student_uid, studiensemester_kurzbz, studiengang_kz, semester, verband, gruppe, updateamum, updatevon, insertamum, insertvon)
+SELECT student_uid, NearestWintersemester(0), '4','3','A','', null, null, now(), 'auto'
+FROM students;
+
+-- B Verband
+WITH persons AS (
+ SELECT generate_series(406, 412) AS person_id
+),
+students as (
+ SELECT ('s425b' || person_id) as student_uid, person_id
+ FROM persons
+)
+INSERT INTO public.tbl_studentlehrverband (student_uid, studiensemester_kurzbz, studiengang_kz, semester, verband, gruppe, updateamum, updatevon, insertamum, insertvon)
+SELECT student_uid, NearestWintersemester(-2), '4','1','B','', null, null, now(), 'auto'
+FROM students;
+
+WITH persons AS (
+ SELECT generate_series(406, 412) AS person_id
+),
+students as (
+ SELECT ('s425b' || person_id) as student_uid, person_id
+ FROM persons
+)
+INSERT INTO public.tbl_studentlehrverband (student_uid, studiensemester_kurzbz, studiengang_kz, semester, verband, gruppe, updateamum, updatevon, insertamum, insertvon)
+SELECT student_uid, NearestWintersemester(-1), '4','2','B','', null, null, now(), 'auto'
+FROM students;
+
+WITH persons AS (
+ SELECT generate_series(406, 412) AS person_id
+),
+students as (
+ SELECT ('s425b' || person_id) as student_uid, person_id
+ FROM persons
+)
+INSERT INTO public.tbl_studentlehrverband (student_uid, studiensemester_kurzbz, studiengang_kz, semester, verband, gruppe, updateamum, updatevon, insertamum, insertvon)
+SELECT student_uid, NearestWintersemester(0), '4','3','B','', null, null, now(), 'auto'
+FROM students;
+
+
+---- 6 Semestrige
+
+WITH status AS (
+ SELECT * FROM (VALUES
+ ('Interessent',NearestWintersemester(-4),1,(SELECT start FROM public.tbl_studiensemester WHERE studiensemester_kurzbz=NearestWintersemester(-4)),'2023-08-24 14:56:04','auto',NULL,NULL,NULL,3011,'2023-08-24','demoadmin',NULL,false,NULL,NULL,NULL,NULL),
+ ('Bewerber',NearestWintersemester(-4),1,(SELECT start FROM public.tbl_studiensemester WHERE studiensemester_kurzbz=NearestWintersemester(-4)),'2023-08-24 14:58:11','auto',NULL,NULL,NULL,3011,'2023-08-24','demoadmin',NULL,false,NULL,NULL,NULL,NULL),
+ ('Aufgenommener',NearestWintersemester(-4),1,(SELECT start FROM public.tbl_studiensemester WHERE studiensemester_kurzbz=NearestWintersemester(-4)),'2023-08-24 14:58:14','auto',NULL,NULL,NULL,3011,'2023-08-24','demoadmin',NULL,false,NULL,NULL,NULL,NULL),
+ ('Student',NearestWintersemester(-4),1,(SELECT start FROM public.tbl_studiensemester WHERE studiensemester_kurzbz=NearestWintersemester(-4)),'2023-08-24 14:58:16','auto',NULL,NULL,NULL,3011,'2023-08-24','demoadmin',NULL,false,NULL,NULL,NULL,NULL),
+ ('Student',NearestWintersemester(-3),2,(SELECT start FROM public.tbl_studiensemester WHERE studiensemester_kurzbz=NearestWintersemester(-3)),'2023-08-24 14:58:16','auto',NULL,NULL,NULL,3011,'2023-08-24','demoadmin',NULL,false,NULL,NULL,NULL,NULL),
+ ('Student',NearestWintersemester(-2),3,(SELECT start FROM public.tbl_studiensemester WHERE studiensemester_kurzbz=NearestWintersemester(-2)),'2023-08-24 14:58:16','auto',NULL,NULL,NULL,3011,'2023-08-24','demoadmin',NULL,false,NULL,NULL,NULL,NULL),
+ ('Student',NearestWintersemester(-1),4,(SELECT start FROM public.tbl_studiensemester WHERE studiensemester_kurzbz=NearestWintersemester(-1)),'2023-08-24 14:58:16','auto',NULL,NULL,NULL,3011,'2023-08-24','demoadmin',NULL,false,NULL,NULL,NULL,NULL),
+ ('Student',NearestWintersemester(0),5,(SELECT start FROM public.tbl_studiensemester WHERE studiensemester_kurzbz=NearestWintersemester(0)),'2023-08-24 14:58:16','auto',NULL,NULL,NULL,3011,'2023-08-24','demoadmin',NULL,false,NULL,NULL,NULL,NULL),
+ ('Student',NearestWintersemester(+1),6,(SELECT start FROM public.tbl_studiensemester WHERE studiensemester_kurzbz=NearestWintersemester(+1)),'2023-08-24 14:58:16','auto',NULL,NULL,NULL,3011,'2023-08-24','demoadmin',NULL,false,NULL,NULL,NULL,NULL)
+ ) AS t(status_kurzbz,studiensemester_kurzbz,ausbildungssemester,datum,insertamum,insertvon,updateamum,updatevon,orgform_kurzbz,studienplan_id,bestaetigtam,bestaetigtvon,fgm,faktiv,anmerkung,bewerbung_abgeschicktamum,rt_stufe,statusgrund_id)
+),
+inserts AS (
+ SELECT generate_series(4413, 4416) AS prestudent_id,*
+ FROM status
+)
+INSERT INTO public.tbl_prestudentstatus (prestudent_id, status_kurzbz,studiensemester_kurzbz,ausbildungssemester,datum,insertamum,insertvon,updateamum,updatevon,orgform_kurzbz,studienplan_id,bestaetigtam,bestaetigtvon,fgm,faktiv,anmerkung,bewerbung_abgeschicktamum,rt_stufe,statusgrund_id)
+SELECT prestudent_id, status_kurzbz, studiensemester_kurzbz, ausbildungssemester, datum::date, insertamum::timestamp, insertvon, updateamum::timestamp, updatevon,orgform_kurzbz,studienplan_id,bestaetigtam::date,bestaetigtvon,fgm::smallint,faktiv,anmerkung,bewerbung_abgeschicktamum::timestamp,rt_stufe::smallint,statusgrund_id::int
+FROM inserts;
+
+-- tbl_benutzer
+WITH persons AS (
+ SELECT generate_series(413, 416) AS person_id
+)
+INSERT INTO public.tbl_benutzer (person_id, uid, aktiv, alias, insertamum, insertvon, updateamum, updatevon, updateaktivam, updateaktivvon, aktivierungscode)
+SELECT person_id, 's46b' || person_id, true, null, now(), 'auto', null, null, null, false, null
+FROM persons;
+
+-- tbl_student
+-- A Verband
+WITH persons AS (
+ SELECT generate_series(413, 416) AS person_id
+)
+INSERT INTO public.tbl_student (student_uid, matrikelnr, prestudent_id, studiengang_kz, semester, verband, gruppe, updateamum, updatevon, insertamum, insertvon)
+SELECT 's46b' || person_id, '2510004' || person_id, ('4' || person_id)::integer, '4', '6', 'A','', null, null, now(), 'auto'
+FROM persons;
+
+-- tbl_studentlehrverband
+-- A Verband
+WITH persons AS (
+ SELECT generate_series(413, 416) AS person_id
+),
+students as (
+ SELECT ('s46b' || person_id) as student_uid, person_id
+ FROM persons
+)
+INSERT INTO public.tbl_studentlehrverband (student_uid, studiensemester_kurzbz, studiengang_kz, semester, verband, gruppe, updateamum, updatevon, insertamum, insertvon)
+SELECT student_uid, NearestWintersemester(-4), '4','1','A','', null, null, now(), 'auto'
+FROM students;
+WITH persons AS (
+ SELECT generate_series(413, 416) AS person_id
+),
+students as (
+ SELECT ('s46b' || person_id) as student_uid, person_id
+ FROM persons
+)
+INSERT INTO public.tbl_studentlehrverband (student_uid, studiensemester_kurzbz, studiengang_kz, semester, verband, gruppe, updateamum, updatevon, insertamum, insertvon)
+SELECT student_uid, NearestWintersemester(-3), '4','2','A','', null, null, now(), 'auto'
+FROM students;
+
+WITH persons AS (
+ SELECT generate_series(413, 416) AS person_id
+),
+students as (
+ SELECT ('s46b' || person_id) as student_uid, person_id
+ FROM persons
+)
+INSERT INTO public.tbl_studentlehrverband (student_uid, studiensemester_kurzbz, studiengang_kz, semester, verband, gruppe, updateamum, updatevon, insertamum, insertvon)
+SELECT student_uid, NearestWintersemester(-2), '4','3','A','', null, null, now(), 'auto'
+FROM students;
+
+WITH persons AS (
+ SELECT generate_series(413, 416) AS person_id
+),
+students as (
+ SELECT ('s46b' || person_id) as student_uid, person_id
+ FROM persons
+)
+INSERT INTO public.tbl_studentlehrverband (student_uid, studiensemester_kurzbz, studiengang_kz, semester, verband, gruppe, updateamum, updatevon, insertamum, insertvon)
+SELECT student_uid, NearestWintersemester(-1), '4','4','A','', null, null, now(), 'auto'
+FROM students;
+
+WITH persons AS (
+ SELECT generate_series(413, 416) AS person_id
+),
+students as (
+ SELECT ('s46b' || person_id) as student_uid, person_id
+ FROM persons
+)
+INSERT INTO public.tbl_studentlehrverband (student_uid, studiensemester_kurzbz, studiengang_kz, semester, verband, gruppe, updateamum, updatevon, insertamum, insertvon)
+SELECT student_uid, NearestWintersemester(0), '4','5','A','', null, null, now(), 'auto'
+FROM students;
+
+WITH persons AS (
+ SELECT generate_series(413, 416) AS person_id
+),
+students as (
+ SELECT ('s46b' || person_id) as student_uid, person_id
+ FROM persons
+)
+INSERT INTO public.tbl_studentlehrverband (student_uid, studiensemester_kurzbz, studiengang_kz, semester, verband, gruppe, updateamum, updatevon, insertamum, insertvon)
+SELECT student_uid, NearestWintersemester(+1), '4','6','A','', null, null, now(), 'auto'
+FROM students;
diff --git a/system/seeders/010_projektarbeiten_studiengang_4.sql b/system/seeders/010_projektarbeiten_studiengang_4.sql
new file mode 100644
index 000000000..214705bc4
--- /dev/null
+++ b/system/seeders/010_projektarbeiten_studiengang_4.sql
@@ -0,0 +1,16 @@
+INSERT INTO lehre.tbl_projektarbeit
+(projektarbeit_id, projekttyp_kurzbz, titel, lehreinheit_id, student_uid, firma_id, note, punkte, beginn, ende, faktor, freigegeben, gesperrtbis, stundensatz, themenbereich, anmerkung, updateamum, updatevon, insertamum, insertvon, ext_id, gesamtstunden, titel_english, sprache, seitenanzahl, abgabedatum, kontrollschlagwoerter, schlagwoerter, schlagwoerter_en, abstract, abstract_en, "final") VALUES
+(1, 'Bachelor', 'Auswirkung der Sonneneinstrahlung auf die Gesundheit', 46111, 's46b413', NULL, NULL, NULL, NULL, NULL, NULL, true, NULL, NULL, NULL, NULL, NULL, NULL, '2025-12-12 14:06:10.000', 'anondata', NULL, 3.0000, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, true),
+(2, 'Bachelor', 'Neue Unterrichtsmethoden mit KI', 46111, 's46b414', NULL, NULL, NULL, NULL, NULL, NULL, true, NULL, NULL, NULL, NULL, NULL, NULL, '2025-12-12 14:06:10.000', 'anondata', NULL, 3.0000, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, true),
+(3, 'Bachelor', 'Betrugsbekämpfung bei Anwesenheitskontrolle', 46111, 's46b415', NULL, NULL, NULL, NULL, NULL, NULL, true, NULL, NULL, NULL, NULL, NULL, NULL, '2025-12-12 14:06:10.000', 'anondata', NULL, 3.0000, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, true),
+(4, 'Bachelor', 'Neuronale Netze', 46111, 's46b416', NULL, NULL, NULL, NULL, NULL, NULL, true, NULL, NULL, NULL, NULL, NULL, NULL, '2025-12-12 14:06:10.000', 'anondata', NULL, 3.0000, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, true)
+;
+
+INSERT INTO lehre.tbl_projektbetreuer
+(person_id, projektarbeit_id, note, faktor, "name", punkte, stundensatz, updateamum, updatevon, insertamum, insertvon, ext_id, betreuerart_kurzbz, stunden, zugangstoken, zugangstoken_gueltigbis, vertrag_id) VALUES
+(21, 1, NULL, NULL, NULL, NULL, 80.00, NULL, NULL, '2025-12-12 14:07:10.000', 'anondata', NULL, 'Begutachter', 4.0000, NULL, NULL, NULL),
+(21, 2, NULL, NULL, NULL, NULL, 80.00, NULL, NULL, '2025-12-12 14:07:10.000', 'anondata', NULL, 'Begutachter', 4.0000, NULL, NULL, NULL),
+(21, 3, NULL, NULL, NULL, NULL, 80.00, NULL, NULL, '2025-12-12 14:07:10.000', 'anondata', NULL, 'Begutachter', 4.0000, NULL, NULL, NULL),
+(21, 4, NULL, NULL, NULL, NULL, 80.00, NULL, NULL, '2025-12-12 14:07:10.000', 'anondata', NULL, 'Begutachter', 4.0000, NULL, NULL, NULL)
+;
+
diff --git a/system/seeders/011_zeitwuensche.sql b/system/seeders/011_zeitwuensche.sql
new file mode 100644
index 000000000..8895bf5fe
--- /dev/null
+++ b/system/seeders/011_zeitwuensche.sql
@@ -0,0 +1,107 @@
+INSERT INTO campus.tbl_zeitwunsch_gueltigkeit
+(zeitwunsch_gueltigkeit_id, mitarbeiter_uid, von, bis, insertamum, insertvon, updateamum, updatevon)
+VALUES(1, 'demolektor1', '2020-09-01', NULL, NOW(), 'demolektor1', NULL, NULL);
+
+INSERT INTO campus.tbl_zeitwunsch
+(stunde, mitarbeiter_uid, tag, gewicht, updateamum, updatevon, insertamum, insertvon, zeitwunsch_id, zeitwunsch_gueltigkeit_id) VALUES
+(1, 'demolektor1', 1, -2, now(), 'demolektor1', now(), 'demolektor1', 1, 1),
+(2, 'demolektor1', 1, -2, now(), 'demolektor1', now(), 'demolektor1', 2, 1),
+(3, 'demolektor1', 1, 2, now(), 'demolektor1', now(), 'demolektor1', 3, 1),
+(4, 'demolektor1', 1, 2, now(), 'demolektor1', now(), 'demolektor1', 4, 1),
+(5, 'demolektor1', 1, 2, now(), 'demolektor1', now(), 'demolektor1', 5, 1),
+(6, 'demolektor1', 1, 2, now(), 'demolektor1', now(), 'demolektor1', 6, 1),
+(7, 'demolektor1', 1, 2, now(), 'demolektor1', now(), 'demolektor1', 7, 1),
+(8, 'demolektor1', 1, 2, now(), 'demolektor1', now(), 'demolektor1', 8, 1),
+(9, 'demolektor1', 1, 2, now(), 'demolektor1', now(), 'demolektor1', 9, 1),
+(10, 'demolektor1', 1, 2, now(), 'demolektor1', now(), 'demolektor1', 10, 1),
+(11, 'demolektor1', 1, 2, now(), 'demolektor1', now(), 'demolektor1', 11, 1),
+(12, 'demolektor1', 1, 2, now(), 'demolektor1', now(), 'demolektor1', 12, 1),
+(13, 'demolektor1', 1, 2, now(), 'demolektor1', now(), 'demolektor1', 13, 1),
+(14, 'demolektor1', 1, -1, now(), 'demolektor1', now(), 'demolektor1', 14, 1),
+(15, 'demolektor1', 1, -1, now(), 'demolektor1', now(), 'demolektor1', 15, 1),
+(16, 'demolektor1', 1, -1, now(), 'demolektor1', now(), 'demolektor1', 16, 1),
+(1, 'demolektor1', 2, -2, now(), 'demolektor1', now(), 'demolektor1', 17, 1),
+(2, 'demolektor1', 2, -1, now(), 'demolektor1', now(), 'demolektor1', 18, 1),
+(3, 'demolektor1', 2, 1, now(), 'demolektor1', now(), 'demolektor1', 19, 1),
+(4, 'demolektor1', 2, 1, now(), 'demolektor1', now(), 'demolektor1', 20, 1),
+(5, 'demolektor1', 2, 1, now(), 'demolektor1', now(), 'demolektor1', 21, 1),
+(6, 'demolektor1', 2, 1, now(), 'demolektor1', now(), 'demolektor1', 22, 1),
+(7, 'demolektor1', 2, 1, now(), 'demolektor1', now(), 'demolektor1', 23, 1),
+(8, 'demolektor1', 2, 1, now(), 'demolektor1', now(), 'demolektor1', 24, 1),
+(9, 'demolektor1', 2, 1, now(), 'demolektor1', now(), 'demolektor1', 25, 1),
+(10, 'demolektor1', 2, 1, now(), 'demolektor1', now(), 'demolektor1', 26, 1),
+(11, 'demolektor1', 2, 1, now(), 'demolektor1', now(), 'demolektor1', 27, 1),
+(12, 'demolektor1', 2, 1, now(), 'demolektor1', now(), 'demolektor1', 28, 1),
+(13, 'demolektor1', 2, 1, now(), 'demolektor1', now(), 'demolektor1', 29, 1),
+(14, 'demolektor1', 2, 1, now(), 'demolektor1', now(), 'demolektor1', 30, 1),
+(15, 'demolektor1', 2, 1, now(), 'demolektor1', now(), 'demolektor1', 31, 1),
+(16, 'demolektor1', 2, 1, now(), 'demolektor1', now(), 'demolektor1', 32, 1),
+(1, 'demolektor1', 3, 1, now(), 'demolektor1', now(), 'demolektor1', 33, 1),
+(2, 'demolektor1', 3, 1, now(), 'demolektor1', now(), 'demolektor1', 34, 1),
+(3, 'demolektor1', 3, 1, now(), 'demolektor1', now(), 'demolektor1', 35, 1),
+(4, 'demolektor1', 3, 1, now(), 'demolektor1', now(), 'demolektor1', 36, 1),
+(5, 'demolektor1', 3, 1, now(), 'demolektor1', now(), 'demolektor1', 37, 1),
+(6, 'demolektor1', 3, 1, now(), 'demolektor1', now(), 'demolektor1', 38, 1),
+(7, 'demolektor1', 3, 1, now(), 'demolektor1', now(), 'demolektor1', 39, 1),
+(8, 'demolektor1', 3, 1, now(), 'demolektor1', now(), 'demolektor1', 40, 1),
+(9, 'demolektor1', 3, 1, now(), 'demolektor1', now(), 'demolektor1', 41, 1),
+(10, 'demolektor1', 3, 1, now(), 'demolektor1', now(), 'demolektor1', 42, 1),
+(11, 'demolektor1', 3, 1, now(), 'demolektor1', now(), 'demolektor1', 43, 1),
+(12, 'demolektor1', 3, 1, now(), 'demolektor1', now(), 'demolektor1', 44, 1),
+(13, 'demolektor1', 3, 1, now(), 'demolektor1', now(), 'demolektor1', 45, 1),
+(14, 'demolektor1', 3, 1, now(), 'demolektor1', now(), 'demolektor1', 46, 1),
+(15, 'demolektor1', 3, 1, now(), 'demolektor1', now(), 'demolektor1', 47, 1),
+(16, 'demolektor1', 3, 1, now(), 'demolektor1', now(), 'demolektor1', 48, 1),
+(1, 'demolektor1', 4, 1, now(), 'demolektor1', now(), 'demolektor1', 49, 1),
+(2, 'demolektor1', 4, 1, now(), 'demolektor1', now(), 'demolektor1', 50, 1),
+(3, 'demolektor1', 4, 1, now(), 'demolektor1', now(), 'demolektor1', 51, 1),
+(4, 'demolektor1', 4, 1, now(), 'demolektor1', now(), 'demolektor1', 52, 1),
+(5, 'demolektor1', 4, 1, now(), 'demolektor1', now(), 'demolektor1', 53, 1),
+(6, 'demolektor1', 4, 1, now(), 'demolektor1', now(), 'demolektor1', 54, 1),
+(7, 'demolektor1', 4, 1, now(), 'demolektor1', now(), 'demolektor1', 55, 1),
+(8, 'demolektor1', 4, 1, now(), 'demolektor1', now(), 'demolektor1', 56, 1),
+(9, 'demolektor1', 4, 1, now(), 'demolektor1', now(), 'demolektor1', 57, 1),
+(10, 'demolektor1', 4, 1, now(), 'demolektor1', now(), 'demolektor1', 58, 1),
+(11, 'demolektor1', 4, 1, now(), 'demolektor1', now(), 'demolektor1', 59, 1),
+(12, 'demolektor1', 4, 1, now(), 'demolektor1', now(), 'demolektor1', 60, 1),
+(13, 'demolektor1', 4, 1, now(), 'demolektor1', now(), 'demolektor1', 61, 1),
+(14, 'demolektor1', 4, 1, now(), 'demolektor1', now(), 'demolektor1', 62, 1),
+(15, 'demolektor1', 4, 1, now(), 'demolektor1', now(), 'demolektor1', 63, 1),
+(16, 'demolektor1', 4, 1, now(), 'demolektor1', now(), 'demolektor1', 64, 1),
+(1, 'demolektor1', 5, 1, now(), 'demolektor1', now(), 'demolektor1', 65, 1),
+(2, 'demolektor1', 5, 1, now(), 'demolektor1', now(), 'demolektor1', 66, 1),
+(3, 'demolektor1', 5, 1, now(), 'demolektor1', now(), 'demolektor1', 67, 1),
+(4, 'demolektor1', 5, 1, now(), 'demolektor1', now(), 'demolektor1', 68, 1),
+(5, 'demolektor1', 5, 1, now(), 'demolektor1', now(), 'demolektor1', 69, 1),
+(6, 'demolektor1', 5, 1, now(), 'demolektor1', now(), 'demolektor1', 70, 1),
+(7, 'demolektor1', 5, 1, now(), 'demolektor1', now(), 'demolektor1', 71, 1),
+(8, 'demolektor1', 5, 1, now(), 'demolektor1', now(), 'demolektor1', 72, 1),
+(9, 'demolektor1', 5, 1, now(), 'demolektor1', now(), 'demolektor1', 73, 1),
+(10, 'demolektor1', 5, 1, now(), 'demolektor1', now(), 'demolektor1', 74, 1),
+(11, 'demolektor1', 5, 1, now(), 'demolektor1', now(), 'demolektor1', 75, 1),
+(12, 'demolektor1', 5, 1, now(), 'demolektor1', now(), 'demolektor1', 76, 1),
+(13, 'demolektor1', 5, 1, now(), 'demolektor1', now(), 'demolektor1', 77, 1),
+(14, 'demolektor1', 5, 1, now(), 'demolektor1', now(), 'demolektor1', 78, 1),
+(15, 'demolektor1', 5, 1, now(), 'demolektor1', now(), 'demolektor1', 79, 1),
+(16, 'demolektor1', 5, 1, now(), 'demolektor1', now(), 'demolektor1', 80, 1),
+(1, 'demolektor1', 6, 1, now(), 'demolektor1', now(), 'demolektor1', 81, 1),
+(2, 'demolektor1', 6, 1, now(), 'demolektor1', now(), 'demolektor1', 82, 1),
+(3, 'demolektor1', 6, 1, now(), 'demolektor1', now(), 'demolektor1', 83, 1),
+(4, 'demolektor1', 6, 1, now(), 'demolektor1', now(), 'demolektor1', 84, 1),
+(5, 'demolektor1', 6, 1, now(), 'demolektor1', now(), 'demolektor1', 85, 1),
+(6, 'demolektor1', 6, 1, now(), 'demolektor1', now(), 'demolektor1', 86, 1),
+(7, 'demolektor1', 6, 1, now(), 'demolektor1', now(), 'demolektor1', 87, 1),
+(8, 'demolektor1', 6, 1, now(), 'demolektor1', now(), 'demolektor1', 88, 1),
+(9, 'demolektor1', 6, 1, now(), 'demolektor1', now(), 'demolektor1', 89, 1),
+(10, 'demolektor1', 6, 1, now(), 'demolektor1', now(), 'demolektor1', 90, 1),
+(11, 'demolektor1', 6, 1, now(), 'demolektor1', now(), 'demolektor1', 91, 1),
+(12, 'demolektor1', 6, 1, now(), 'demolektor1', now(), 'demolektor1', 92, 1),
+(13, 'demolektor1', 6, 1, now(), 'demolektor1', now(), 'demolektor1', 93, 1),
+(14, 'demolektor1', 6, 1, now(), 'demolektor1', now(), 'demolektor1', 94, 1),
+(15, 'demolektor1', 6, 1, now(), 'demolektor1', now(), 'demolektor1', 95, 1),
+(16, 'demolektor1', 6, 1, now(), 'demolektor1', now(), 'demolektor1', 96, 1)
+;
+
+
+SELECT setval('campus.seq_zeitwunsch_zeitwunsch_id', (select max(zeitwunsch_id) from campus.tbl_zeitwunsch), true);
+SELECT setval('campus.seq_zeitwunsch_gueltigkeit_zeitwunsch_gueltigkeit_id', (select max(zeitwunsch_gueltigkeit_id) from campus.tbl_zeitwunsch_gueltigkeit),true);
diff --git a/system/seeders/012_cms_content.sql b/system/seeders/012_cms_content.sql
new file mode 100644
index 000000000..72c381b93
--- /dev/null
+++ b/system/seeders/012_cms_content.sql
@@ -0,0 +1,38 @@
+INSERT INTO campus.tbl_content
+(content_id, template_kurzbz, oe_kurzbz, insertamum, insertvon, updateamum, updatevon, aktiv, menu_open, beschreibung) VALUES
+(30, 'contentmittitel', 'etw', now(), 'demoadmin', now(), 'demoadmin', true, false, NULL),
+(32, 'contentmittitel', 'etw', now(), 'demoadmin', now(), 'demoadmin', true, false, NULL),
+(33, 'redirect', 'etw', now(), 'demoadmin', now(), 'demoadmin', true, false, NULL),
+(34, 'redirect', 'etw', now(), 'demoadmin', now(), 'demoadmin', true, false, NULL),
+(35, 'redirect', 'etw', now(), 'demoadmin', now(), 'demoadmin', true, false, NULL),
+(36, 'redirect', 'etw', now(), 'demoadmin', now(), 'demoadmin', true, false, NULL),
+(37, 'redirect', 'etw', now(), 'demoadmin', now(), 'demoadmin', true, false, NULL),
+(38, 'redirect', 'etw', now(), 'demoadmin', now(), 'demoadmin', true, false, NULL)
+;
+
+INSERT INTO campus.tbl_contentsprache (sprache, content_id, "version", sichtbar, "content", reviewvon, reviewamum, updateamum, updatevon, insertamum, insertvon, titel, gesperrt_uid) VALUES
+('German', 30, 1, true, '
', NULL, NULL, now(), 'demoadmin', now(), 'demoadmin', 'CIS4', NULL),
+('German', 32, 1, true, '
', NULL, NULL, now(), 'demoadmin', now(), 'demoadmin', 'Studium', NULL),
+('German', 33, 1, true, '
', NULL, NULL, now(), 'demoadmin', now(), 'demoadmin', 'LV-Plan', NULL),
+('German', 34, 1, true, '
', NULL, NULL, now(), 'demoadmin', now(), 'demoadmin', 'Dahsboard', NULL),
+('German', 35, 1, true, '
', NULL, NULL, now(), 'demoadmin', now(), 'demoadmin', 'Meine LVs', NULL),
+('German', 36, 1, true, '
', NULL, NULL, now(), 'demoadmin', now(), 'demoadmin', 'Studium', NULL),
+('German', 37, 1, true, '
', NULL, NULL, now(), 'demoadmin', now(), 'demoadmin', 'Profil', NULL),
+('German', 38, 1, true, '
', NULL, NULL, now(), 'demoadmin', now(), 'demoadmin', 'Dokumente', NULL)
+;
+
+INSERT INTO campus.tbl_contentchild (content_id, child_content_id, insertamum, insertvon, updateamum, updatevon, sort) VALUES
+(30, 37, now(), 'demoadmin', NULL, NULL, 5),
+(30, 27, now(), 'demoadmin', NULL, NULL, 7),
+(30, 32, now(), 'demoadmin', NULL, NULL, 2),
+(30, 34, now(), 'demoadmin', NULL, NULL, 1),
+(32, 33, now(), 'demoadmin', NULL, NULL, 1),
+(32, 36, now(), 'demoadmin', NULL, NULL, 2),
+(32, 38, now(), 'demoadmin', NULL, NULL, 3),
+(32, 35, now(), 'demoadmin', NULL, NULL, 4)
+;
+
+INSERT INTO campus.tbl_contentgruppe (content_id, gruppe_kurzbz, insertamum, insertvon) VALUES
+(30, 'MA', now(), 'demoadmin'),
+(30, 'STUD', now(), 'demoadmin')
+;
diff --git a/system/seeders/013_studiengang_5.sql b/system/seeders/013_studiengang_5.sql
new file mode 100644
index 000000000..682ff9325
--- /dev/null
+++ b/system/seeders/013_studiengang_5.sql
@@ -0,0 +1,231 @@
+-- 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,'invalid@example.com',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','oesi','2023-08-24 14:28:39','oesi',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','oesi','2023-08-24 14:28:39','oesi',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','oesi','2023-08-24 14:28:39','oesi',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','oesi','2023-08-24 14:28:39','oesi',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','oesi','2023-08-24 14:28:39','oesi',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','oesi','2023-08-24 14:28:39','oesi',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','oesi','2023-08-24 14:28:39','oesi',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','oesi','2023-08-24 14:28:39','oesi',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','oesi','2023-08-24 14:28:39','oesi',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','oesi','2023-08-24 14:28:39','oesi',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','oesi','2023-08-24 14:28:39','oesi',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','oesi','2023-08-24 14:28:39','oesi',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','oesi','2023-08-24 14:28:39','oesi',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','oesi','2023-08-24 14:28:39','oesi',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','oesi','2023-08-24 14:28:39','oesi',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','oesi','2023-08-24 14:28:39','oesi',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','oesi','2023-08-24 14:28:39','oesi',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','oesi','2023-08-24 14:28:39','oesi',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','oesi','2023-08-24 14:28:39','oesi',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','oesi','2023-08-24 14:28:39','oesi',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','oesi','2023-08-24 14:28:39','oesi',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','oesi','2023-08-24 14:28:39','oesi',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','oesi','2023-08-24 14:28:39','oesi',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','oesi','2023-08-24 14:28:39','oesi',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','oesi','2023-08-24 14:28:39','oesi',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','oesi','2023-08-24 14:28:39','oesi',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','oesi','2023-08-24 14:28:39','oesi',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','oesi','2023-08-24 14:28:39','oesi',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
+ -- MODULE im Studienplan
+ (501, 5011,511,1,NULL,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (502, 5011,512,1,NULL,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (503, 5011,513,1,NULL,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (504, 5011,514,1,NULL,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (505, 5011,521,2,NULL,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (506, 5011,522,2,NULL,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (507, 5011,523,2,NULL,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (508, 5011,531,3,NULL,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (509, 5011,532,3,NULL,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (510, 5011,541,4,NULL,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (511, 5011,542,4,NULL,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (512, 5011,551,5,NULL,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (513, 5011,552,5,NULL,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (514, 5011,561,6,NULL,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+
+ -- LVS zu Modulen
+ (515, 5011,5111,1,501,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (516, 5011,5121,1,502,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (517, 5011,5131,1,503,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (518, 5011,5141,1,504,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (519, 5011,5211,2,505,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (520, 5011,5221,2,519,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (521, 5011,5231,2,507,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (522, 5011,5311,3,508,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (523, 5011,5321,3,509,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (524, 5011,5411,4,510,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (525, 5011,5421,4,511,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (526, 5011,5511,5,512,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (527, 5011,5521,5,513,true,NULL,'2023-08-24 14:29:03.506117','oesi',NULL,NULL,NULL,NULL,true,true,true),
+ (528, 5011,5611,6,514,true,NULL,'2023-08-24 14:29:03.506117','oesi',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', 'oesi', '2023-08-24 14:54:43.000', 'oesi', 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', 'oesi', 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', 'oesi', 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', 'oesi', 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', 'oesi', '2023-08-24 14:54:43.000', 'oesi', 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', 'oesi', 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', 'oesi', 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', 'oesi', '2023-08-24 14:54:43.000', 'oesi', 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', 'oesi', 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', 'oesi', 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', 'oesi', '2023-08-24 14:54:43.000', 'oesi', 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', 'oesi', 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', 'oesi', 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', 'oesi', '2023-08-24 14:54:43.000', 'oesi', 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', 'oesi', 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', 'oesi', 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', 'oesi', '2023-08-24 14:54:43.000', 'oesi', 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', 'oesi', 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', 'oesi', 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', 'oesi', 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', 'oesi', 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)
+;
diff --git a/system/seeders/014_betriebsmittel.sql b/system/seeders/014_betriebsmittel.sql
new file mode 100644
index 000000000..6239ccfa9
--- /dev/null
+++ b/system/seeders/014_betriebsmittel.sql
@@ -0,0 +1,11 @@
+INSERT INTO wawi.tbl_betriebsmitteltyp
+(betriebsmitteltyp, beschreibung, anzahl, kaution, typ_code, mastershapename)
+VALUES('Inventar', NULL, NULL, NULL, NULL, NULL);
+
+INSERT INTO wawi.tbl_betriebsmittel
+(betriebsmittel_id, beschreibung, betriebsmitteltyp, nummer, reservieren, updateamum, updatevon, insertamum, insertvon, ext_id, inventarnummer, oe_kurzbz, ort_kurzbz, hersteller, seriennummer, bestellung_id, bestelldetail_id, afa, verwendung, anmerkung, leasing_bis, inventuramum, inventurvon, anschaffungswert, anschaffungsdatum, tiefe, hoehe, breite, nummer2, verplanen)
+VALUES
+(1, 'pc', 'Laptop', NULL, false, '2024-03-07 08:17:33.000', 'demoadmin', '2015-12-01 14:55:32.000', 'demoadmin', NULL, '00001', NULL, NULL, 'pc', NULL, NULL, NULL, 3, NULL, 'Angelegt von demoadmin für die test', NULL, '2015-12-01 14:55:32.000', 'demoadmin', NULL, NULL, NULL, NULL, NULL, NULL, true),
+(2, 'projector', 'Inventar', NULL, false, '2024-03-07 08:17:33.000', 'demoadmin', '2015-12-01 14:55:32.000', 'demoadmin', NULL, '00002', NULL, NULL, 'projector', NULL, NULL, NULL, 3, NULL, 'Angelegt von demoadmin für die test', NULL, '2015-12-01 14:55:32.000', 'demoadmin', NULL, NULL, NULL, NULL, NULL, NULL, true),
+(3, 'workstation', 'Inventar', NULL, false, '2024-03-07 08:17:33.000', 'demoadmin', '2015-12-01 14:55:32.000', 'demoadmin', NULL, '00003', NULL, NULL, 'workstation', NULL, NULL, NULL, 3, NULL, 'Angelegt von demoadmin für die test', NULL, '2015-12-01 14:55:32.000', 'demoadmin', NULL, NULL, NULL, NULL, NULL, NULL, true),
+(4, 'pc2', 'Laptop', NULL, false, '2024-03-07 08:17:33.000', 'demoadmin', '2015-12-01 14:55:32.000', 'demoadmin', NULL, '00004', NULL, NULL, 'pc2', NULL, NULL, NULL, 3, NULL, 'Angelegt von demoadmin für die test', NULL, '2015-12-01 14:55:32.000', 'demoadmin', NULL, NULL, NULL, NULL, NULL, NULL, true);
diff --git a/system/seeders/015_kalender.sql b/system/seeders/015_kalender.sql
new file mode 100644
index 000000000..345e003a3
--- /dev/null
+++ b/system/seeders/015_kalender.sql
@@ -0,0 +1,210 @@
+-- Rooms setup for calendar tests
+INSERT INTO public.tbl_adresse
+(adresse_id, person_id, "name", strasse, plz, ort, gemeinde, nation, typ, heimatadresse, zustelladresse, firma_id, updateamum, updatevon, insertamum, insertvon, ext_id, rechnungsadresse, anmerkung, co_name)
+VALUES(1, NULL, 'Hauptwohnsitz', 'Stromstraße 18-20/1/10', '1200', 'Wien', 'Wien', 'A', 'h', true, true, NULL, '2007-08-13 10:53:40.298', 'demoadmin', '2007-08-10 10:42:02.579', 'demoadmin', 14470, false, NULL, NULL);
+
+INSERT INTO public.tbl_standort
+(kurzbz, adresse_id, standort_id, bezeichnung, insertvon, insertamum, updatevon, updateamum, ext_id, firma_id, code)
+VALUES('Vienna Universit', 1, 1, 'Vienna University of Technology, Institute of Design and Assessment of Technology', 'demoadmin', '2012-02-02 10:24:11.803', 'demoadmin', '2015-05-27 09:05:53.894', NULL, 6033, NULL);
+
+INSERT INTO public.tbl_ort
+(ort_kurzbz, bezeichnung, planbezeichnung, max_person, lehre, reservieren, aktiv, lageplan, dislozierung, kosten, ausstattung, updateamum, updatevon, insertamum, insertvon, ext_id, stockwerk, telefonklappe, standort_id, content_id, m2, gebteil, oe_kurzbz, arbeitsplaetze)
+VALUES
+('Bro_F7.37', 'Büro', 'F7.37', NULL, true, true, true, NULL, NULL, NULL, NULL, '2023-09-19 10:35:20.000', 'demoadmin', '2012-09-05 15:20:25.979', 'demoadmin', NULL, 7, NULL, 1, NULL, 21.58, 'F', 'etw', NULL),
+('Bro_F7.38', 'Büro', 'F7.38', NULL, true, true, true, NULL, NULL, NULL, NULL, '2023-09-19 10:35:20.000', 'demoadmin', '2012-09-05 15:20:25.979', 'demoadmin', NULL, 7, NULL, 1, NULL, 21.58, 'F', 'etw', NULL),
+('Bro_F7.39', 'Büro', 'F7.39', NULL, true, true, true, NULL, NULL, NULL, NULL, '2023-09-19 10:35:20.000', 'demoadmin', '2012-09-05 15:20:25.979', 'demoadmin', NULL, 7, NULL, 1, NULL, 21.58, 'F', 'etw', NULL),
+('Bro_F7.40', 'Büro', 'F7.40', NULL, true, true, true, NULL, NULL, NULL, NULL, '2023-09-19 10:35:20.000', 'demoadmin', '2012-09-05 15:20:25.979', 'demoadmin', NULL, 7, NULL, 1, NULL, 21.58, 'F', 'etw', NULL),
+('Bro_F7.41', 'Büro', 'F7.41', NULL, true, true, true, NULL, NULL, NULL, NULL, '2023-09-19 10:35:20.000', 'demoadmin', '2012-09-05 15:20:25.979', 'demoadmin', NULL, 7, NULL, 1, NULL, 21.58, 'F', 'etw', NULL);
+
+INSERT INTO public.tbl_ortraumtyp
+(ort_kurzbz, hierarchie, raumtyp_kurzbz)
+VALUES
+('Bro_F7.37', 1, 'Dummy'),
+('Bro_F7.38', 1, 'Dummy'),
+('Bro_F7.39', 1, 'Dummy'),
+('Bro_F7.40', 1, 'Dummy'),
+('Bro_F7.41', 1, 'Dummy');
+-- ////////////////////////////////////////////////////////////////////////////////////////////////////
+
+
+-- Calendar setup for event drag and drop tests
+INSERT INTO lehre.tbl_kalender
+(von, bis, typ, status_kurzbz, vorgaenger_kalender_id, insertamum, insertvon, updateamum, updatevon) VALUES
+(date_trunc('week', CURRENT_DATE) + INTERVAL '2 day' + TIME '08:00:00', date_trunc('week', CURRENT_DATE) + INTERVAL '2 day' + TIME '08:45:00', 'lehreinheit', 'live', NULL, now(), 'demoadmin', NULL, 'demoadmin'),
+(date_trunc('week', CURRENT_DATE) + INTERVAL '2 day' + TIME '08:45:00', date_trunc('week', CURRENT_DATE) + INTERVAL '2 day' + TIME '09:30:00', 'lehreinheit', 'live', NULL, now(), 'demoadmin', NULL, 'demoadmin'),
+(date_trunc('week', CURRENT_DATE) + INTERVAL '2 day' + TIME '09:40:00', date_trunc('week', CURRENT_DATE) + INTERVAL '2 day' + TIME '10:25:00', 'lehreinheit', 'live', NULL, now(), 'demoadmin', NULL, 'demoadmin'),
+(date_trunc('week', CURRENT_DATE) + INTERVAL '2 day' + TIME '10:25:00', date_trunc('week', CURRENT_DATE) + INTERVAL '2 day' + TIME '11:10:00', 'lehreinheit', 'live', NULL, now(), 'demoadmin', NULL, 'demoadmin'),
+(date_trunc('week', CURRENT_DATE) + INTERVAL '2 day' + TIME '09:40:00', date_trunc('week', CURRENT_DATE) + INTERVAL '2 day' + TIME '10:25:00', 'lehreinheit', 'live', NULL, now(), 'demoadmin', NULL, 'demoadmin'),
+(date_trunc('week', CURRENT_DATE) + INTERVAL '2 day' + TIME '09:40:00', date_trunc('week', CURRENT_DATE) + INTERVAL '2 day' + TIME '10:25:00', 'lehreinheit', 'live', NULL, now(), 'demoadmin', NULL, 'demoadmin'),
+(date_trunc('week', CURRENT_DATE) + INTERVAL '2 day' + TIME '10:25:00', date_trunc('week', CURRENT_DATE) + INTERVAL '2 day' + TIME '11:10:00', 'lehreinheit', 'live', NULL, now(), 'demoadmin', NULL, 'demoadmin'),
+(date_trunc('week', CURRENT_DATE) + INTERVAL '2 day' + TIME '10:25:00', date_trunc('week', CURRENT_DATE) + INTERVAL '2 day' + TIME '11:10:00', 'lehreinheit', 'live', NULL, now(), 'demoadmin', NULL, 'demoadmin')
+;
+
+INSERT INTO lehre.tbl_kalender_ort
+(location, ort_kurzbz, kalender_id)
+VALUES(NULL, 'Bro_F7.37', 1);
+
+INSERT INTO lehre.tbl_kalender_lehreinheit
+(lehreinheit_id, kalender_id)
+VALUES
+(51001, 1),
+(51002, 2),
+(51003, 3),
+(51003, 4),
+(51002, 5),
+(51006, 6),
+(51002, 7),
+(51006, 8);
+-- ////////////////////////////////////////////////////////////////////////////////////////////////////
+
+
+-- Calendar setup for resize tests
+INSERT INTO lehre.tbl_kalender
+(von, bis, typ, status_kurzbz, vorgaenger_kalender_id, insertamum, insertvon, updateamum, updatevon) VALUES
+(date_trunc('week', CURRENT_DATE) + INTERVAL '3 day' + TIME '08:00:00', date_trunc('week', CURRENT_DATE) + INTERVAL '3 day' + TIME '08:45:00', 'lehreinheit', 'live', NULL, now(), 'demoadmin', NULL, 'demoadmin'),
+(date_trunc('week', CURRENT_DATE) + INTERVAL '4 day' + TIME '10:25:00', date_trunc('week', CURRENT_DATE) + INTERVAL '4 day' + TIME '11:10:00', 'lehreinheit', 'live', NULL, now(), 'demoadmin', NULL, 'demoadmin'),
+(date_trunc('week', CURRENT_DATE) + INTERVAL '4 day' + TIME '10:25:00', date_trunc('week', CURRENT_DATE) + INTERVAL '4 day' + TIME '11:10:00', 'lehreinheit', 'live', NULL, now(), 'demoadmin', NULL, 'demoadmin')
+;
+
+INSERT INTO lehre.tbl_kalender_lehreinheit
+(lehreinheit_id, kalender_id)
+VALUES
+(51001, 9),
+(51003, 10),
+(51002, 11);
+-- ////////////////////////////////////////////////////////////////////////////////////////////////////
+
+
+-- Calendar setup for role preview tests
+INSERT INTO lehre.tbl_kalender
+(von, bis, typ, status_kurzbz, vorgaenger_kalender_id, insertamum, insertvon, updateamum, updatevon) VALUES
+(date_trunc('week', CURRENT_DATE) + INTERVAL '3 day' + TIME '13:35:00', date_trunc('week', CURRENT_DATE) + INTERVAL '3 day' + TIME '14:20:00', 'lehreinheit', 'live', NULL, now(), 'demoadmin', NULL, 'demoadmin'),
+(date_trunc('week', CURRENT_DATE) + INTERVAL '3 day' + TIME '13:35:00', date_trunc('week', CURRENT_DATE) + INTERVAL '3 day' + TIME '14:20:00', 'lehreinheit', 'live', NULL, now(), 'demoadmin', NULL, 'demoadmin')
+;
+
+INSERT INTO lehre.tbl_kalender_lehreinheit
+(lehreinheit_id, kalender_id)
+VALUES
+(51001, 12),
+(51002, 13);
+-- ////////////////////////////////////////////////////////////////////////////////////////////////////
+
+-- Calendar setup for API event creation tests
+INSERT INTO lehre.tbl_kalender
+(von, bis, typ, status_kurzbz, vorgaenger_kalender_id, insertamum, insertvon, updateamum, updatevon) VALUES
+(date_trunc('week', CURRENT_DATE) + INTERVAL '2 day' + TIME '18:35:00', date_trunc('week', CURRENT_DATE) + INTERVAL '2 day' + TIME '19:20:00', 'lehreinheit', 'live', NULL, now(), 'demoadmin', NULL, 'demoadmin'),
+(date_trunc('week', CURRENT_DATE) + INTERVAL '3 day' + TIME '18:35:00', date_trunc('week', CURRENT_DATE) + INTERVAL '3 day' + TIME '19:20:00', 'lehreinheit', 'live', NULL, now(), 'demoadmin', NULL, 'demoadmin')
+;
+
+INSERT INTO lehre.tbl_kalender_lehreinheit
+(lehreinheit_id, kalender_id)
+VALUES
+(51003, 14),
+(51005, 15);
+-- ////////////////////////////////////////////////////////////////////////////////////////////////////
+
+
+-- Calendar setup for API event update tests
+INSERT INTO lehre.tbl_kalender
+(von, bis, typ, status_kurzbz, vorgaenger_kalender_id, insertamum, insertvon, updateamum, updatevon)
+VALUES
+(date_trunc('week', CURRENT_DATE) + INTERVAL '1 day' + TIME '19:30:00', date_trunc('week', CURRENT_DATE) + INTERVAL '1 day' + TIME '20:15:00', 'lehreinheit', 'live', NULL, now(), 'demoadmin', NULL, 'demoadmin'),
+(date_trunc('week', CURRENT_DATE) + INTERVAL '2 day' + TIME '19:30:00', date_trunc('week', CURRENT_DATE) + INTERVAL '2 day' + TIME '20:15:00', 'lehreinheit', 'live', NULL, now(), 'demoadmin', NULL, 'demoadmin'),
+(date_trunc('week', CURRENT_DATE) + INTERVAL '2 day' + TIME '20:15:00', date_trunc('week', CURRENT_DATE) + INTERVAL '2 day' + TIME '21:00:00', 'lehreinheit', 'live', NULL, now(), 'demoadmin', NULL, 'demoadmin'),
+(date_trunc('week', CURRENT_DATE) + INTERVAL '3 day' + TIME '19:30:00', date_trunc('week', CURRENT_DATE) + INTERVAL '3 day' + TIME '20:15:00', 'lehreinheit', 'live', NULL, now(), 'demoadmin', NULL, 'demoadmin'),
+(date_trunc('week', CURRENT_DATE) + INTERVAL '3 day' + TIME '20:15:00', date_trunc('week', CURRENT_DATE) + INTERVAL '3 day' + TIME '21:00:00', 'lehreinheit', 'live', NULL, now(), 'demoadmin', NULL, 'demoadmin'),
+(date_trunc('week', CURRENT_DATE) + INTERVAL '4 day' + TIME '19:30:00', date_trunc('week', CURRENT_DATE) + INTERVAL '4 day' + TIME '20:15:00', 'lehreinheit', 'live', NULL, now(), 'demoadmin', NULL, 'demoadmin'),
+(date_trunc('week', CURRENT_DATE) + INTERVAL '4 day' + TIME '19:30:00', date_trunc('week', CURRENT_DATE) + INTERVAL '4 day' + TIME '20:15:00', 'lehreinheit', 'live', NULL, now(), 'demoadmin', NULL, 'demoadmin'),
+(date_trunc('week', CURRENT_DATE) + INTERVAL '4 day' + TIME '20:15:00', date_trunc('week', CURRENT_DATE) + INTERVAL '4 day' + TIME '21:00:00', 'lehreinheit', 'live', NULL, now(), 'demoadmin', NULL, 'demoadmin'),
+(date_trunc('week', CURRENT_DATE) + INTERVAL '5 day' + TIME '19:30:00', date_trunc('week', CURRENT_DATE) + INTERVAL '5 day' + TIME '20:15:00', 'lehreinheit', 'live', NULL, now(), 'demoadmin', NULL, 'demoadmin'),
+(date_trunc('week', CURRENT_DATE) + INTERVAL '5 day' + TIME '20:15:00', date_trunc('week', CURRENT_DATE) + INTERVAL '5 day' + TIME '21:00:00', 'lehreinheit', 'live', NULL, now(), 'demoadmin', NULL, 'demoadmin'),
+(date_trunc('week', CURRENT_DATE) + INTERVAL '5 day' + TIME '20:15:00', date_trunc('week', CURRENT_DATE) + INTERVAL '5 day' + TIME '21:00:00', 'reservierung', 'live', NULL, now(), 'demoadmin', NULL, 'demoadmin')
+;
+
+INSERT INTO lehre.tbl_kalender_ort
+(location, ort_kurzbz, kalender_id)
+VALUES
+(NULL, 'Bro_F7.37', 17),
+(NULL, 'Bro_F7.37', 18);
+
+INSERT INTO lehre.tbl_kalender_lehreinheit
+(lehreinheit_id, kalender_id)
+VALUES
+(51001, 16),
+(51001, 17),
+(51003, 18),
+(51001, 19),
+(51003, 20),
+(51003, 21),
+(51006, 22),
+(51004, 23),
+(51005, 24),
+(51004, 25),
+(51006, 26);
+-- ////////////////////////////////////////////////////////////////////////////////////////////////////
+
+
+-- Calendar setup for Stundenraster disabled tests
+INSERT INTO lehre.tbl_kalender
+(von, bis, typ, status_kurzbz, vorgaenger_kalender_id, insertamum, insertvon, updateamum, updatevon) VALUES
+(date_trunc('week', CURRENT_DATE) + INTERVAL '2 day' + TIME '11:20:00', date_trunc('week', CURRENT_DATE) + INTERVAL '2 day' + TIME '12:05:00', 'lehreinheit', 'live', NULL, now(), 'demoadmin', NULL, 'demoadmin'),
+(date_trunc('week', CURRENT_DATE) + INTERVAL '2 day' + TIME '11:20:00', date_trunc('week', CURRENT_DATE) + INTERVAL '2 day' + TIME '12:05:00', 'lehreinheit', 'live', NULL, now(), 'demoadmin', NULL, 'demoadmin')
+;
+
+INSERT INTO lehre.tbl_kalender_lehreinheit
+(lehreinheit_id, kalender_id)
+VALUES
+(51002, 27),
+(51006, 28);
+-- ////////////////////////////////////////////////////////////////////////////////////////////////////
+
+
+-- Calendar setup for resources tests
+INSERT INTO lehre.tbl_kalender
+(von, bis, typ, status_kurzbz, vorgaenger_kalender_id, eindeutige_gruppen_id, insertamum, insertvon, updateamum, updatevon)
+VALUES
+(date_trunc('week', CURRENT_DATE) + INTERVAL '6 day' + TIME '19:30:00', date_trunc('week', CURRENT_DATE) + INTERVAL '6 day' + TIME '20:15:00', 'lehreinheit', 'live', NULL, 'ec1bd001-facf-4b21-a671-bcb2d4f037fd', now(), 'demoadmin', NULL, 'demoadmin'),
+(date_trunc('week', CURRENT_DATE) + INTERVAL '6 day' + TIME '20:15:00', date_trunc('week', CURRENT_DATE) + INTERVAL '6 day' + TIME '21:00:00', 'lehreinheit', 'live', NULL, 'ec2bd001-facf-4b21-a671-bcb2d4f037fd', now(), 'demoadmin', NULL, 'demoadmin'),
+(date_trunc('week', CURRENT_DATE) + INTERVAL '6 day' + TIME '17:50:00', date_trunc('week', CURRENT_DATE) + INTERVAL '6 day' + TIME '18:35:00', 'lehreinheit', 'live', NULL, 'ec3bd001-facf-4b21-a671-bcb2d4f037fd', now(), 'demoadmin', NULL, 'demoadmin'),
+(date_trunc('week', CURRENT_DATE) + INTERVAL '6 day' + TIME '18:35:00', date_trunc('week', CURRENT_DATE) + INTERVAL '6 day' + TIME '19:20:00', 'lehreinheit', 'live', NULL, 'ec4bd001-facf-4b21-a671-bcb2d4f037fd', now(), 'demoadmin', NULL, 'demoadmin')
+;
+
+INSERT INTO lehre.tbl_kalender_lehreinheit
+(lehreinheit_id, kalender_id)
+VALUES
+(51004, 29),
+(51006, 30),
+(51006, 31),
+(51004, 32);
+
+-- INSERT INTO public.tbl_variablenname
+-- (name, defaultwert)
+-- VALUES('ignore_resources_collisions', 'false');
+
+INSERT INTO public.tbl_variable
+(name, uid, wert)
+VALUES('ignore_resources_collisions', 'demoadmin', 'false');
+
+INSERT INTO lehre.tbl_betriebsmittel_kalender
+(eindeutige_kalender_gruppen_id, betriebsmittel_id, anmerkung, quelle, insertamum, insertvon, updateamum, updatevon)
+VALUES
+('ec1bd001-facf-4b21-a671-bcb2d4f037fd'::uuid, 1, '', 'tempus_neu', '2026-06-17 16:48:59.669', 'demoadmin', '2026-06-17 17:13:23.000', 'demoadmin'),
+('ec2bd001-facf-4b21-a671-bcb2d4f037fd'::uuid, 1, '', 'tempus_neu', '2026-06-17 16:48:59.669', 'demoadmin', '2026-06-17 17:13:23.000', 'demoadmin'),
+('ec3bd001-facf-4b21-a671-bcb2d4f037fd'::uuid, 2, '', 'tempus_neu', '2026-06-17 16:48:59.669', 'demoadmin', '2026-06-17 17:13:23.000', 'demoadmin'),
+('ec4bd001-facf-4b21-a671-bcb2d4f037fd'::uuid, 3, '', 'tempus_neu', '2026-06-17 16:48:59.669', 'demoadmin', '2026-06-17 17:13:23.000', 'demoadmin');
+-- //////////////////////////////////////////////////////////////////////////////////////////////////--
+
+
+INSERT INTO lehre.tbl_kalender_event
+(kalender_id, titel, beschreibung)
+VALUES(26, 'Test reservation', NULL);
+
+INSERT INTO lehre.tbl_kalender_event_teilnehmer
+(kalender_id, rolle_kurzbz, uid, studiensemester_kurzbz, gruppe_kurzbz, studiengang_kz, semester, verband, gruppe, studentenlehrverband_id)
+VALUES(26, 'teilnehmer', 'demolektor4', NULL, NULL, NULL, NULL, NULL, NULL, NULL);
+
+INSERT INTO campus.tbl_zeitsperre
+(zeitsperre_id, zeitsperretyp_kurzbz, mitarbeiter_uid, bezeichnung, vondatum, vonstunde, bisdatum, bisstunde, vertretung_uid, updateamum, updatevon, insertamum, insertvon, erreichbarkeit_kurzbz, freigabeamum, freigabevon)
+VALUES(1, 'DienstV', 'demolektor1', 'Test', date_trunc('week', CURRENT_DATE) + INTERVAL '5 day', NULL, date_trunc('week', CURRENT_DATE) + INTERVAL '5 day', NULL, 'demoadmin', '2007-04-02 11:04:31.000', 'demoadmin', '2007-04-02 11:04:31.000', 'demoadmin', 't', NULL, NULL);
+
+
diff --git a/system/seeders/016_benotungstool_noten.sql b/system/seeders/016_benotungstool_noten.sql
new file mode 100644
index 000000000..1b254ef03
--- /dev/null
+++ b/system/seeders/016_benotungstool_noten.sql
@@ -0,0 +1,183 @@
+-- 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 013_studiengang_5.sql.';
+ 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,
, '', '') is created by 013_studiengang_5.sql.
+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;
diff --git a/system/seeders/017_schema_grants.sql b/system/seeders/017_schema_grants.sql
new file mode 100644
index 000000000..6b8582c8a
--- /dev/null
+++ b/system/seeders/017_schema_grants.sql
@@ -0,0 +1,40 @@
+-- Vollzugriff auf ALLE Anwendungsschemata für alle DB-Rollen.
+
+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 $$;
diff --git a/system/seeders/README.txt b/system/seeders/README.txt
new file mode 100644
index 000000000..22480e725
--- /dev/null
+++ b/system/seeders/README.txt
@@ -0,0 +1,32 @@
+== README ==
+Dieser Ordner enthält Anonyme Demo Daten
+Diese können auf einen leeren FH-Complete Dump angewandt werden um Demo/Testdaten einzufügen.
+
+Die Datensätze werden mit fixen IDs erstellt um die Verknüpfung der Elemente Untereinander zu ermöglichen.
+Danach werden die Sequences entsprechend aktualisiert.
+
+Die Inserts können daher nur auf einen leeren Dump gespielt werden da es sonst zu ID Konflikten kommmen kann.
+
+== Logik der ID Generierung ==
+
+Studiengangskennzahl 1
+Studierende des Studiengangs 1 haben Person ID 3-Stellig mit Studiengangskennzahl startend : 101, 102, 103
+PrestudentID ist 4-Stellig mit Studiengangskennzahl+PersonID: 1101, 1102, 1103
+Dadurch können Inserts leichter erstellt werden.
+
+Bei Lehrveranstaltungen gilt ähnliches Konzept für
+Studienordnung
+Studienplan
+Modul
+Lehrveranstaltung
+
+== Helper Funktionen ==
+Damit die Daten immer im aktuellen Semester angelegt werden gibt es eine Helper Funktion damit das Studiensemester nicht hartcodiert werden muss:
+
+=== NearestWintersemester ===
+
+NearestWintersemester(0) -> Liefert das näheste Wintersemester
+NearestWintersemester(-1) -> Liefert Studiensemester VOR dem nähesten Wintersemester
+NearestWintersemester(+1) -> Liefert Studiensemester NACH dem nähesten Wintersemester
+
+
diff --git a/system/setup_testinstance.php b/system/setup_testinstance.php
new file mode 100644
index 000000000..f76191be0
--- /dev/null
+++ b/system/setup_testinstance.php
@@ -0,0 +1,229 @@
+ FHComplete-3.3.sql ->
+ * checksystem.php -> seeders. This script only adds what db_setup.sh does not cover — addon
+ * dbupdates, extension SQL and a closing health check.
+ *
+ * needs psql and db_setup.sh. Connection data comes from config/system.config.inc.php.
+ */
+
+if (php_sapi_name() !== 'cli') {
+ http_response_code(403);
+ exit("Command line only.\n");
+}
+
+error_reporting(E_ALL & ~E_DEPRECATED & ~E_NOTICE & ~E_WARNING);
+set_time_limit(0);
+ini_set('memory_limit', '1024M');
+
+define('SETUP_ROOT', dirname(__DIR__));
+define('DB_SETUP_SH', '/home/testing/fhc-coreutils/tests/automated/db_setup.sh');
+
+function out($msg = '') { fwrite(STDOUT, $msg . PHP_EOL); }
+function step($msg) { out(''); out('=== ' . $msg . ' ' . str_repeat('=', max(0, 62 - strlen($msg)))); }
+function info($msg) { out(' ' . $msg); }
+function ok($msg) { out(' + ' . $msg); }
+function warn($msg) { out(' ! ' . $msg); }
+function bail($msg) { out(''); out(' X ' . $msg); out(''); exit(1); }
+
+function loadConfig()
+{
+ $file = SETUP_ROOT . '/config/system.config.inc.php';
+ if (!is_file($file)) bail("Config not found: $file");
+
+ require_once($file);
+
+ // the FHC config sets error_reporting(E_ALL); restore ours, or every include of the legacy
+ // classes prints deprecation notices over the output
+ error_reporting(E_ALL & ~E_DEPRECATED & ~E_NOTICE & ~E_WARNING);
+
+ foreach (['DB_HOST', 'DB_NAME', 'DB_USER'] as $const) {
+ if (!defined($const)) bail("$const is not defined in $file.");
+ }
+ putenv('PGPASSWORD=' . (defined('DB_PASSWORD') ? DB_PASSWORD : ''));
+}
+
+function connectDb()
+{
+ $conn = @pg_connect(sprintf(
+ 'host=%s port=%s dbname=%s user=%s password=%s',
+ DB_HOST, defined('DB_PORT') ? DB_PORT : '5432', DB_NAME, DB_USER,
+ defined('DB_PASSWORD') ? DB_PASSWORD : ''
+ ));
+ if ($conn === false) bail('Cannot connect to ' . DB_NAME . '@' . DB_HOST . ' as ' . DB_USER . '.');
+ return $conn;
+}
+
+function scalar($conn, $sql)
+{
+ $res = @pg_query($conn, $sql);
+ if ($res === false) return null;
+ $row = pg_fetch_row($res);
+ return $row ? $row[0] : null;
+}
+
+/** Runs one .sql file. Returns the error count; "already exists" is normal on a repeat run. */
+function psqlFile($file)
+{
+ exec(sprintf('psql -h %s -p %s -U %s -d %s -f %s 2>&1',
+ escapeshellarg(DB_HOST), escapeshellarg(defined('DB_PORT') ? DB_PORT : '5432'),
+ escapeshellarg(DB_USER), escapeshellarg(DB_NAME), escapeshellarg($file)), $lines);
+
+ $errors = 0;
+ foreach ($lines as $line) {
+ if (stripos($line, 'ERROR') !== false && stripos($line, 'already exists') === false) $errors++;
+ }
+ return $errors;
+}
+
+/** Guards, before anything is dropped: --setup empties the target, so refuse anything but cy-*. */
+function preflight()
+{
+ step('preflight');
+
+ if (!shell_exec('command -v psql')) bail('psql is missing. Run this on the dev machine.');
+ if (!is_file(DB_SETUP_SH)) bail('db_setup.sh not found: ' . DB_SETUP_SH);
+
+ if (strpos(DB_NAME, 'cy-') !== 0) {
+ bail("Target is '" . DB_NAME . "'. --setup drops every schema and only touches cy-* databases.");
+ }
+
+ $seeders = glob(SETUP_ROOT . '/system/seeders/*.sql');
+ if (!$seeders) bail('No seeders in ' . SETUP_ROOT . '/system/seeders/ — the rebuild would leave an empty database.');
+
+ ok(count($seeders) . ' seeders, target ' . DB_NAME);
+}
+
+/** Output is passed through verbatim, checksystem.php's HTML included. */
+function rebuild()
+{
+ step('db_setup.sh');
+ info('truncate -> FHComplete-3.3.sql -> checksystem.php -> seeders');
+ out('');
+
+ passthru(escapeshellarg(DB_SETUP_SH) . ' ' . escapeshellarg(SETUP_ROOT), $code);
+ if ($code !== 0) bail('db_setup.sh failed with exit code ' . $code . '.');
+
+ ok('database rebuilt');
+}
+
+/**
+ * Addon DB updates. Their install.php files are web installers and empty stubs; the DDL sits in
+ * addons//system/dbupdate_*.php and expects CHECKSYSTEM and a $db in scope.
+ */
+function runAddons()
+{
+ step('addons');
+
+ $files = glob(SETUP_ROOT . '/addons/*/system/dbupdate_*.php');
+ if (!$files) { info('none found'); return; }
+
+ if (!defined('CHECKSYSTEM')) define('CHECKSYSTEM', 'setup_testinstance');
+ require_once(SETUP_ROOT . '/include/basis_db.class.php');
+ $db = new basis_db();
+
+ foreach ($files as $file) {
+ $name = basename(dirname(dirname($file))) . '/' . basename($file);
+ ob_start();
+ try {
+ require($file);
+ ob_end_clean();
+ ok($name);
+ } catch (Throwable $t) {
+ ob_end_clean();
+ warn($name . ': ' . $t->getMessage());
+ }
+ }
+}
+
+/** Extension SQL, for an instance that has the files but never ran their sql//*.sql. */
+function runExtensions()
+{
+ step('extensions');
+
+ $dirs = glob(SETUP_ROOT . '/application/extensions/*/sql', GLOB_ONLYDIR);
+ if (!$dirs) { info('none found'); return; }
+
+ foreach ($dirs as $dir) {
+ // every version the extension ships, oldest first; a test instance always wants the newest schema
+ $files = glob($dir . '/*/*.sql') ?: [];
+ sort($files, SORT_NATURAL);
+
+ $errors = 0;
+ foreach ($files as $file) $errors += psqlFile($file);
+
+ $label = sprintf('%s (%d file(s)%s)', basename(dirname($dir)), count($files), $errors ? ", $errors errors" : '');
+ if ($errors) warn($label); else ok($label);
+ }
+}
+
+/**
+ * General signs of a usable instance only. Whether a given suite's fixture is in place is that
+ * suite's own business (see tests/cypress/tools/dbCheck.js).
+ */
+function verify($conn)
+{
+ step('verify');
+
+ $checks = [
+ // dump applied
+ ['Tables', "SELECT count(*) FROM information_schema.tables
+ WHERE table_schema IN ('public','lehre','campus')", function ($v) { return $v > 200; }],
+ // checksystem.php ran
+ ['Phrases', "SELECT count(*) FROM system.tbl_phrase", function ($v) { return $v > 0; }],
+ ['Permissions', "SELECT count(*) FROM system.tbl_berechtigung", function ($v) { return $v > 0; }],
+ // seeders ran
+ ['Studiensemester', "SELECT count(*) FROM public.tbl_studiensemester
+ WHERE start <= now()", function ($v) { return $v > 0; }],
+ ['Mitarbeiter', "SELECT count(*) FROM public.tbl_mitarbeiter", function ($v) { return $v > 0; }],
+ ['Studenten', "SELECT count(*) FROM public.tbl_student", function ($v) { return $v > 0; }],
+ ];
+
+ $failed = 0;
+ foreach ($checks as list($label, $sql, $test)) {
+ $value = scalar($conn, $sql);
+ $passed = $test($value);
+ if (!$passed) $failed++;
+ out(sprintf(' [%s] %-20s %s', $passed ? 'OK ' : 'XX ', $label, $value === null ? '(null)' : $value));
+ }
+
+ return $failed === 0;
+}
+
+$flags = array_slice($argv, 1);
+$setup = in_array('--setup', $flags, true);
+$verify = in_array('--verify', $flags, true);
+
+// --setup must be spelled out: the run empties the target database.
+if (!$setup && !$verify) {
+ out('');
+ out(' php system/setup_testinstance.php --setup rebuild the database, then verify');
+ out(' php system/setup_testinstance.php --verify verify only, write nothing');
+ out('');
+ exit(0);
+}
+
+loadConfig();
+
+out('');
+out(' Target : ' . DB_NAME . ' @ ' . DB_HOST);
+
+if ($setup) {
+ preflight();
+ rebuild();
+ runAddons();
+ runExtensions();
+}
+
+$success = verify(connectDb());
+
+out('');
+out($success ? ' Done.' : ' Finished with findings, see above.');
+out('');
+
+exit($success ? 0 : 1);
diff --git a/tests/cypress/.env.example b/tests/cypress/.env.example
new file mode 100644
index 000000000..ba505a998
--- /dev/null
+++ b/tests/cypress/.env.example
@@ -0,0 +1,56 @@
+# Copy to tests/cypress/.env (gitignored) and fill in.
+# THIS FILE IS COMMITTED - never put a real password in it.
+
+# ---- required -------------------------------------------------------------------------------
+
+# Your own instance, e.g. https://c3p0.dev.technikum-wien.at//fhcompletecis4
+# No default anywhere: empty fails the run rather than pointing it at a colleague's installation.
+BASE_URL=
+
+# HTTP Basic + LDAP user the API calls authenticate as. Use the LECTURER under test
+# (e.g. demolektor1) - getBenotungstoolContext resolves that user's own Lehrveranstaltungen,
+# and an admin account usually teaches nothing and would resolve none.
+USER_NAME=
+USER_PASSWORD=
+
+# ---- required: fixture reset ------------------------------------------------------------------
+# The suite resets its fixture over a direct database connection; there is no HTTP fallback, so
+# the specs fail without it. TEST_ENV_PREFIX selects the _DB_* / _SSH_* names.
+TEST_ENV_PREFIX=NOTEN
+
+NOTEN_DB_HOST=db.dev.technikum-wien.at
+NOTEN_DB_PORT=5432
+NOTEN_DB_NAME=
+NOTEN_DB_USER=
+NOTEN_DB_PASSWORD=
+NOTEN_DB_ALLOW_WRITES=true
+
+# ---- required from a workstation: SSH tunnel --------------------------------------------------
+# pg_hba.conf admits only the application server. NOTEN_DB_HOST above stays the REAL database -
+# the tunnel rewrites the connection to 127.0.0.1 itself.
+#
+# Two things that are easy to get wrong:
+# - the SSH host is usually NOT the web hostname
+# - a key whose filename is not an OpenSSH default is NEVER tried automatically; name it here,
+# or leave NOTEN_SSH_KEY empty to use your SSH agent
+NOTEN_SSH_TUNNEL=true
+NOTEN_SSH_HOST=
+NOTEN_SSH_USER=
+NOTEN_SSH_KEY=
+
+# ---- optional ---------------------------------------------------------------------------------
+
+# Semester and LV are discovered at runtime. Pin them only when discovery picks the wrong one.
+NOTEN_SEM=
+NOTEN_LV_ID=
+
+# Access-control specs: this API has no impersonation, so scoping needs a second real login.
+# NOTEN_FOREIGN_LV_ID must be an LV this teacher does NOT teach. Unset -> those specs skip.
+NOTEN_TEACHER_USER=
+NOTEN_TEACHER_PASSWORD=
+NOTEN_FOREIGN_LV_ID=
+
+# saveStudentenNoten always sends the Notenfreigabe mail (not behind a config flag). Leave off
+# unless outbound mail on this instance is a dead end. Unset -> the happy-path specs skip.
+NOTEN_FREIGABE_ENABLED=false
+NOTEN_FREIGABE_PASSWORD=
diff --git a/tests/cypress/e2e/specs/api/noten/noten.access.cy.js b/tests/cypress/e2e/specs/api/noten/noten.access.cy.js
new file mode 100644
index 000000000..d59587893
--- /dev/null
+++ b/tests/cypress/e2e/specs/api/noten/noten.access.cy.js
@@ -0,0 +1,174 @@
+/**
+ * Access control (P2) - das Scoping von assertLvAccess.
+ *
+ * Diese API kennt keine Impersonation, der Lektoren-Scope braucht daher echte Zugangsdaten:
+ * NOTEN_TEACHER_USER / NOTEN_TEACHER_PASSWORD / NOTEN_FOREIGN_LV_ID.
+ */
+
+import { notenApi } from "../../../../support/api/notenApi";
+import {
+ expectAuthError,
+ expectNotenError,
+ expectNotenSuccess,
+} from "../../../../support/helpers/notenErrors";
+import { loadNotenContext } from "../../../../support/helpers/notenTestData";
+
+const NOTEN_API = "/index.ci.php/api/frontend/v1/Noten";
+
+const teacherConfigured = () =>
+ Boolean(Cypress.env("NOTEN_TEACHER_USER") && Cypress.env("NOTEN_FOREIGN_LV_ID"));
+
+/** A getStudentenNoten call as an explicitly chosen user (or with no credentials at all). */
+const getStudentenNotenAs = (auth, lvId, semKurzbz) =>
+ cy.request({
+ method: "GET",
+ url: `${NOTEN_API}/getStudentenNoten`,
+ qs: { lv_id: lvId, sem_kurzbz: semKurzbz },
+ auth,
+ failOnStatusCode: false,
+ });
+
+describe("Noten API - access control", () => {
+ let ctx;
+
+ before(() => {
+ loadNotenContext().then((context) => {
+ ctx = context;
+ });
+ });
+
+ it("rejects requests that are not authenticated", () => {
+ cy.clearAllCookies(); // otherwise the previous login is still active
+
+ cy.request({ method: "GET", url: `${NOTEN_API}/getCisConfig`, failOnStatusCode: false }).then(
+ (response) => expectAuthError(response),
+ );
+
+ cy.request({
+ method: "GET",
+ url: `${NOTEN_API}/getCisConfig`,
+ auth: { username: "no-such-user", password: "no-such-password" },
+ failOnStatusCode: false,
+ }).then((response) => expectAuthError(response));
+ });
+
+ describe("the configured API user", () => {
+ it("can read the students of the test LV", () => {
+ notenApi.getStudentenNoten(ctx.lvId, ctx.semKurzbz).then((response) => {
+ const data = expectNotenSuccess(response, "getStudentenNoten on the own/admin LV");
+ expect(data[0], "student list").to.be.an("array").and.not.be.empty;
+ });
+ });
+ });
+
+ describe("teacher scoping", () => {
+ beforeEach(function () {
+ if (!teacherConfigured()) {
+ cy.log(
+ "Skipped: needs NOTEN_TEACHER_USER / NOTEN_TEACHER_PASSWORD and NOTEN_FOREIGN_LV_ID " +
+ "(an LV that teacher does NOT teach). This API has no impersonation, so a second " +
+ "real login is required.",
+ );
+ this.skip();
+ }
+ // cy.request reuses the session cookie of the previous (admin) call and the server prefers
+ // it over the Basic header - without this every test here silently runs as the admin user.
+ cy.clearAllCookies();
+ });
+
+ const teacherAuth = () => ({
+ username: Cypress.env("NOTEN_TEACHER_USER"),
+ password: Cypress.env("NOTEN_TEACHER_PASSWORD"),
+ });
+
+ /** Ohne das wären die Ablehnungen unten grün, weil der zweite Account gar nicht erst reinkommt. */
+ it("authenticates as the configured teacher", () => {
+ cy.request({
+ method: "GET",
+ url: "/index.ci.php/api/frontend/v1/AuthInfo/getAuthUID",
+ auth: teacherAuth(),
+ failOnStatusCode: false,
+ }).then((response) => {
+ expect(
+ response.status,
+ `NOTEN_TEACHER_USER "${Cypress.env("NOTEN_TEACHER_USER")}" cannot log in, so every ` +
+ "authorization test below would only be re-testing the login. Fix the credentials.",
+ ).to.eq(200);
+ expect(String(response.body?.data?.uid).toLowerCase()).to.eq(
+ String(Cypress.env("NOTEN_TEACHER_USER")).toLowerCase(),
+ );
+ });
+ });
+
+ it("lets a teacher read an LV they teach", () => {
+ cy.request({
+ method: "GET",
+ url: `${NOTEN_API}/getBenotungstoolContext`,
+ qs: { sem_kurzbz: ctx.semKurzbz },
+ auth: teacherAuth(),
+ failOnStatusCode: false,
+ }).then((response) => {
+ const context = expectNotenSuccess(response, "teacher getBenotungstoolContext");
+ const lvs = context.lehrveranstaltungen || [];
+
+ expect(
+ lvs.length,
+ `${Cypress.env("NOTEN_TEACHER_USER")} must teach at least one LV in ` +
+ `${ctx.semKurzbz} for this test to mean anything`,
+ ).to.be.greaterThan(0);
+
+ getStudentenNotenAs(teacherAuth(), lvs[0].lehrveranstaltung_id, ctx.semKurzbz).then(
+ (studentsResponse) => {
+ expectNotenSuccess(studentsResponse, "teacher reading their own LV");
+ },
+ );
+ });
+ });
+
+ // assertLvAccess denies through terminateWithError -> 500 + phrase, not a 401.
+ it("denies a teacher access to an LV they do not teach", () => {
+ getStudentenNotenAs(
+ teacherAuth(),
+ Cypress.env("NOTEN_FOREIGN_LV_ID"),
+ ctx.semKurzbz,
+ ).then((response) => {
+ expectNotenError(response, "keineBerechtigungNoten");
+ });
+ });
+
+ it("denies a teacher writing a grade in an LV they do not teach", () => {
+ cy.request({
+ method: "POST",
+ url: `${NOTEN_API}/saveNotenvorschlag`,
+ body: {
+ lv_id: Cypress.env("NOTEN_FOREIGN_LV_ID"),
+ sem_kurzbz: ctx.semKurzbz,
+ student_uid: ctx.students[0].uid,
+ note: ctx.gradeNotes[0],
+ punkte: null,
+ },
+ auth: teacherAuth(),
+ failOnStatusCode: false,
+ }).then((response) => {
+ expectNotenError(response, "keineBerechtigungNoten");
+ });
+ });
+ });
+
+ // getLvForStudiengang ist NICHT abgedeckt: als Admin greift isBerechtigt('admin') und jeder
+ // Aufruf gelingt. Braucht denselben Nicht-Admin-Login wie die Lektorentests oben.
+
+ describe("getBenotungstoolContext shape", () => {
+ it("returns the role-determining payload", () => {
+ notenApi.getBenotungstoolContext(ctx.semKurzbz).then((response) => {
+ const context = expectNotenSuccess(response, "getBenotungstoolContext");
+
+ expect(context).to.have.property("isAssistenz");
+ expect(context.isAssistenz, "isAssistenz is a boolean").to.be.a("boolean");
+ expect(context.studiengaenge, "studiengaenge").to.be.an("array");
+ expect(context.lehrveranstaltungen, "lehrveranstaltungen").to.be.an("array");
+ expect(context).to.have.property("preselectStudiengang_kz");
+ });
+ });
+ });
+});
diff --git a/tests/cypress/e2e/specs/api/noten/noten.bulk.cy.js b/tests/cypress/e2e/specs/api/noten/noten.bulk.cy.js
new file mode 100644
index 000000000..5cfb87555
--- /dev/null
+++ b/tests/cypress/e2e/specs/api/noten/noten.bulk.cy.js
@@ -0,0 +1,325 @@
+/**
+ * Bulk- und Importpfade (P2). Dieselben Guards wie beim einzelnen Speichern, aber je Zeile
+ * gemeldet: HTTP 200 mit der Fehlermeldung in data[uid].
+ */
+
+import { notenApi } from "../../../../support/api/notenApi";
+import {
+ expectBulkRowAccepted,
+ expectBulkRowError,
+ expectNotenSuccess,
+} from "../../../../support/helpers/notenErrors";
+import {
+ attemptDate,
+ loadNotenContext,
+ readLvGesamtnote,
+ requireDbReset,
+ resetNotenState,
+ seedBaseline,
+} from "../../../../support/helpers/notenTestData";
+import {
+ addPruefung,
+ attemptsOfStudent,
+ readState,
+} from "../../../../support/helpers/notenScenario";
+
+describe("Noten API - bulk paths", () => {
+ let ctx;
+
+ before(() => {
+ requireDbReset();
+ loadNotenContext().then((context) => {
+ ctx = context;
+ });
+ });
+
+ describe("createPruefungen", () => {
+ it("rejects only the row that breaks a §1 rule and accepts the rest", () => {
+ const atCap = ctx.students[0];
+ const fresh = ctx.students[1];
+
+ resetNotenState(ctx);
+ seedBaseline(ctx, atCap.uid, { freigegeben: true });
+ seedBaseline(ctx, fresh.uid, { freigegeben: true });
+
+ // drive the first student up to the cap - the baseline already provides Antritt 1
+ for (let i = 0; i < ctx.maxAntritte - 1; i += 1) {
+ addPruefung(ctx, atCap, {
+ note: ctx.gradeNotes[0],
+ datum: attemptDate(ctx, i + 1),
+ }).then((response) => {
+ expectNotenSuccess(response, `bring ${atCap.uid} to the cap (attempt ${i + 2})`);
+ });
+ }
+
+ const datum = attemptDate(ctx, ctx.maxAntritte + 1);
+
+ notenApi
+ .createPruefungen(
+ [
+ { uid: atCap.uid, lehreinheit_id: atCap.lehreinheit_id },
+ { uid: fresh.uid, lehreinheit_id: fresh.lehreinheit_id },
+ ],
+ datum,
+ ctx.lvId,
+ ctx.semKurzbz,
+ )
+ .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);
+ });
+
+ readState(ctx).then((data) => {
+ const created = attemptsOfStudent(data, fresh.uid);
+ expect(
+ created.map((p) => String(p.datum).slice(0, 10)),
+ "the accepted row was really created",
+ ).to.include(datum);
+ });
+ });
+
+ it("applies the chronological rule per row", () => {
+ const student = ctx.students[2];
+
+ resetNotenState(ctx);
+ seedBaseline(ctx, student.uid, { freigegeben: true });
+
+ // an open (uncounted) attempt occupies a date without consuming an Antritt
+ addPruefung(ctx, student, {
+ note: ctx.notes.nochNichtEingetragen,
+ datum: attemptDate(ctx, 3),
+ }).then((response) => {
+ expectNotenSuccess(response, "seed an open attempt");
+ });
+
+ notenApi
+ .createPruefungen(
+ [{ uid: student.uid, lehreinheit_id: student.lehreinheit_id }],
+ attemptDate(ctx, 2), // earlier than the existing attempt
+ ctx.lvId,
+ ctx.semKurzbz,
+ )
+ .then((response) => {
+ const data = expectNotenSuccess(response, "createPruefungen out of order");
+ expectBulkRowError(data, student.uid, "pruefungDatumBeforeExisting");
+ });
+ });
+
+ });
+
+ describe("savePruefungenBulk", () => {
+ beforeEach(() => {
+ requireDbReset();
+ });
+
+ it("applies the §1 rules per row", function () {
+ if (ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) {
+ // im Punktemodus leitet der Endpunkt die Note aus den Punkten ab; die Regelprüfung
+ // deckt der punktebasierte Test unten ab
+ cy.log("Skipped: CIS_GESAMTNOTE_PUNKTE ist aktiv, siehe den punktebasierten Test.");
+ this.skip();
+ }
+
+ const atCap = ctx.students[0];
+ const fresh = ctx.students[1];
+
+ resetNotenState(ctx);
+ seedBaseline(ctx, atCap.uid, { freigegeben: true });
+ seedBaseline(ctx, fresh.uid, { freigegeben: true });
+
+ for (let i = 0; i < ctx.maxAntritte - 1; i += 1) {
+ addPruefung(ctx, atCap, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, i + 1) });
+ }
+
+ const datum = attemptDate(ctx, ctx.maxAntritte + 1);
+
+ notenApi
+ .savePruefungenBulk(ctx.lvId, ctx.semKurzbz, [
+ {
+ uid: atCap.uid,
+ note: ctx.gradeNotes[0],
+ punkte: null,
+ datum,
+ lehreinheit_id: atCap.lehreinheit_id,
+ },
+ {
+ uid: fresh.uid,
+ note: ctx.gradeNotes[0],
+ punkte: null,
+ datum,
+ lehreinheit_id: fresh.lehreinheit_id,
+ },
+ ])
+ .then((response) => {
+ const data = expectNotenSuccess(response, "savePruefungenBulk");
+ expectBulkRowError(data, atCap.uid, "maxAntritteReached");
+ expectBulkRowAccepted(data, fresh.uid);
+ });
+ });
+
+ // Punktemodus: die Note kommt aus dem Notenschlüssel, die Zeile liefert nur Punkte.
+ describe("Punktemodus", () => {
+ beforeEach(function () {
+ if (!ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) {
+ cy.log("Skipped: CIS_GESAMTNOTE_PUNKTE ist aus.");
+ this.skip();
+ }
+ });
+
+ it("leitet die Note aus den Punkten ab", () => {
+ const student = ctx.students[1];
+
+ resetNotenState(ctx);
+ seedBaseline(ctx, student.uid, { freigegeben: true });
+
+ // erwartete Note zur Laufzeit erfragen, damit der Notenschlüssel nicht hardcodiert ist
+ notenApi.getNoteByPunkte(100, ctx.lvId, ctx.semKurzbz).then((response) => {
+ const erwartet = expectNotenSuccess(response, "getNoteByPunkte(100)");
+ expect(erwartet, "100 Punkte müssen eine Note ergeben").to.not.be.null;
+
+ notenApi
+ .savePruefungenBulk(ctx.lvId, ctx.semKurzbz, [
+ {
+ uid: student.uid,
+ note: null,
+ punkte: 100,
+ datum: attemptDate(ctx, 1),
+ lehreinheit_id: student.lehreinheit_id,
+ },
+ ])
+ .then((bulkResponse) => {
+ const data = expectNotenSuccess(bulkResponse, "savePruefungenBulk mit Punkten");
+ expectBulkRowAccepted(data, student.uid);
+ expect(String(data[student.uid].savedPruefung[0].note)).to.eq(String(erwartet));
+ });
+ });
+ });
+
+ it("überspringt eine Zeile ohne Punkte und schreibt die übrigen", () => {
+ const ohnePunkte = ctx.students[0];
+ const mitPunkten = ctx.students[1];
+
+ resetNotenState(ctx);
+ seedBaseline(ctx, ohnePunkte.uid, { freigegeben: true });
+ seedBaseline(ctx, mitPunkten.uid, { freigegeben: true });
+
+ notenApi
+ .savePruefungenBulk(ctx.lvId, ctx.semKurzbz, [
+ {
+ uid: ohnePunkte.uid,
+ note: null,
+ punkte: null,
+ datum: attemptDate(ctx, 1),
+ lehreinheit_id: ohnePunkte.lehreinheit_id,
+ },
+ {
+ uid: mitPunkten.uid,
+ note: null,
+ punkte: 100,
+ datum: attemptDate(ctx, 1),
+ lehreinheit_id: mitPunkten.lehreinheit_id,
+ },
+ ])
+ .then((response) => {
+ // eine kaputte Zeile darf den ganzen Import nicht abbrechen
+ const data = expectNotenSuccess(response, "savePruefungenBulk mit Luecke");
+ expectBulkRowError(data, ohnePunkte.uid, "c4punkteKeineNoteErmittelt");
+ expectBulkRowAccepted(data, mitPunkten.uid);
+ });
+ });
+ });
+ });
+
+ describe("saveNotenvorschlagBulk", () => {
+ it("writes an LV note for every row", function () {
+ if (ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) {
+ // im Punktemodus kommt die Note aus dem Notenschlüssel, siehe den Punktemodus-Block
+ cy.log("Skipped: CIS_GESAMTNOTE_PUNKTE ist aktiv.");
+ this.skip();
+ }
+
+ const [a, b] = ctx.students;
+
+ resetNotenState(ctx);
+
+ notenApi
+ .saveNotenvorschlagBulk(ctx.lvId, ctx.semKurzbz, [
+ { uid: a.uid, note: ctx.gradeNotes[0], punkte: null },
+ { uid: b.uid, note: ctx.gradeNotes[1], punkte: null },
+ ])
+ .then((response) => {
+ // nach uid geschlüsselt: je Zeile die LV-Note oder eine Fehlermeldung
+ const data = expectNotenSuccess(response, "saveNotenvorschlagBulk");
+ expectBulkRowAccepted(data, a.uid);
+ expectBulkRowAccepted(data, b.uid);
+ });
+
+ readLvGesamtnote(ctx, a.uid).then((row) => {
+ expect(row, `row for ${a.uid}`).to.not.be.null;
+ expect(String(row.note)).to.eq(String(ctx.gradeNotes[0]));
+ });
+
+ readLvGesamtnote(ctx, b.uid).then((row) => {
+ expect(row, `row for ${b.uid}`).to.not.be.null;
+ expect(String(row.note)).to.eq(String(ctx.gradeNotes[1]));
+ });
+ });
+
+ describe("Punktemodus", () => {
+ beforeEach(function () {
+ if (!ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) {
+ cy.log("Skipped: CIS_GESAMTNOTE_PUNKTE ist aus.");
+ this.skip();
+ }
+ });
+
+ it("schreibt die aus den Punkten abgeleitete Note", () => {
+ const student = ctx.students[0];
+
+ resetNotenState(ctx);
+
+ notenApi.getNoteByPunkte(100, ctx.lvId, ctx.semKurzbz).then((response) => {
+ const erwartet = expectNotenSuccess(response, "getNoteByPunkte(100)");
+ expect(erwartet, "100 Punkte müssen eine Note ergeben").to.not.be.null;
+
+ notenApi
+ .saveNotenvorschlagBulk(ctx.lvId, ctx.semKurzbz, [
+ { uid: student.uid, note: null, punkte: 100 },
+ ])
+ .then((bulk) => expectBulkRowAccepted(expectNotenSuccess(bulk, "Notenimport"), student.uid))
+ .then(() => readLvGesamtnote(ctx, student.uid))
+ .then((row) => {
+ expect(String(row.note), "Note kommt aus dem Notenschlüssel").to.eq(String(erwartet));
+ expect(Number(row.punkte), "die Punkte werden mitgeschrieben").to.eq(100);
+ });
+ });
+ });
+
+ it("überspringt eine Zeile ohne Punkte und schreibt die übrigen", () => {
+ const ohnePunkte = ctx.students[0];
+ const mitPunkten = ctx.students[1];
+
+ resetNotenState(ctx);
+
+ notenApi
+ .saveNotenvorschlagBulk(ctx.lvId, ctx.semKurzbz, [
+ { uid: ohnePunkte.uid, note: null, punkte: null },
+ { uid: mitPunkten.uid, note: null, punkte: 100 },
+ ])
+ .then((response) => {
+ // eine kaputte Zeile darf den ganzen Import nicht abbrechen
+ const data = expectNotenSuccess(response, "Notenimport mit Luecke");
+ expectBulkRowError(data, ohnePunkte.uid, "c4punkteKeineNoteErmittelt");
+ expectBulkRowAccepted(data, mitPunkten.uid);
+ });
+
+ readLvGesamtnote(ctx, ohnePunkte.uid).then((row) => {
+ expect(row, "die übersprungene Zeile bleibt ungeschrieben").to.be.null;
+ });
+ });
+ });
+ });
+});
diff --git a/tests/cypress/e2e/specs/api/noten/noten.freigabe.cy.js b/tests/cypress/e2e/specs/api/noten/noten.freigabe.cy.js
new file mode 100644
index 000000000..a2191e8d0
--- /dev/null
+++ b/tests/cypress/e2e/specs/api/noten/noten.freigabe.cy.js
@@ -0,0 +1,177 @@
+/**
+ * Notenfreigabe (P1, cases 12-14).
+ *
+ * Ein erfolgreiches saveStudentenNoten verschickt IMMER die Freigabemail (kein Config-Flag),
+ * deshalb ist der Happy Path opt-in über NOTEN_FREIGABE_ENABLED. Der Passworttest braucht das
+ * nicht: die Prüfung läuft vor jedem Schreibzugriff.
+ */
+
+import { notenApi } from "../../../../support/api/notenApi";
+import { expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
+import {
+ baselineBenotungsdatum,
+ loadNotenContext,
+ readLvGesamtnote,
+ requireDbReset,
+ resetNotenState,
+ seedBaseline,
+} from "../../../../support/helpers/notenTestData";
+import { lvNoteOf, readState } from "../../../../support/helpers/notenScenario";
+
+const freigabeEnabled = () => String(Cypress.env("NOTEN_FREIGABE_ENABLED")).toLowerCase() === "true";
+const freigabePassword = () =>
+ Cypress.env("NOTEN_FREIGABE_PASSWORD") || Cypress.env("adminpassword");
+
+/** The payload shape saveStudentenNoten expects per student (see the studlist builder). */
+const notenPayload = (student, noteBezeichnung) => ({
+ uid: student.uid,
+ matrikelnr: student.matrikelnr || student.matr_nr || "",
+ kuerzel: student.kuerzel || "",
+ nachname: student.nachname || "",
+ vorname: student.vorname || "",
+ noteBezeichnung: noteBezeichnung || "",
+});
+
+describe("Noten API - Notenfreigabe", () => {
+ let ctx;
+
+ before(() => {
+ loadNotenContext().then((context) => {
+ ctx = context;
+ });
+ });
+
+ describe("password gate", () => {
+ it("rejects a Freigabe with a wrong password and changes nothing", () => {
+ requireDbReset();
+
+ const student = ctx.students[0];
+
+ resetNotenState(ctx);
+ seedBaseline(ctx, student.uid, {
+ note: ctx.gradeNotes[0],
+ freigegeben: false,
+ benotungsdatum: baselineBenotungsdatum(ctx),
+ });
+
+ notenApi
+ .saveStudentenNoten(
+ "definitely-not-the-password",
+ [notenPayload(student)],
+ ctx.lvId,
+ ctx.semKurzbz,
+ )
+ .then((response) => {
+ expectNotenError(response, "wrongPassword");
+ });
+
+ readLvGesamtnote(ctx, student.uid).then((row) => {
+ expect(row, "the row still exists").to.not.be.null;
+ expect(row.freigabedatum, "a rejected Freigabe must not stamp freigabedatum").to.be.null;
+ });
+ });
+
+ });
+
+ describe("happy path (sends mail - opt in via NOTEN_FREIGABE_ENABLED)", () => {
+ beforeEach(function () {
+ if (!freigabeEnabled()) {
+ cy.log(
+ "Skipped: saveStudentenNoten sends the Notenfreigabe email on success. " +
+ "Set NOTEN_FREIGABE_ENABLED=true only on an environment where that mail is harmless.",
+ );
+ this.skip();
+ }
+ requireDbReset();
+ });
+
+ it("stamps freigabedatum on a changed note", () => {
+ const student = ctx.students[0];
+
+ resetNotenState(ctx);
+ seedBaseline(ctx, student.uid, {
+ note: ctx.gradeNotes[0],
+ freigegeben: false,
+ benotungsdatum: baselineBenotungsdatum(ctx),
+ });
+
+ notenApi
+ .saveStudentenNoten(
+ freigabePassword(),
+ [notenPayload(student)],
+ ctx.lvId,
+ ctx.semKurzbz,
+ )
+ .then((response) => {
+ const data = expectNotenSuccess(response, "saveStudentenNoten");
+ expect(data, "freigegebene rows are reported back").to.be.an("array");
+
+ const entry = data.find((r) => r.uid === student.uid);
+ expect(entry, `${student.uid} must be reported as freigegeben`).to.exist;
+ expect(entry.freigabedatum, "freigabedatum is stamped").to.exist;
+
+ // Die Freigabe legt den ersten Antritt an. Ohne den Verlauf in der Antwort
+ // zeigt die Tabelle den neuen Termin erst nach einem Reload.
+ expect(entry.verlauf, "der Verlauf kommt mit der Antwort zurück").to.exist;
+ expect(entry.verlauf.pruefungen, "inklusive der Termine").to.be.an("array");
+ expect(
+ entry.verlauf.pruefungen.length,
+ "der erste Antritt ist dabei",
+ ).to.be.greaterThan(0);
+ });
+
+ readState(ctx).then((data) => {
+ const grades = lvNoteOf(data, student.uid);
+ expect(grades.freigabedatum, "the note is now freigegeben").to.exist;
+ });
+ });
+
+ it("only touches rows whose benotungsdatum is newer than their freigabedatum", () => {
+ const changed = ctx.students[0];
+ const alreadyReleased = ctx.students[1];
+
+ resetNotenState(ctx);
+
+ // changed: benotungsdatum after freigabedatum -> must be re-freigegeben
+ seedBaseline(ctx, changed.uid, {
+ note: ctx.gradeNotes[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, alreadyReleased.uid, {
+ note: ctx.gradeNotes[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
+ .saveStudentenNoten(
+ freigabePassword(),
+ [notenPayload(changed), notenPayload(alreadyReleased)],
+ ctx.lvId,
+ ctx.semKurzbz,
+ )
+ .then((response) => {
+ const data = expectNotenSuccess(response, "selective Freigabe");
+ const uids = data.map((r) => r.uid);
+
+ expect(uids, "the changed note is freigegeben").to.include(changed.uid);
+ expect(
+ uids,
+ "an unchanged, already freigegebene note must not be freigegeben again",
+ ).to.not.include(alreadyReleased.uid);
+ });
+
+ readLvGesamtnote(ctx, alreadyReleased.uid).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`);
+ });
+ });
+ });
+});
diff --git a/tests/cypress/e2e/specs/api/noten/noten.frist.cy.js b/tests/cypress/e2e/specs/api/noten/noten.frist.cy.js
new file mode 100644
index 000000000..cff99a353
--- /dev/null
+++ b/tests/cypress/e2e/specs/api/noten/noten.frist.cy.js
@@ -0,0 +1,154 @@
+/**
+ * Prüfungsordnung §1 - Noteneintragungsfrist (P0, cases 8-9).
+ *
+ * Die Prüfung läuft vor jedem Schreibzugriff, ein abgelaufenes Semester wird also abgelehnt ohne
+ * eine Zeile anzufassen. Die Fehlermeldung nennt die Frist und prüft damit zugleich deren
+ * Ableitung (SS -> 15.11.yyyy, WS -> 15.05.yyyy+1).
+ */
+
+import { notenApi } from "../../../../support/api/notenApi";
+import { expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
+import {
+ attemptDate,
+ expectedFristString,
+ fristHasPassed,
+ loadNotenContext,
+ requireDbReset,
+ semesterWithPastFrist,
+} from "../../../../support/helpers/notenTestData";
+import { addPruefung, givenBaseline } from "../../../../support/helpers/notenScenario";
+
+// Deadlines computeNoteneintragungsfrist derives: SS -> 15.11.yyyy, WS -> 15.05.yyyy+1.
+const fristConfig = () => ({ ss: { month: 11, day: 15 }, ws: { month: 5, day: 15 } });
+
+describe("Noten API - Noteneintragungsfrist (Prüfungsordnung §1)", () => {
+ let ctx;
+ let enforced;
+
+ before(() => {
+ loadNotenContext().then((context) => {
+ ctx = context;
+ enforced = Boolean(ctx.cisConfig.CIS_GESAMTNOTE_NOTENEINTRAGUNGSFRIST);
+ cy.log(`CIS_GESAMTNOTE_NOTENEINTRAGUNGSFRIST = ${enforced}`);
+ });
+ });
+
+ // §7 und §11: nicht nur der Zeitpunkt der Eingabe zählt, sondern auch das Prüfungsdatum selbst.
+ // Die kommissionelle Prüfung muss bis zur Frist STATTFINDEN, sonst verliert der Student ein
+ // Semester. Das ist eine andere Frage als "darf jetzt noch eingetragen werden".
+ describe("Prüfungsdatum nach der Frist", () => {
+ beforeEach(function () {
+ if (!enforced) {
+ cy.log("Übersprungen: CIS_GESAMTNOTE_NOTENEINTRAGUNGSFRIST ist aus.");
+ this.skip();
+ }
+ requireDbReset();
+ });
+
+ it("lehnt einen Termin ab, der nach der Frist liegt", () => {
+ const student = ctx.students[0];
+ const jahr = Number(ctx.semKurzbz.slice(2, 6));
+ // die Frist des laufenden Semesters liegt in der Zukunft, ein Datum dahinter ist ungültig
+ const nachDerFrist = `${jahr + 1}-12-31`;
+
+ givenBaseline(ctx, student);
+
+ addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: nachDerFrist }).then((response) => {
+ expectNotenError(response, "pruefungsdatumNachFrist");
+ });
+ });
+
+ it("nimmt einen Termin vor der Frist an", () => {
+ const student = ctx.students[1];
+
+ givenBaseline(ctx, student);
+
+ addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 1) }).then(
+ (response) => expectNotenSuccess(response, "Termin innerhalb der Frist"),
+ );
+ });
+ });
+
+ describe("deadline has passed", () => {
+ // A rejected request writes nothing, so these need no DB reset.
+ [
+ { type: "SS", label: "Sommersemester (deadline in the same year)" },
+ { type: "WS", label: "Wintersemester (deadline in the following year)" },
+ ].forEach(({ type, label }) => {
+ it(`rejects grade entry for a ${label}`, function () {
+ // no flag, no deadline - skip rather than pretend it passed
+ if (!enforced) this.skip();
+
+ const sem = semesterWithPastFrist(type);
+ const cfg = fristConfig();
+ const expectedDeadline = expectedFristString(sem, cfg.ss, cfg.ws);
+
+ expect(fristHasPassed(sem), `${sem} deadline is in the past`).to.be.true;
+
+ notenApi
+ .saveStudentPruefung({
+ student_uid: ctx.students[0].uid,
+ note: ctx.gradeNotes[0],
+ punkte: null,
+ datum: attemptDate(ctx, 1),
+ lva_id: ctx.lvId,
+ lehreinheit_id: ctx.students[0].lehreinheit_id,
+ sem_kurzbz: sem,
+ pruefung_id: null,
+ })
+ .then((response) => {
+ expectNotenError(response, "noteneintragungsfristVorbei");
+
+ const message = response.body.errors.map((e) => e.message).join(" | ");
+ expect(
+ message,
+ `the ${type} deadline the server derived for ${sem}`,
+ ).to.include(expectedDeadline);
+ });
+ });
+ });
+
+ it("rejects saveNotenvorschlag past the deadline as well", function () {
+ if (!enforced) this.skip();
+
+ const sem = semesterWithPastFrist("SS");
+
+ notenApi
+ .saveNotenvorschlag(ctx.lvId, sem, ctx.students[0].uid, ctx.gradeNotes[0])
+ .then((response) => {
+ expectNotenError(response, "noteneintragungsfristVorbei");
+ });
+ });
+ });
+
+ describe("deadline is still ahead", () => {
+ before(() => {
+ requireDbReset();
+ });
+
+ it("accepts grade entry in a semester whose deadline has not passed", function () {
+ if (enforced && fristHasPassed(ctx.semKurzbz)) {
+ // hier ist kein Schreiben möglich, damit wäre die gesamte mutierende Suite blockiert
+ throw new Error(
+ `Noteneintragungsfrist enforcement is ON and the deadline for the test semester ` +
+ `${ctx.semKurzbz} (${expectedFristString(ctx.semKurzbz)}) has already passed, so no ` +
+ `grade can be entered. Point NOTEN_SEM at a semester whose deadline is still ahead.`,
+ );
+ }
+
+ const student = ctx.students[0];
+
+ givenBaseline(ctx, student);
+
+ addPruefung(ctx, student, {
+ note: ctx.gradeNotes[0],
+ datum: attemptDate(ctx, 1),
+ }).then((response) => {
+ expectNotenSuccess(response, `grade entry within the deadline for ${ctx.semKurzbz}`);
+ });
+ });
+ });
+
+ // Die SS/WS-Ableitung wird gegen den SERVER geprüft (die Frist in der Fehlermeldung oben) -
+ // ein lokaler Vergleich von expectedFristString() gegen Konstanten testet nur den Helfer.
+});
diff --git a/tests/cypress/e2e/specs/api/noten/noten.notenschluessel.cy.js b/tests/cypress/e2e/specs/api/noten/noten.notenschluessel.cy.js
new file mode 100644
index 000000000..567c6d28c
--- /dev/null
+++ b/tests/cypress/e2e/specs/api/noten/noten.notenschluessel.cy.js
@@ -0,0 +1,118 @@
+/**
+ * Notenschlüssel - Punkte zu Note (P0, case 10). Read-only.
+ *
+ * Statt die Zuordnung nachzubauen, wird der Bereich abgetastet und auf die Eigenschaften einer
+ * korrekten Skala geprüft: lückenlos, monoton, exakt an jeder Grenze.
+ */
+
+import { notenApi } from "../../../../support/api/notenApi";
+import { expectNotenSuccess } from "../../../../support/helpers/notenErrors";
+import { loadNotenContext } from "../../../../support/helpers/notenTestData";
+
+const MAX_PUNKTE = 100;
+
+describe("Noten API - Notenschlüssel (getNoteByPunkte)", () => {
+ let ctx;
+ let lvId;
+
+ before(() => {
+ loadNotenContext().then((context) => {
+ ctx = context;
+ lvId = context.lvId;
+ });
+ });
+
+ /** 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(punkte, lvId, ctx.semKurzbz).then((response) => {
+ const note = expectNotenSuccess(response, `getNoteByPunkte(${punkte})`);
+ observed.push({ punkte, note });
+ });
+ }
+
+ return cy.then(() => observed);
+ };
+
+ it("maps every point value through a monotonic, exact grade scale", () => {
+ sweepScale().then((observed) => {
+ const graded = observed.filter((entry) => entry.note !== null && entry.note !== undefined);
+
+ expect(
+ graded.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);
+
+ // unter der niedrigsten Schwelle antwortet das Modell null; das muss ein zusammenhängender
+ // Anfang sein, ein null NACH einer Note wäre eine Lücke in der Skala
+ const firstGradedIndex = observed.findIndex((e) => e.note !== null && e.note !== undefined);
+ observed.slice(firstGradedIndex).forEach((entry) => {
+ expect(
+ entry.note,
+ `points ${entry.punkte} fall inside the graded range and must map to a grade`,
+ ).to.not.be.oneOf([null, undefined]);
+ });
+
+ // monoton: die PKs laufen 1 (beste) .. 5, die Zahl darf also nie steigen
+ for (let i = 1; i < graded.length; i += 1) {
+ expect(
+ Number(graded[i].note),
+ `grade at ${graded[i].punkte} points must not be worse than at ${graded[i - 1].punkte}`,
+ ).to.be.at.most(Number(graded[i - 1].note));
+ }
+
+ // jede Stufe einzeln melden, damit eine verschobene Schwelle in der Ausgabe sichtbar wird
+ const boundaries = [];
+ for (let i = 1; i < graded.length; i += 1) {
+ if (Number(graded[i].note) !== Number(graded[i - 1].note)) {
+ boundaries.push({
+ punkte: graded[i].punkte,
+ note: graded[i].note,
+ previousNote: graded[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 must have at least one grade boundary in the swept range",
+ ).to.be.greaterThan(0);
+
+ // re-assert each boundary individually so a failure names the exact threshold
+ boundaries.forEach((boundary) => {
+ notenApi.getNoteByPunkte(boundary.punkte, lvId, ctx.semKurzbz).then((response) => {
+ const note = expectNotenSuccess(response, `boundary ${boundary.punkte}`);
+ expect(Number(note), `grade exactly at the ${boundary.punkte}-point boundary`).to.eq(
+ Number(boundary.note),
+ );
+ });
+
+ notenApi.getNoteByPunkte(boundary.punkte - 1, lvId, ctx.semKurzbz).then((response) => {
+ const note = expectNotenSuccess(response, `just below ${boundary.punkte}`);
+ expect(
+ Number(note),
+ `one point below the ${boundary.punkte}-point boundary must still be the worse grade`,
+ ).to.eq(Number(boundary.previousNote));
+ });
+ });
+ });
+ });
+
+ it("reports no grade for an LV without a Notenschlüssel", () => {
+ // eine nicht existierende LV hat garantiert keinen Schlüssel - fixturefrei prüfbar
+ notenApi.getNoteByPunkte(50, 0, ctx.semKurzbz).then((response) => {
+ const note = expectNotenSuccess(response, "getNoteByPunkte for an LV without a schluessel");
+ expect(note, "no Notenschlüssel -> no grade").to.be.oneOf([null, undefined]);
+ });
+ });
+
+});
diff --git a/tests/cypress/e2e/specs/api/noten/noten.notenvorschlag.cy.js b/tests/cypress/e2e/specs/api/noten/noten.notenvorschlag.cy.js
new file mode 100644
index 000000000..d55538482
--- /dev/null
+++ b/tests/cypress/e2e/specs/api/noten/noten.notenvorschlag.cy.js
@@ -0,0 +1,169 @@
+/**
+ * Notenvorschlag - LV-Note schreiben und der resultierende Status (P1, case 11).
+ *
+ * Status aus zwei Zeitstempeln (notenRules.js::checkFreigabe). Der "changed nach Freigabe"-Fall
+ * wird über eine bereits freigegebene Baseline erzeugt, damit dieser Spec ohne LDAP-Passwort und
+ * ohne Freigabemail auskommt.
+ */
+
+import { notenApi } from "../../../../support/api/notenApi";
+import { expectNotenSuccess } from "../../../../support/helpers/notenErrors";
+import {
+ baselineBenotungsdatum,
+ loadNotenContext,
+ readLvGesamtnote,
+ requireDbReset,
+ resetNotenState,
+ seedBaseline,
+} from "../../../../support/helpers/notenTestData";
+import { lvNoteOf, readState } from "../../../../support/helpers/notenScenario";
+
+describe("Noten API - Notenvorschlag", () => {
+ let ctx;
+
+ before(() => {
+ requireDbReset();
+ loadNotenContext().then((context) => {
+ ctx = context;
+ });
+ });
+
+ const readRow = (uid) => readLvGesamtnote(ctx, uid);
+
+ describe("saving a Notenvorschlag on a student without a grade", () => {
+ it("returns the stored note with a benotungsdatum and no freigabedatum", () => {
+ const student = ctx.students[0];
+
+ resetNotenState(ctx);
+
+ notenApi
+ .saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.gradeNotes[0])
+ .then((response) => {
+ const data = expectNotenSuccess(response, "saveNotenvorschlag");
+ expect(data, "saveNotenvorschlag returns [ lvgesamtnote ]").to.be.an("array").and.not
+ .be.empty;
+
+ const row = data[0];
+ expect(String(row.note), "stored note").to.eq(String(ctx.gradeNotes[0]));
+ expect(row.benotungsdatum, "benotungsdatum is stamped on save").to.exist;
+ expect(row.freigabedatum, "a fresh Notenvorschlag is not freigegeben").to.be.oneOf([
+ null,
+ undefined,
+ "",
+ ]);
+ });
+ });
+
+ it("persists the row with freigabedatum NULL (not freigegeben)", () => {
+ const student = ctx.students[0];
+
+ resetNotenState(ctx);
+ notenApi.saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.gradeNotes[0]);
+
+ readRow(student.uid).then((row) => {
+ expect(row, "the lvgesamtnote row must exist in the database").to.not.be.null;
+ expect(String(row.note)).to.eq(String(ctx.gradeNotes[0]));
+ expect(row.benotungsdatum, "benotungsdatum").to.exist;
+ expect(row.freigabedatum, "freigabedatum stays NULL until Freigabe").to.be.null;
+ });
+ });
+
+ it("reports the offen note through getNotenvorschlagStudent", () => {
+ const student = ctx.students[0];
+
+ resetNotenState(ctx);
+ notenApi.saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.gradeNotes[0]);
+
+ notenApi.getNotenvorschlagStudent(ctx.lvId, ctx.semKurzbz, student.uid).then((response) => {
+ const data = expectNotenSuccess(response, "getNotenvorschlagStudent");
+ expect(String(data[0].note)).to.eq(String(ctx.gradeNotes[0]));
+ expect(data[0].freigabedatum, "still offen").to.be.null;
+ });
+ });
+
+ // getStudentenNoten liest seit dem Wechsel auf den ungefilterten Getter auch noch nicht
+ // freigegebene Noten - sonst sind LV-Note und Freigabestatus nach einem Reload leer.
+ it("reports the offen note back through getStudentenNoten", () => {
+ const student = ctx.students[0];
+
+ resetNotenState(ctx);
+ notenApi.saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.gradeNotes[0]);
+
+ readState(ctx).then((data) => {
+ const grades = lvNoteOf(data, student.uid);
+ expect(grades, `grades entry for ${student.uid}`).to.exist;
+ expect(
+ String(grades.note_lv),
+ "a saved (not yet freigegebene) Notenvorschlag must be readable again - if this is " +
+ "null, getLvGesamtNoten's `freigabedatum < NOW()` filter dropped the row",
+ ).to.eq(String(ctx.gradeNotes[0]));
+ expect(grades.freigabedatum, "still offen").to.be.oneOf([null, undefined, ""]);
+ });
+ });
+ });
+
+ // `erstantritt: false` ist die Altdatenform: freigegebene LV-Note ohne Prüfungszeile. Nur dort
+ // ist ein direktes Umbenoten erlaubt - existiert eine Prüfung, lehnt der Server ab
+ // (validateNotenvorschlag, siehe noten.ueberschreiben).
+ describe("re-grading an already freigegebene note", () => {
+ it("moves the state from freigegeben to changed", () => {
+ const student = ctx.students[1];
+
+ resetNotenState(ctx);
+ seedBaseline(ctx, student.uid, {
+ note: ctx.gradeNotes[0],
+ freigegeben: true,
+ erstantritt: false,
+ benotungsdatum: baselineBenotungsdatum(ctx),
+ });
+
+ // baseline: benotungsdatum == freigabedatum -> freigegeben, not changed
+ readState(ctx).then((data) => {
+ const grades = lvNoteOf(data, student.uid);
+ expect(grades.note_lv, "seeded freigegebene note is visible").to.exist;
+ expect(
+ new Date(grades.benotungsdatum) > new Date(grades.freigabedatum),
+ "baseline must not already look changed",
+ ).to.be.false;
+ });
+
+ notenApi
+ .saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.gradeNotes[1])
+ .then((response) => {
+ expectNotenSuccess(response, "re-grade after Freigabe");
+ });
+
+ readState(ctx).then((data) => {
+ const grades = lvNoteOf(data, student.uid);
+
+ expect(String(grades.note_lv), "the new grade is stored").to.eq(
+ String(ctx.gradeNotes[1]),
+ );
+ expect(grades.freigabedatum, "the old Freigabe timestamp is kept").to.exist;
+ expect(
+ new Date(grades.benotungsdatum) > new Date(grades.freigabedatum),
+ "benotungsdatum must now be newer than freigabedatum (state: changed)",
+ ).to.be.true;
+ });
+ });
+
+ it("overwrites the grade rather than adding a second row", () => {
+ const student = ctx.students[1];
+
+ resetNotenState(ctx);
+ seedBaseline(ctx, student.uid, {
+ note: ctx.gradeNotes[0],
+ freigegeben: true,
+ erstantritt: false,
+ });
+
+ notenApi.saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.gradeNotes[1]);
+
+ readRow(student.uid).then((row) => {
+ expect(row, "the single lvgesamtnote row").to.not.be.null;
+ expect(String(row.note)).to.eq(String(ctx.gradeNotes[1]));
+ });
+ });
+ });
+
+});
diff --git a/tests/cypress/e2e/specs/api/noten/noten.pruefungsantritte.cy.js b/tests/cypress/e2e/specs/api/noten/noten.pruefungsantritte.cy.js
new file mode 100644
index 000000000..224c12db7
--- /dev/null
+++ b/tests/cypress/e2e/specs/api/noten/noten.pruefungsantritte.cy.js
@@ -0,0 +1,469 @@
+/**
+ * Prüfungsordnung §1 - Prüfungsantritte.
+ *
+ * Die Regeln greifen in der Reihenfolge A (Maximum) -> B (Chronologie) -> C (Vorkommenslimit).
+ * Jeder Test baut einen Zustand, in dem genau eine davon feuern kann, sonst maskiert die frühere.
+ *
+ * Gezählt wird über die NOTE: entschuldigt, "Noch nicht eingetragen" und "Nicht beurteilt" zählen
+ * nie mit. Antritt 1 ist eine echte Prüfung (von givenBaseline geseedet).
+ */
+
+import { notenApi } from "../../../../support/api/notenApi";
+import { expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
+import {
+ attemptDate,
+ baselineDate,
+ loadNotenContext,
+ requireDbReset,
+ seedPruefung,
+ shiftDate,
+} from "../../../../support/helpers/notenTestData";
+import {
+ addPruefung,
+ attemptsOfStudent,
+ countingAttemptsOfStudent,
+ editPruefung,
+ givenBaseline,
+ readState,
+ verlaufOfStudent,
+} from "../../../../support/helpers/notenScenario";
+
+describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
+ let ctx;
+
+ before(() => {
+ requireDbReset();
+ loadNotenContext().then((context) => {
+ ctx = context;
+
+ cy.log(
+ `LV ${ctx.lvId} / ${ctx.semKurzbz} | maxAntritte=${ctx.maxAntritte} | ` +
+ `entschuldigt=${ctx.notes.entschuldigt}`,
+ );
+
+ expect(
+ ctx.maxAntritte,
+ "needs room for at least one retake beyond the first Antritt",
+ ).to.be.greaterThan(1);
+ });
+ });
+
+ // one student per test, so a leaked row cannot reach the next scenario
+ const studentFor = (index) => ctx.students[index % ctx.students.length];
+
+ /** Adds counting attempts until the cap is reached. Baseline already provides Antritt 1. */
+ const fillToCap = (student, firstIndex = 1) => {
+ for (let i = 0; i < ctx.maxAntritte - 1; i += 1) {
+ addPruefung(ctx, student, {
+ note: ctx.gradeNotes[0],
+ datum: attemptDate(ctx, firstIndex + i),
+ }).then((response) => {
+ expectNotenSuccess(response, `attempt ${i + 2} of ${ctx.maxAntritte}`);
+ });
+ }
+ return attemptDate(ctx, firstIndex + ctx.maxAntritte - 1);
+ };
+
+ // Alle Regeln hängen an diesen PKs. Auf Demodaten ist entschuldigt 14, nicht die 17 aus
+ // application/config/noten.php - gewinnt der Fallback, greifen die Regeln stillschweigend nicht mehr.
+ it("resolves the special notes from tbl_note, not from config", () => {
+ notenApi.getCisConfig().then((response) => {
+ const config = expectNotenSuccess(response, "getCisConfig");
+
+ expect(String(config.NOTE_ENTSCHULDIGT)).to.eq(String(ctx.notes.entschuldigt));
+ expect(config.NOTEN_OHNE_ANTRITT.map(String)).to.include.members([
+ String(ctx.notes.entschuldigt),
+ String(ctx.notes.nochNichtEingetragen),
+ ]);
+ expect(
+ Object.keys(config.NOTEN_OCCURANCE_LIMIT_MAP).map(String),
+ "the occurrence limit must be keyed on the resolved PK",
+ ).to.include(String(ctx.notes.entschuldigt));
+ });
+ });
+
+ it("exposes the attempt cap instead of the retake type toggles", () => {
+ notenApi.getCisConfig().then((response) => {
+ const config = expectNotenSuccess(response, "getCisConfig");
+
+ expect(config.CIS_GESAMTNOTE_MAX_ANTRITTE, "the cap is derived server-side").to.eq(ctx.maxAntritte);
+ expect(config, "the client no longer needs the type toggles").to.not.have.property(
+ "CIS_GESAMTNOTE_PRUEFUNG_TERMIN2",
+ );
+ });
+ });
+
+ describe("Rule A - maximum number of Prüfungsantritte", () => {
+ it("rejects an attempt once the configured maximum is reached", () => {
+ const student = studentFor(0);
+
+ givenBaseline(ctx, student);
+
+ const nextDate = fillToCap(student);
+
+ addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: nextDate }).then((response) => {
+ expectNotenError(response, "maxAntritteReached");
+ });
+
+ // and nothing was written for the rejected attempt
+ readState(ctx).then((data) => {
+ const dates = attemptsOfStudent(data, student.uid).map((p) => String(p.datum).slice(0, 10));
+ expect(dates, "no row may carry the rejected date").to.not.include(nextDate);
+
+ expect(countingAttemptsOfStudent(data, student.uid), "exactly maxAntritte counting rows")
+ .to.have.length(ctx.maxAntritte);
+ });
+ });
+ });
+
+ describe("Rule B - attempts are taken in chronological order", () => {
+ // "Noch nicht eingetragen" belegt ein Datum ohne zu zählen, damit Regel A nicht maskiert
+ const givenOpenAttemptOn = (student, datum) => {
+ givenBaseline(ctx, student);
+ addPruefung(ctx, student, { note: ctx.notes.nochNichtEingetragen, datum }).then((response) => {
+ expectNotenSuccess(response, "seed an open (uncounted) attempt");
+ });
+ };
+
+ it("rejects a new attempt dated on the same day as an existing one", () => {
+ const student = studentFor(1);
+ const existing = attemptDate(ctx, 2);
+
+ givenOpenAttemptOn(student, existing);
+
+ addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: existing }).then((response) => {
+ expectNotenError(response, "pruefungDatumBeforeExisting");
+ });
+ });
+
+ it("rejects a new attempt dated before an existing one", () => {
+ const student = studentFor(1);
+
+ givenOpenAttemptOn(student, attemptDate(ctx, 2));
+
+ addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 1) }).then(
+ (response) => {
+ expectNotenError(response, "pruefungDatumBeforeExisting");
+ },
+ );
+ });
+
+ it("accepts a new attempt dated after every existing one", () => {
+ const student = studentFor(1);
+
+ givenOpenAttemptOn(student, attemptDate(ctx, 2));
+
+ addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 3) }).then(
+ (response) => {
+ expectNotenSuccess(response, "attempt strictly after the existing one");
+ },
+ );
+ });
+ });
+
+ describe("Rule C - 'entschuldigt' may be assigned only once", () => {
+ it("rejects a second entschuldigt attempt", () => {
+ const student = studentFor(2);
+
+ givenBaseline(ctx, student);
+
+ addPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: attemptDate(ctx, 1) }).then(
+ (response) => expectNotenSuccess(response, "first entschuldigt attempt"),
+ );
+
+ addPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: attemptDate(ctx, 2) }).then(
+ (response) => expectNotenError(response, "noteOccuranceLimitReached"),
+ );
+
+ readState(ctx).then((data) => {
+ const excused = attemptsOfStudent(data, student.uid).filter(
+ (p) => String(p.note) === String(ctx.notes.entschuldigt),
+ );
+ expect(excused, "exactly one excused attempt may exist").to.have.length(1);
+ });
+ });
+ });
+
+ describe("entschuldigt does not consume an attempt", () => {
+ it("still accepts a real grade after an excused attempt", () => {
+ const student = studentFor(0);
+
+ givenBaseline(ctx, student);
+
+ addPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: attemptDate(ctx, 1) }).then(
+ (response) => expectNotenSuccess(response, "excused attempt"),
+ );
+
+ // excused must not count, so this real grade is still within the cap
+ addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 2) }).then(
+ (response) =>
+ expectNotenSuccess(
+ response,
+ "a real grade after an excused attempt (excused must not count towards the cap)",
+ ),
+ );
+
+ readState(ctx).then((data) => {
+ expect(
+ verlaufOfStudent(data, student.uid).antrittCount,
+ "baseline + the real grade, the excused one not counted",
+ ).to.eq(2);
+ });
+ });
+
+ it("preserves the excused entry as its own dated row instead of overwriting it", () => {
+ const student = studentFor(0);
+
+ givenBaseline(ctx, student);
+
+ addPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: attemptDate(ctx, 1) });
+ addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 2) });
+
+ readState(ctx).then((data) => {
+ const attempts = attemptsOfStudent(data, student.uid);
+
+ expect(attempts, "baseline, the excused row and the new row must coexist").to.have.length(3);
+
+ expect(String(attempts[1].note), "second row keeps the excused grade").to.eq(
+ String(ctx.notes.entschuldigt),
+ );
+ expect(String(attempts[1].datum).slice(0, 10)).to.eq(attemptDate(ctx, 1));
+
+ expect(String(attempts[2].note), "third row carries the real grade").to.eq(
+ String(ctx.gradeNotes[0]),
+ );
+ expect(String(attempts[2].datum).slice(0, 10)).to.eq(attemptDate(ctx, 2));
+ });
+ });
+ });
+
+ describe("die kommissionelle Prüfung ist der letzte Antritt", () => {
+ // §17 Abs 1: die zweite Wiederholung ist kommissionell. Der Server leitet den Typ aus der
+ // Position ab und schreibt ihn in pruefungstyp_kurzbz, weil die Studierendenverwaltung
+ // diese Spalte weiterhin liest.
+ it("schreibt den letzten Antritt als kommPruef", () => {
+ const student = studentFor(3);
+
+ givenBaseline(ctx, student);
+
+ // bis zum vorletzten Antritt auffüllen
+ for (let i = 1; i < ctx.maxAntritte - 1; i += 1) {
+ addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, i) }).then(
+ (response) => expectNotenSuccess(response, `Antritt ${i + 1}`),
+ );
+ }
+
+ addPruefung(ctx, student, {
+ note: ctx.gradeNotes[0],
+ datum: attemptDate(ctx, ctx.maxAntritte),
+ }).then((response) => {
+ const [saved] = expectNotenSuccess(response, "letzter Antritt");
+ expect(saved.pruefungstyp_kurzbz, "der letzte Antritt ist kommissionell").to.eq("kommPruef");
+ });
+
+ readState(ctx).then((data) => {
+ const verlauf = verlaufOfStudent(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;
+ });
+ });
+
+ // Auch eine kommissionelle Prüfung ohne zählende Note schliesst die Kette. Sonst liesse sich
+ // nach einer noch unbenoteten kommissionellen ein weiterer Antritt anlegen.
+ it("sperrt weitere Antritte auch bei einer kommPruef ohne zählende Note", () => {
+ const student = studentFor(1);
+
+ givenBaseline(ctx, student);
+
+ seedPruefung(ctx, student, {
+ note: ctx.notes.nochNichtEingetragen,
+ datum: attemptDate(ctx, 1),
+ typ: "kommPruef",
+ });
+
+ readState(ctx).then((data) => {
+ const verlauf = verlaufOfStudent(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.gradeNotes[0], datum: attemptDate(ctx, 2) }).then(
+ (response) => expectNotenError(response, "maxAntritteReached"),
+ );
+ });
+
+ it("refuses a further attempt once a kommPruef exists", () => {
+ const student = studentFor(0);
+
+ givenBaseline(ctx, student);
+
+ // kommPruef is entered in a different tool - no API path, hence the direct seed
+ seedPruefung(ctx, student, {
+ note: ctx.gradeNotes[0],
+ datum: attemptDate(ctx, 1),
+ typ: "kommPruef",
+ });
+
+ // dated after the kommPruef, so only Rule A can fire
+ addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 2) }).then(
+ (response) => expectNotenError(response, "maxAntritteReached"),
+ );
+
+ readState(ctx).then((data) => {
+ const verlauf = verlaufOfStudent(data, student.uid);
+ expect(verlauf.terminal, "the Verlauf is marked terminal").to.be.true;
+ expect(verlauf.canAdd, "and closed for further attempts").to.be.false;
+ });
+ });
+ });
+
+ describe("'Noch nicht eingetragen' does not consume an attempt", () => {
+ it("leaves the full cap available after an uncounted attempt", () => {
+ const student = studentFor(1);
+
+ givenBaseline(ctx, student);
+
+ // occupies a date without counting
+ addPruefung(ctx, student, {
+ note: ctx.notes.nochNichtEingetragen,
+ datum: attemptDate(ctx, 1),
+ }).then((response) => expectNotenSuccess(response, "uncounted attempt"));
+
+ // the whole remaining cap is still available afterwards
+ const nextDate = fillToCap(student, 2);
+
+ addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: nextDate }).then((response) => {
+ expectNotenError(response, "maxAntritteReached");
+ });
+ });
+ });
+
+ describe("Anrechnung - no Prüfungen at all", () => {
+ it("refuses an attempt while the Zeugnisnote is angerechnet", () => {
+ const angerechnet = ctx.notes.angerechnet ?? ctx.notes.internAngerechnet;
+ if (!angerechnet) {
+ cy.log("no Anrechnungsnote in tbl_note - skipped");
+ return;
+ }
+
+ // braucht einen Studenten, dessen Fixture-Zeugnisnote eine Anrechnung ist
+ readState(ctx).then((data) => {
+ const target = (data[0] || []).find((s) => String(s.note) === String(angerechnet));
+ if (!target) {
+ cy.log(`no student with Zeugnisnote ${angerechnet} in LV ${ctx.lvId} - skipped`);
+ return;
+ }
+
+ const verlauf = verlaufOfStudent(data, target.uid);
+ expect(verlauf.angerechnet, "the Verlauf marks the Anrechnung").to.be.true;
+ expect(verlauf.canAdd, "and blocks further attempts").to.be.false;
+
+ addPruefung(ctx, target, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 1) }).then(
+ (response) => expectNotenError(response, "c4angerechnetKeinePruefung"),
+ );
+ });
+ });
+ });
+
+ describe("edit guards", () => {
+ /** Antritt 1 < entschuldigt < echte Note; liefert die entschuldigte Zeile (Nachbarn beidseits). */
+ const givenThreeAttempts = (student) => {
+ givenBaseline(ctx, student);
+
+ addPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: attemptDate(ctx, 1) });
+ addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 2) });
+
+ return readState(ctx).then((data) => {
+ const excused = attemptsOfStudent(data, student.uid).find(
+ (p) => String(p.note) === String(ctx.notes.entschuldigt),
+ );
+ expect(excused, "excused attempt to edit").to.exist;
+ return excused;
+ });
+ };
+
+ it("rejects changing the grade once a later attempt exists", () => {
+ const student = studentFor(1);
+
+ givenThreeAttempts(student).then((excused) => {
+ editPruefung(ctx, student, {
+ pruefungId: excused.pruefung_id,
+ note: ctx.gradeNotes[1], // different grade -> locked
+ datum: attemptDate(ctx, 1),
+ }).then((response) => expectNotenError(response, "pruefungNoteLocked"));
+ });
+ });
+
+ it("still allows a date-only correction between the neighbouring attempts", () => {
+ const student = studentFor(1);
+
+ givenThreeAttempts(student).then((excused) => {
+ // same note -> the lock does not apply; date stays strictly inside (baseline, attempt2)
+ editPruefung(ctx, student, {
+ pruefungId: excused.pruefung_id,
+ note: excused.note,
+ datum: shiftDate(attemptDate(ctx, 1), 3),
+ })
+ .then((response) => expectNotenSuccess(response, "date-only correction inside the bounds"))
+ .then(() => readState(ctx))
+ .then((data) => {
+ const moved = attemptsOfStudent(data, student.uid).find(
+ (p) => p.pruefung_id === excused.pruefung_id,
+ );
+ expect(moved, "the addressed row still exists").to.exist;
+ expect(String(moved.datum).slice(0, 10), "and it is the one that moved").to.eq(
+ shiftDate(attemptDate(ctx, 1), 3),
+ );
+ });
+ });
+ });
+
+ // Regel C beim Bearbeiten; die Gegenprobe (die entschuldigte Zeile selbst neu speichern)
+ // ist die reine Datumskorrektur oben
+ it("rejects an edit that would exceed the entschuldigt limit", () => {
+ const student = studentFor(0);
+
+ // the real attempt is the latest one, so no note lock masks the limit
+ givenThreeAttempts(student)
+ .then(() => readState(ctx))
+ .then((data) =>
+ attemptsOfStudent(data, student.uid).find(
+ (p) => String(p.datum).slice(0, 10) === attemptDate(ctx, 2),
+ ),
+ )
+ .then((real) => {
+ expect(real, "the latest attempt").to.exist;
+ return editPruefung(ctx, student, {
+ pruefungId: real.pruefung_id,
+ note: ctx.notes.entschuldigt,
+ datum: attemptDate(ctx, 2),
+ });
+ })
+ .then((response) => expectNotenError(response, "noteOccuranceLimitReached"));
+ });
+
+ it("rejects a date on or before the previous attempt", () => {
+ const student = studentFor(2);
+
+ givenThreeAttempts(student).then((excused) => {
+ editPruefung(ctx, student, {
+ pruefungId: excused.pruefung_id,
+ note: excused.note,
+ datum: baselineDate(ctx), // == the Antritt-1 date -> not strictly after
+ }).then((response) => expectNotenError(response, "pruefungDatumOutOfRange"));
+ });
+ });
+
+ it("rejects a date on or after the following attempt", () => {
+ const student = studentFor(2);
+
+ givenThreeAttempts(student).then((excused) => {
+ editPruefung(ctx, student, {
+ pruefungId: excused.pruefung_id,
+ note: excused.note,
+ datum: attemptDate(ctx, 2), // == the later attempt -> not strictly before
+ }).then((response) => expectNotenError(response, "pruefungDatumOutOfRange"));
+ });
+ });
+ });
+});
diff --git a/tests/cypress/e2e/specs/api/noten/noten.pruefungstermin.cy.js b/tests/cypress/e2e/specs/api/noten/noten.pruefungstermin.cy.js
new file mode 100644
index 000000000..d66732f95
--- /dev/null
+++ b/tests/cypress/e2e/specs/api/noten/noten.pruefungstermin.cy.js
@@ -0,0 +1,257 @@
+/**
+ * Der Schreibpfad (savePruefungFuerStudent), den saveStudentPruefung, createPruefungen und
+ * savePruefungenBulk teilen. Die Validatoren decken die anderen Specs ab.
+ *
+ * Invariante: eine Aktion schreibt genau eine Prüfung; Antritt 1 entsteht bei der Freigabe.
+ */
+
+import { expectNotenSuccess } from "../../../../support/helpers/notenErrors";
+import {
+ attemptDate,
+ loadNotenContext,
+ readLvGesamtnote,
+ requireDbReset,
+ resetNotenState,
+ seedPruefung,
+ shiftDate,
+} from "../../../../support/helpers/notenTestData";
+import {
+ addPruefung,
+ attemptsOfStudent,
+ editPruefung,
+ givenBaseline,
+ readState,
+ verlaufOfStudent,
+} from "../../../../support/helpers/notenScenario";
+import { notenApi, pruefungenOf } from "../../../../support/api/notenApi";
+
+const dayOf = (value) => String(value).slice(0, 10);
+
+describe("Noten API - Prüfungstermin (write path)", () => {
+ let ctx;
+
+ before(() => {
+ requireDbReset();
+ loadNotenContext().then((context) => {
+ ctx = context;
+ expect(context.maxAntritte, "needs room for at least one retake").to.be.greaterThan(1);
+ });
+ });
+
+ const studentFor = (index) => ctx.students[index % ctx.students.length];
+
+ it("writes exactly one Prüfung per add - no snapshot alongside it", () => {
+ const student = studentFor(0);
+
+ givenBaseline(ctx, student);
+
+ addPruefung(ctx, student, { note: ctx.gradeNotes[1], datum: attemptDate(ctx, 1) })
+ .then((response) => {
+ const [saved, lvgesamtnote, verlauf] = expectNotenSuccess(response, "add an attempt");
+
+ expect(saved, "savedPruefung").to.exist;
+ expect(String(saved.note)).to.eq(String(ctx.gradeNotes[1]));
+ expect(lvgesamtnote, "lvgesamtnote is returned so the client can refresh the row").to.exist;
+ expect(verlauf, "verlauf is returned").to.exist;
+ expect(verlauf.pruefungen, "verlauf carries the attempts").to.be.an("array");
+ })
+ .then(() => readState(ctx))
+ .then((data) => {
+ const attempts = attemptsOfStudent(data, student.uid);
+ expect(attempts, "baseline Antritt 1 plus exactly one added row").to.have.length(2);
+ });
+ });
+
+ it("derives position and Antrittsnummer server-side", () => {
+ const student = studentFor(1);
+
+ givenBaseline(ctx, student);
+
+ addPruefung(ctx, student, { note: ctx.gradeNotes[1], datum: attemptDate(ctx, 1) })
+ .then(() => readState(ctx))
+ .then((data) => {
+ const attempts = attemptsOfStudent(data, student.uid);
+
+ expect(attempts.map((p) => p.position), "positions are 1..n in date order").to.deep.eq([1, 2]);
+ expect(attempts.map((p) => p.antritt_nr), "both count as attempts").to.deep.eq([1, 2]);
+ attempts.forEach((p) => expect(p.zaehlt, `zaehlt of position ${p.position}`).to.be.true);
+
+ const verlauf = verlaufOfStudent(data, student.uid);
+ expect(verlauf.antrittCount, "both attempts counted").to.eq(2);
+ expect(verlauf.maxAntritte, "the cap comes from the server").to.eq(ctx.maxAntritte);
+ expect(verlauf.canAdd, `canAdd with 2 of ${ctx.maxAntritte} used`).to.eq(2 < ctx.maxAntritte);
+ });
+ });
+
+ it("does not count an excused attempt, and keeps it as its own row", () => {
+ const student = studentFor(2);
+
+ givenBaseline(ctx, student);
+
+ addPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: attemptDate(ctx, 1) })
+ .then((response) => expectNotenSuccess(response, "excused attempt"))
+ .then(() => readState(ctx))
+ .then((data) => {
+ const attempts = attemptsOfStudent(data, student.uid);
+
+ expect(attempts, "the excused row is kept, not merged").to.have.length(2);
+
+ const excused = attempts[1];
+ expect(excused.zaehlt, "excused consumes no attempt").to.be.false;
+ expect(excused.antritt_nr, "and carries no Antrittsnummer").to.be.null;
+
+ expect(verlaufOfStudent(data, student.uid).antrittCount, "still one attempt used").to.eq(1);
+ });
+ });
+
+ it("appends a new row per add instead of overwriting the previous one", () => {
+ // nur eine explizite pruefung_id aktualisiert, ein Add fügt immer ein
+ const student = studentFor(0);
+
+ givenBaseline(ctx, student);
+
+ let firstId;
+ addPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: attemptDate(ctx, 1) })
+ .then((response) => {
+ firstId = expectNotenSuccess(response, "first add")[0].pruefung_id;
+ return addPruefung(ctx, student, { note: ctx.gradeNotes[1], datum: attemptDate(ctx, 2) });
+ })
+ .then((response) => expectNotenSuccess(response, "second add"))
+ .then(() => readState(ctx))
+ .then((data) => {
+ const attempts = attemptsOfStudent(data, student.uid);
+
+ expect(attempts, "baseline + two added rows").to.have.length(3);
+ expect(attempts.map((p) => p.pruefung_id), "the first add still exists").to.include(firstId);
+ });
+ });
+
+ it("edits the pruefung identified by pruefung_id", () => {
+ // An excused and a real attempt side by side - no endpoint builds this, hence the direct seed
+ const student = studentFor(2);
+ const excusedDate = attemptDate(ctx, 1);
+ const gradedDate = attemptDate(ctx, 2);
+ const movedTo = shiftDate(excusedDate, 5);
+
+ givenBaseline(ctx, student);
+
+ let excusedId;
+ seedPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: excusedDate, typ: "Termin2" })
+ .then((seeded) => {
+ excusedId = seeded.pruefungId;
+ return seedPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: gradedDate, typ: "Termin2" });
+ })
+ // note stays entschuldigt: a later pruefung exists, so only the datum may move
+ .then(() =>
+ editPruefung(ctx, student, {
+ pruefungId: excusedId,
+ note: ctx.notes.entschuldigt,
+ datum: movedTo,
+ }),
+ )
+ .then((response) => expectNotenSuccess(response, "moving the excused attempt"))
+ .then(() => readState(ctx))
+ .then((data) => {
+ const rows = pruefungenOf(data, student.uid);
+ const excused = rows.find((p) => p.pruefung_id === excusedId);
+ const graded = rows.find((p) => dayOf(p.datum) === gradedDate);
+
+ // the untargeted row first: silently rewriting a real grade is the worse outcome
+ expect(graded, "the other attempt still exists").to.exist;
+ expect(String(graded.note), "the untargeted attempt keeps its grade").to.eq(
+ String(ctx.gradeNotes[0]),
+ );
+
+ expect(excused, "the excused attempt still exists").to.exist;
+ expect(dayOf(excused.datum), "the row named by pruefung_id moved").to.eq(movedTo);
+ });
+ });
+
+ it("adds an attempt while the LV note is still offen", () => {
+ // liest der Schreibpfad gefiltert, sieht er die Note nicht, versucht ein INSERT gegen den
+ // bestehenden PK und antwortet für immer "keine LV-Note eingetragen"
+ const student = studentFor(3);
+
+ givenBaseline(ctx, student, { freigegeben: false });
+
+ addPruefung(ctx, student, { note: ctx.gradeNotes[1], datum: attemptDate(ctx, 1) }).then((response) => {
+ expectNotenSuccess(response, "attempt on an un-freigegebene LV note");
+ });
+ });
+
+ it("stores an empty note as 'Noch nicht eingetragen'", () => {
+ const student = studentFor(1);
+
+ givenBaseline(ctx, student);
+
+ addPruefung(ctx, student, { note: "", datum: attemptDate(ctx, 1) }).then((response) => {
+ const [saved] = expectNotenSuccess(response, "attempt without a grade");
+ expect(String(saved.note), "empty note is normalised").to.eq(
+ String(ctx.notes.nochNichtEingetragen),
+ );
+ });
+ });
+
+ it("writes the LV note from the attempt's grade", () => {
+ const student = studentFor(3);
+
+ givenBaseline(ctx, student);
+
+ addPruefung(ctx, student, { note: ctx.gradeNotes[1], datum: attemptDate(ctx, 1) })
+ .then((response) => expectNotenSuccess(response, "attempt with a grade"))
+ .then(() => readLvGesamtnote(ctx, student.uid))
+ .then((row) => {
+ expect(String(row.note), "the LV note follows the newest attempt").to.eq(
+ String(ctx.gradeNotes[1]),
+ );
+ });
+ });
+
+ it("creates the LV note when a student has none yet", () => {
+ // der Bulk-Pfad läuft durch denselben Kern und schreibt die LV-Note vor dem Termin
+ const student = studentFor(0);
+
+ resetNotenState(ctx);
+
+ notenApi
+ .createPruefungen(
+ [{ uid: student.uid, lehreinheit_id: student.lehreinheit_id }],
+ attemptDate(ctx, 1),
+ ctx.lvId,
+ ctx.semKurzbz,
+ )
+ .then((response) => {
+ const data = expectNotenSuccess(response, "createPruefungen without an LV note");
+ expect(data[student.uid], `row result for ${student.uid}`).to.be.an("object");
+ expect(data[student.uid].savedPruefung, "the Prüfung was written").to.exist;
+ })
+ .then(() => readLvGesamtnote(ctx, student.uid))
+ .then((row) => {
+ expect(row, "the LV note was created alongside").to.not.be.null;
+ expect(String(row.note), "carries the Prüfung's note").to.eq(
+ String(ctx.notes.nochNichtEingetragen),
+ );
+ });
+ });
+
+ it("applies a chosen note to both the Prüfung and the LV note in the bulk path", () => {
+ const student = studentFor(1);
+
+ resetNotenState(ctx);
+
+ notenApi
+ .createPruefungen(
+ [{ uid: student.uid, lehreinheit_id: student.lehreinheit_id }],
+ attemptDate(ctx, 1),
+ ctx.lvId,
+ ctx.semKurzbz,
+ ctx.gradeNotes[1],
+ )
+ .then((response) => {
+ const data = expectNotenSuccess(response, "createPruefungen with a note");
+ expect(String(data[student.uid].savedPruefung[0].note)).to.eq(String(ctx.gradeNotes[1]));
+ })
+ .then(() => readLvGesamtnote(ctx, student.uid))
+ .then((row) => expect(String(row.note)).to.eq(String(ctx.gradeNotes[1])));
+ });
+});
diff --git a/tests/cypress/e2e/specs/api/noten/noten.ueberschreiben.cy.js b/tests/cypress/e2e/specs/api/noten/noten.ueberschreiben.cy.js
new file mode 100644
index 000000000..509b2a4ed
--- /dev/null
+++ b/tests/cypress/e2e/specs/api/noten/noten.ueberschreiben.cy.js
@@ -0,0 +1,180 @@
+/**
+ * Overwrite-Regeln der Notenvorschlag-Spalte.
+ *
+ * Drei Regeln: die Editorliste bietet nur lehre-Noten an, die Spalte ist gesperrt, sobald eine
+ * Prüfung existiert, und eine Zeugnisnote mit lkt_ueberschreibbar = false sperrt sie ebenfalls.
+ * Alle drei stehen in Benotungstool.js UND in Noten::validateNotenvorschlag - der direkte
+ * API-Aufruf und der CSV-Import erreichen den Client nie.
+ */
+
+import { expectBulkRowError, expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
+import {
+ attemptDate,
+ loadNotenContext,
+ readLvGesamtnote,
+ requireDbReset,
+ seedZeugnisnote,
+} from "../../../../support/helpers/notenTestData";
+import { addPruefung, givenBaseline } from "../../../../support/helpers/notenScenario";
+import { notenApi } from "../../../../support/api/notenApi";
+
+describe("Noten API - Notenvorschlag overwrite rules", () => {
+ let ctx;
+
+ before(() => {
+ requireDbReset();
+ loadNotenContext().then((context) => {
+ ctx = context;
+ });
+ });
+
+ const studentFor = (index) => ctx.students[index % ctx.students.length];
+
+ it("refuses a note the editor list never offers", function () {
+ if (ctx.notes.nichtLehre === null) {
+ this.skip(); // every active note is a lehre note on this instance
+ }
+
+ const student = studentFor(0);
+
+ // ohne Erstantritt: sonst greift die Prüfungsregel und die Notenregel bliebe ungeprüft
+ givenBaseline(ctx, student, { erstantritt: false });
+
+ notenApi
+ .saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.notes.nichtLehre)
+ .then((response) => expectNotenError(response, "c4noteNichtInLehre"))
+ .then(() => readLvGesamtnote(ctx, student.uid))
+ .then((row) => {
+ expect(String(row.note), "a non-lehre note must not become the LV note").to.eq(
+ String(ctx.gradeNotes[0]),
+ );
+ });
+ });
+
+ it("refuses to change the Notenvorschlag once a Prüfung exists", () => {
+ const student = studentFor(1);
+
+ givenBaseline(ctx, student);
+
+ addPruefung(ctx, student, { note: ctx.gradeNotes[1], datum: attemptDate(ctx, 1) }).then((response) => {
+ expectNotenSuccess(response, "seed a Prüfung");
+ });
+
+ // the grade now belongs to the attempt history; a direct edit bypasses the Antritt rules
+ notenApi
+ .saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.gradeNotes[0])
+ .then((response) => expectNotenError(response, "c4notenvorschlagGesperrt"))
+ .then(() => readLvGesamtnote(ctx, student.uid))
+ .then((row) => {
+ expect(String(row.note), "the attempt's grade must survive the call").to.eq(
+ String(ctx.gradeNotes[1]),
+ );
+ });
+ });
+
+ it("refuses to overwrite a locked Zeugnisnote", function () {
+ if (ctx.notes.nichtUeberschreibbar === null) {
+ this.skip(); // every active note allows the teacher to overwrite it on this instance
+ }
+
+ const student = studentFor(4);
+
+ givenBaseline(ctx, student, { erstantritt: false });
+ seedZeugnisnote(ctx, student.uid, ctx.notes.nichtUeberschreibbar);
+
+ notenApi
+ .saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.gradeNotes[1])
+ .then((response) => expectNotenError(response, "c4zeugnisnoteGesperrt"))
+ .then(() => readLvGesamtnote(ctx, student.uid))
+ .then((row) => {
+ expect(String(row.note), "the LV note must not change").to.eq(String(ctx.gradeNotes[0]));
+ });
+ });
+
+ // Derselbe Weg über den CSV-Import, den eine Assistenz tatsächlich fährt. Der Bulk-Endpunkt
+ // antwortet 200 und meldet die abgelehnte Zeile in data[uid].
+ describe("saveNotenvorschlagBulk", () => {
+ beforeEach(function () {
+ if (ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) {
+ // im Punktemodus leitet der Import die Note aus den Punkten ab und verwirft eine
+ // Zeile ohne Punkte, bevor eine dieser Regeln greift
+ cy.log("Skipped: CIS_GESAMTNOTE_PUNKTE ist aktiv.");
+ this.skip();
+ }
+ });
+
+ it("refuses a note the editor list never offers", function () {
+ if (ctx.notes.nichtLehre === null) this.skip();
+
+ const student = studentFor(2);
+
+ givenBaseline(ctx, student, { erstantritt: false });
+
+ notenApi
+ .saveNotenvorschlagBulk(ctx.lvId, ctx.semKurzbz, [
+ { uid: student.uid, note: ctx.notes.nichtLehre, punkte: null },
+ ])
+ .then((response) => {
+ const data = expectNotenSuccess(response, "saveNotenvorschlagBulk");
+ expectBulkRowError(data, student.uid, "c4noteNichtInLehre");
+ })
+ .then(() => readLvGesamtnote(ctx, student.uid))
+ .then((row) => {
+ expect(String(row.note), "a non-lehre note must not become the LV note").to.eq(
+ String(ctx.gradeNotes[0]),
+ );
+ });
+ });
+
+ it("refuses to overwrite a locked Zeugnisnote", function () {
+ if (ctx.notes.nichtUeberschreibbar === null) this.skip();
+
+ const student = studentFor(5);
+
+ givenBaseline(ctx, student, { erstantritt: false });
+ seedZeugnisnote(ctx, student.uid, ctx.notes.nichtUeberschreibbar);
+
+ notenApi
+ .saveNotenvorschlagBulk(ctx.lvId, ctx.semKurzbz, [
+ { uid: student.uid, note: ctx.gradeNotes[1], punkte: null },
+ ])
+ .then((response) => {
+ const data = expectNotenSuccess(response, "saveNotenvorschlagBulk");
+ expectBulkRowError(data, student.uid, "c4zeugnisnoteGesperrt");
+ })
+ .then(() => readLvGesamtnote(ctx, student.uid))
+ .then((row) => {
+ expect(String(row.note), "the LV note must not change").to.eq(
+ String(ctx.gradeNotes[0]),
+ );
+ });
+ });
+
+ it("refuses to change the Notenvorschlag once a Prüfung exists", () => {
+ const student = studentFor(3);
+
+ givenBaseline(ctx, student);
+
+ addPruefung(ctx, student, { note: ctx.gradeNotes[1], datum: attemptDate(ctx, 1) }).then(
+ (response) => {
+ expectNotenSuccess(response, "seed a Prüfung");
+ },
+ );
+
+ notenApi
+ .saveNotenvorschlagBulk(ctx.lvId, ctx.semKurzbz, [
+ { uid: student.uid, note: ctx.gradeNotes[0], punkte: null },
+ ])
+ .then((response) => {
+ const data = expectNotenSuccess(response, "saveNotenvorschlagBulk");
+ expectBulkRowError(data, student.uid, "c4notenvorschlagGesperrt");
+ })
+ .then(() => readLvGesamtnote(ctx, student.uid))
+ .then((row) => {
+ expect(String(row.note), "the attempt's grade must survive the import").to.eq(
+ String(ctx.gradeNotes[1]),
+ );
+ });
+ });
+ });
+});
diff --git a/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.freigabe.cy.js b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.freigabe.cy.js
new file mode 100644
index 000000000..0d88bbe24
--- /dev/null
+++ b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.freigabe.cy.js
@@ -0,0 +1,120 @@
+import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
+import {
+ loadNotenContext,
+ readLvGesamtnote,
+ requireDbReset,
+ resetNotenState,
+ seedBaseline,
+} from "../../../../support/helpers/notenTestData";
+
+/**
+ * Notenfreigabe über den Modal-Dialog mit Passwort.
+ *
+ * Der Dialog listet genau die Zeilen, die freigegeben werden (changedNoten), und verlangt das
+ * LDAP-Passwort. Danach muss die Statusspalte ohne Reload von changed auf ok springen.
+ *
+ * Ein erfolgreiches Speichern verschickt IMMER die Freigabemail - dieselbe Sperre wie in
+ * noten.freigabe: opt-in über NOTEN_FREIGABE_ENABLED. Dialogansicht und Passwortablehnung
+ * schreiben nichts und laufen daher immer.
+ */
+const freigabeEnabled = () => String(Cypress.env("NOTEN_FREIGABE_ENABLED")).toLowerCase() === "true";
+const freigabePassword = () => Cypress.env("NOTEN_FREIGABE_PASSWORD") || Cypress.env("adminpassword");
+
+context("Benotungstool UI - Notenfreigabe", () => {
+ let ctx;
+ let bezeichnung;
+
+ before(() => {
+ requireDbReset();
+ loadNotenContext().then((context) => {
+ ctx = context;
+ bezeichnung = (note) => page.bezeichnungOf(ctx, note);
+ });
+ });
+
+ describe("Dialog", () => {
+ it("listet die noch nicht freigegebene Note mit ihrer Zielnote", () => {
+ const student = ctx.students[0];
+
+ resetNotenState(ctx);
+ seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: false });
+
+ page.visitAndWaitForTable(ctx);
+ page.expectFreigabeState(student.uid, "changed");
+
+ page.openFreigabeModal();
+ page.expectFreigabeSummaryRow(student.uid, bezeichnung(ctx.gradeNotes[0]));
+ });
+
+ it("meldet eine leere Auswahl, wenn nichts freizugeben ist", () => {
+ resetNotenState(ctx);
+
+ page.visitAndWaitForTable(ctx);
+ page.openFreigabeModal();
+
+ cy.get("[data-cy='freigabe-summary-empty']").should("be.visible");
+ });
+
+ it("lehnt ein falsches Passwort ab und lässt den Status unverändert", () => {
+ const student = ctx.students[0];
+
+ resetNotenState(ctx);
+ seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: false });
+
+ page.visitAndWaitForTable(ctx);
+ page.openFreigabeModal();
+
+ page.typeFreigabePasswort("definitely-not-the-password");
+ page.submitFreigabe();
+ cy.wait("@saveStudentenNoten");
+
+ readLvGesamtnote(ctx, student.uid).then((rowData) => {
+ expect(rowData.freigabedatum, "eine abgelehnte Freigabe stempelt nichts").to.be.null;
+ });
+ });
+ });
+
+ describe("Freigabe (verschickt Mail - opt in über NOTEN_FREIGABE_ENABLED)", () => {
+ beforeEach(function () {
+ if (!freigabeEnabled()) {
+ cy.log(
+ "Skipped: die Freigabe verschickt die Notenfreigabe-Mail. NOTEN_FREIGABE_ENABLED=true " +
+ "nur auf einer Umgebung setzen, auf der das harmlos ist.",
+ );
+ this.skip();
+ }
+ });
+
+ it("gibt frei und schaltet die Statusspalte auf ok", () => {
+ const student = ctx.students[0];
+
+ resetNotenState(ctx);
+ seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: false });
+
+ page.visitAndWaitForTable(ctx);
+ page.expectFreigabeState(student.uid, "changed");
+
+ page.openFreigabeModal();
+ page.freigeben(freigabePassword());
+
+ page.expectFreigabeState(student.uid, "ok");
+ page.expectLvNote(student.uid, bezeichnung(ctx.gradeNotes[0]));
+ });
+
+ it("legt mit der Freigabe den ersten Antritt an", () => {
+ const student = ctx.students[1];
+
+ resetNotenState(ctx);
+ seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: false });
+
+ page.visitAndWaitForTable(ctx);
+ page.openFreigabeModal();
+ page.freigeben(freigabePassword());
+
+ // upsertErstantritt schreibt die Prüfungszeile; sichtbar wird sie beim nächsten Laden
+ page.visitAndWaitForTable(ctx);
+ page.expectPruefung(student.uid, "antritt_1", { note: ctx.gradeNotes[0], antritt: 1 });
+ page.expectAntrittCount(student.uid, 1);
+ });
+ });
+});
diff --git a/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.import.cy.js b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.import.cy.js
new file mode 100644
index 000000000..e2c1f2d8f
--- /dev/null
+++ b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.import.cy.js
@@ -0,0 +1,122 @@
+import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
+import {
+ attemptDate,
+ loadNotenContext,
+ requireDbReset,
+ resetNotenState,
+ seedBaseline,
+} from "../../../../support/helpers/notenTestData";
+
+/**
+ * Die beiden Importe aus der Toolbar.
+ *
+ * Notenimport: "UIDNote" je Zeile, schreibt nur die LV-Note.
+ * Prüfungsimport: "UIDdd.MM.yyyyNote" je Zeile, legt zusätzlich den Termin an.
+ *
+ * Beide sind über CIS_GESAMTNOTE_NOTENIMPORT / CIS_GESAMTNOTE_PRUEFUNGSIMPORT konfigurierbar - ist
+ * der Import aus, existiert der Button nicht und der Block wird übersprungen.
+ */
+context("Benotungstool UI - Import", () => {
+ let ctx;
+ let bezeichnung;
+
+ before(() => {
+ requireDbReset();
+ loadNotenContext().then((context) => {
+ ctx = context;
+ bezeichnung = (note) => page.bezeichnungOf(ctx, note);
+ });
+ });
+
+ beforeEach(function () {
+ if (ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) {
+ // im Punktemodus erwarten beide Importe Punkte statt einer Note
+ cy.log("Skipped: CIS_GESAMTNOTE_PUNKTE ist aktiv.");
+ this.skip();
+ }
+ });
+
+ describe("Notenimport", () => {
+ beforeEach(function () {
+ if (!ctx.cisConfig.CIS_GESAMTNOTE_NOTENIMPORT) {
+ cy.log("Skipped: CIS_GESAMTNOTE_NOTENIMPORT ist aus, der Button existiert nicht.");
+ this.skip();
+ }
+ });
+
+ it("schreibt die LV-Note für jede Zeile", () => {
+ const [a, b] = ctx.students;
+
+ resetNotenState(ctx);
+ page.visitAndWaitForTable(ctx);
+
+ page.importNoten([
+ [a.uid, ctx.gradeNotes[0]],
+ [b.uid, ctx.gradeNotes[1]],
+ ]);
+
+ page.expectLvNote(a.uid, bezeichnung(ctx.gradeNotes[0]));
+ page.expectLvNote(b.uid, bezeichnung(ctx.gradeNotes[1]));
+
+ // erfasst, aber nicht freigegeben
+ page.expectFreigabeState(a.uid, "changed");
+ page.expectFreigabeState(b.uid, "changed");
+ });
+
+ it("legt dabei keine Prüfung an", () => {
+ const student = ctx.students[0];
+
+ resetNotenState(ctx);
+ page.visitAndWaitForTable(ctx);
+
+ page.importNoten([[student.uid, ctx.gradeNotes[0]]]);
+
+ // die LV-Note zählt als impliziter erster Antritt, ohne dass eine Zeile entsteht
+ page.expectAntrittCount(student.uid, 1);
+ page.expectKeinePruefung(student.uid, "antritt_1");
+ });
+ });
+
+ describe("Prüfungsimport", () => {
+ beforeEach(function () {
+ if (!ctx.cisConfig.CIS_GESAMTNOTE_PRUEFUNGSIMPORT) {
+ cy.log("Skipped: CIS_GESAMTNOTE_PRUEFUNGSIMPORT ist aus, der Button existiert nicht.");
+ this.skip();
+ }
+ });
+
+ it("legt je Zeile einen datierten Antritt an", () => {
+ const [a, b] = ctx.students;
+ const datum = page.toDDMMYYYY(attemptDate(ctx, 1));
+
+ resetNotenState(ctx);
+ seedBaseline(ctx, a.uid, { note: ctx.gradeNotes[0], freigegeben: true });
+ seedBaseline(ctx, b.uid, { note: ctx.gradeNotes[0], freigegeben: true });
+
+ page.visitAndWaitForTable(ctx);
+
+ page.importPruefungen([
+ [a.uid, datum, ctx.gradeNotes[1]],
+ [b.uid, datum, ctx.gradeNotes[1]],
+ ]);
+
+ [a, b].forEach((student) => {
+ page.expectPruefung(student.uid, "antritt_2", { note: ctx.gradeNotes[1], antritt: 2 });
+ page.expectAntrittCount(student.uid, 2);
+ page.expectLvNote(student.uid, bezeichnung(ctx.gradeNotes[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.toDDMMYYYY(attemptDate(ctx, 1)), ctx.gradeNotes[0]]]);
+
+ page.expectPruefung(student.uid, "antritt_1", { note: ctx.gradeNotes[0], antritt: 1 });
+ page.expectLvNote(student.uid, bezeichnung(ctx.gradeNotes[0]));
+ });
+ });
+});
diff --git a/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.notenvorschlag.cy.js b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.notenvorschlag.cy.js
new file mode 100644
index 000000000..88c9fb433
--- /dev/null
+++ b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.notenvorschlag.cy.js
@@ -0,0 +1,101 @@
+import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
+import {
+ loadNotenContext,
+ requireDbReset,
+ resetNotenState,
+ seedBaseline,
+} from "../../../../support/helpers/notenTestData";
+
+/**
+ * Notenvorschlag eintragen und übernehmen.
+ *
+ * Die Regeln dahinter prüft noten.notenvorschlag; hier geht es darum, dass die Tabelle den
+ * Serverzustand ohne Reload korrekt nachführt: LV-Note, Freigabestatus und das Verschwinden des
+ * Übernehmen-Buttons.
+ */
+context("Benotungstool UI - Notenvorschlag", () => {
+ let ctx;
+ let bezeichnung;
+
+ before(() => {
+ requireDbReset();
+ loadNotenContext().then((context) => {
+ ctx = context;
+ bezeichnung = (note) => page.bezeichnungOf(ctx, note);
+ });
+ });
+
+ beforeEach(function () {
+ if (ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) {
+ // im Punktemodus ist die Vorschlagsspalte gesperrt, die Note kommt aus den Punkten
+ cy.log("Skipped: CIS_GESAMTNOTE_PUNKTE ist aktiv.");
+ this.skip();
+ }
+ });
+
+ it("trägt einen Vorschlag über den Zelleneditor ein", () => {
+ const student = ctx.students[0];
+
+ resetNotenState(ctx);
+ page.visitAndWaitForTable(ctx);
+
+ // ohne Note ist die Zeile leer: kein Benotungsdatum -> offen
+ page.expectFreigabeState(student.uid, "offen");
+
+ page.setNotenvorschlag(student.uid, bezeichnung(ctx.gradeNotes[0]));
+
+ page.expectNotenvorschlag(student.uid, bezeichnung(ctx.gradeNotes[0]));
+ // der Vorschlag allein schreibt noch keine LV-Note
+ page.expectFreigabeState(student.uid, "offen");
+ });
+
+ it("übernimmt den Vorschlag als LV-Note und zeigt den Status changed", () => {
+ const student = ctx.students[0];
+
+ resetNotenState(ctx);
+ page.visitAndWaitForTable(ctx);
+
+ page.setNotenvorschlag(student.uid, bezeichnung(ctx.gradeNotes[0]));
+ page.uebernehmen(student.uid);
+
+ page.expectLvNote(student.uid, bezeichnung(ctx.gradeNotes[0]));
+ page.expectFreigabeState(student.uid, "changed");
+ });
+
+ it("blendet den Übernehmen-Button aus, sobald Vorschlag und LV-Note übereinstimmen", () => {
+ const student = ctx.students[0];
+
+ resetNotenState(ctx);
+ page.visitAndWaitForTable(ctx);
+
+ page.setNotenvorschlag(student.uid, bezeichnung(ctx.gradeNotes[0]));
+ page.uebernehmen(student.uid);
+
+ page.getUebernehmenButton(student.uid).should("not.exist");
+ });
+
+ it("zeigt eine bereits freigegebene Note nach dem Laden als ok", () => {
+ const student = ctx.students[1];
+
+ resetNotenState(ctx);
+ seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: true });
+
+ page.visitAndWaitForTable(ctx);
+
+ page.expectLvNote(student.uid, bezeichnung(ctx.gradeNotes[0]));
+ page.expectFreigabeState(student.uid, "ok");
+ });
+
+ it("zeigt eine erfasste, nicht freigegebene Note nach dem Laden als changed", () => {
+ const student = ctx.students[1];
+
+ resetNotenState(ctx);
+ seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: false });
+
+ page.visitAndWaitForTable(ctx);
+
+ // getStudentenNoten liest ungefiltert - wäre das nicht so, bliebe die Zeile hier leer
+ page.expectLvNote(student.uid, bezeichnung(ctx.gradeNotes[0]));
+ page.expectFreigabeState(student.uid, "changed");
+ });
+});
diff --git a/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.pruefung.cy.js b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.pruefung.cy.js
new file mode 100644
index 000000000..dae56411a
--- /dev/null
+++ b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.pruefung.cy.js
@@ -0,0 +1,206 @@
+import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
+import {
+ attemptDate,
+ loadNotenContext,
+ requireDbReset,
+ resetNotenState,
+ seedBaseline,
+ seedPruefung,
+} from "../../../../support/helpers/notenTestData";
+
+/**
+ * Prüfungen anlegen und bearbeiten, über beide Wege: den Dialog aus der Tabellenzelle und die
+ * Sammelanlage aus der Toolbar. Beide laufen serverseitig durch denselben Kern
+ * (siehe noten.pruefungstermin); geprüft wird, dass die Zelle danach ohne Reload stimmt.
+ *
+ * Die Specs laufen im Antrittsmodus, weil die Spaltennamen dort stabil sind: antritt_1, antritt_2,
+ * ... Im Datumsmodus heisst die Spalte wie das Prüfungsdatum.
+ */
+context("Benotungstool UI - Prüfungen", () => {
+ let ctx;
+ let bezeichnung;
+
+ before(() => {
+ requireDbReset();
+ loadNotenContext().then((context) => {
+ ctx = context;
+ bezeichnung = (note) => page.bezeichnungOf(ctx, note);
+
+ expect(ctx.maxAntritte, "braucht Platz für mindestens eine Wiederholung").to.be.greaterThan(1);
+ });
+ });
+
+ beforeEach(function () {
+ if (ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) {
+ // im Punktemodus ist das Notenfeld gesperrt, die Note kommt aus dem Notenschlüssel
+ cy.log("Skipped: CIS_GESAMTNOTE_PUNKTE ist aktiv.");
+ this.skip();
+ }
+ });
+
+ it("zeigt den von der Freigabe angelegten ersten Antritt", () => {
+ const student = ctx.students[0];
+
+ resetNotenState(ctx);
+ seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: true });
+
+ page.visitAndWaitForTable(ctx);
+
+ page.expectPruefung(student.uid, "antritt_1", { note: ctx.gradeNotes[0], antritt: 1 });
+ page.expectAntrittCount(student.uid, 1);
+ });
+
+ it("legt aus der Zelle eine Wiederholung an und zählt sie als Antritt 2", () => {
+ const student = ctx.students[0];
+
+ resetNotenState(ctx);
+ seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: true });
+
+ page.visitAndWaitForTable(ctx);
+
+ page.addPruefungInCell(student.uid, "antritt_2", {
+ note: bezeichnung(ctx.gradeNotes[1]),
+ datum: page.toDDMMYYYY(attemptDate(ctx, 1)),
+ });
+
+ page.expectPruefung(student.uid, "antritt_2", { note: ctx.gradeNotes[1], antritt: 2 });
+ page.expectAntrittCount(student.uid, 2);
+
+ // die LV-Note folgt dem neuesten Antritt und ist damit wieder unfreigegeben
+ page.expectLvNote(student.uid, bezeichnung(ctx.gradeNotes[1]));
+ page.expectFreigabeState(student.uid, "changed");
+ });
+
+ 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-ohne-lvnote']").should("be.visible");
+ cy.get("[data-cy='pruefung-submit']").click();
+ page.getPruefungModal().should("not.be.visible");
+
+ // ohne gewählte Note entsteht "Noch nicht eingetragen" - das verbraucht keinen Antritt
+ page.expectPruefung(student.uid, "antritt_1", { note: ctx.notes.nochNichtEingetragen });
+ page.expectLvNote(student.uid, bezeichnung(ctx.notes.nochNichtEingetragen));
+ page.expectAntrittCount(student.uid, 0);
+ });
+
+ it("korrigiert nur das Datum eines bestehenden Antritts", () => {
+ const student = ctx.students[0];
+ const neuesDatum = attemptDate(ctx, 2);
+
+ resetNotenState(ctx);
+ seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: true });
+
+ page.visitAndWaitForTable(ctx);
+
+ page.addPruefungInCell(student.uid, "antritt_2", {
+ note: bezeichnung(ctx.gradeNotes[1]),
+ datum: page.toDDMMYYYY(attemptDate(ctx, 1)),
+ });
+ page.editPruefungInCell(student.uid, "antritt_2", { datum: page.toDDMMYYYY(neuesDatum) });
+
+ page.expectPruefung(student.uid, "antritt_2", { note: ctx.gradeNotes[1], antritt: 2 });
+ page.getCell(student.uid, "antritt_2").should("contain.text", page.toDDMMYYYY(neuesDatum));
+ });
+
+ it("sperrt die Note, sobald ein späterer Antritt existiert", () => {
+ const student = ctx.students[0];
+
+ resetNotenState(ctx);
+ seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: true });
+
+ page.visitAndWaitForTable(ctx);
+
+ // entschuldigt zuerst: verbraucht keinen Antritt, daher bleibt die dritte Spalte auch bei
+ // maxAntritte = 2 erreichbar
+ page.addPruefungInCell(student.uid, "antritt_2", {
+ note: bezeichnung(ctx.notes.entschuldigt),
+ datum: page.toDDMMYYYY(attemptDate(ctx, 1)),
+ });
+ page.addPruefungInCell(student.uid, "antritt_3", {
+ note: bezeichnung(ctx.gradeNotes[1]),
+ datum: page.toDDMMYYYY(attemptDate(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", () => {
+ const [a, b] = ctx.students;
+
+ resetNotenState(ctx);
+ seedBaseline(ctx, a.uid, { note: ctx.gradeNotes[0], freigegeben: true });
+ seedBaseline(ctx, b.uid, { note: ctx.gradeNotes[0], freigegeben: true });
+
+ page.visitAndWaitForTable(ctx);
+
+ page.addPruefungBulk({
+ uids: [a.uid, b.uid],
+ note: bezeichnung(ctx.gradeNotes[1]),
+ datum: page.toDDMMYYYY(attemptDate(ctx, 1)),
+ });
+
+ [a, b].forEach((student) => {
+ page.expectPruefung(student.uid, "antritt_2", { note: ctx.gradeNotes[1], antritt: 2 });
+ page.expectAntrittCount(student.uid, 2);
+ page.expectLvNote(student.uid, bezeichnung(ctx.gradeNotes[1]));
+ });
+ });
+
+ it("legt den letzten Antritt als kommissionelle Prüfung an", () => {
+ const student = ctx.students[1];
+
+ resetNotenState(ctx);
+ seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: true });
+
+ page.visitAndWaitForTable(ctx);
+
+ // Baseline liefert Antritt 1. Bis zum vorletzten Antritt laufen die normalen Spalten.
+ for (let i = 1; i < ctx.maxAntritte - 1; i += 1) {
+ page.addPruefungInCell(student.uid, `antritt_${i + 1}`, {
+ note: bezeichnung(ctx.gradeNotes[0]),
+ datum: page.toDDMMYYYY(attemptDate(ctx, i)),
+ });
+ }
+
+ // Der letzte Antritt ist laut Prüfungsordnung kommissionell und wird daher in der
+ // kommPruef-Spalte angelegt, nicht in einer weiteren Antrittsspalte.
+ page.getPruefungAddButton(student.uid, "kommPruef").should("exist");
+ page.expectKeineAntrittsspalte(ctx.maxAntritte);
+
+ page.addPruefungInCell(student.uid, "kommPruef", {
+ note: bezeichnung(ctx.gradeNotes[0]),
+ datum: page.toDDMMYYYY(attemptDate(ctx, ctx.maxAntritte)),
+ });
+
+ page.expectAntrittCount(student.uid, ctx.maxAntritte);
+ page.expectPruefung(student.uid, "kommPruef", { note: ctx.gradeNotes[0], antritt: "K" });
+ });
+
+ it("bietet keinen weiteren Antritt an, sobald die Grenze erreicht ist", () => {
+ const student = ctx.students[2];
+
+ resetNotenState(ctx);
+ seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: true });
+
+ // die kommissionelle Prüfung schliesst die Kette; sie kommt aus dem anderen Tool
+ seedPruefung(ctx, student, {
+ note: ctx.gradeNotes[0],
+ datum: attemptDate(ctx, 1),
+ typ: "kommPruef",
+ });
+
+ page.visitAndWaitForTable(ctx);
+
+ page.getPruefungAddButton(student.uid, "kommPruef").should("not.exist");
+ page.expectKeineAntrittsspalte(2);
+ });
+});
diff --git a/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.punkte.cy.js b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.punkte.cy.js
new file mode 100644
index 000000000..b47c60115
--- /dev/null
+++ b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.punkte.cy.js
@@ -0,0 +1,197 @@
+import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
+import { requirePunkteModus } from "../../../../support/helpers/notenConfig";
+import {
+ attemptDate,
+ loadNotenContext,
+ readLvGesamtnote,
+ requireDbReset,
+ resetNotenState,
+ seedBaseline,
+} from "../../../../support/helpers/notenTestData";
+import { notenApi } from "../../../../support/api/notenApi";
+
+/**
+ * Punktemodus (CIS_GESAMTNOTE_PUNKTE) in der Oberfläche.
+ *
+ * Die Punktespalte und die Punktefelder der beiden Dialoge existieren nur mit diesem Flag, die Note
+ * wird dann aus dem Notenschlüssel abgeleitet statt gewählt. Genau diese Felder deckt sonst nichts
+ * ab, deshalb steht der ganze Modus hier in einer eigenen Datei.
+ *
+ * Der Notenschlüssel wird nie hartcodiert: die erwartete Note kommt zur Laufzeit aus
+ * getNoteByPunkte, damit die Specs an jeder Instanz mit eigenem Schlüssel laufen.
+ */
+context("Benotungstool UI - Punktemodus", () => {
+ let ctx;
+ // Punktewerte, deren Noten zur Laufzeit ermittelt werden
+ const OBEN = 100;
+ const MITTE = 70;
+ let noteOben;
+ let noteMitte;
+
+ before(() => {
+ requireDbReset();
+ loadNotenContext().then((context) => {
+ ctx = context;
+ if (!ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) return;
+
+ notenApi.getNoteByPunkte(OBEN, ctx.lvId, ctx.semKurzbz).then((r) => {
+ noteOben = r.body.data;
+ expect(noteOben, `${OBEN} Punkte müssen eine Note ergeben`).to.not.be.null;
+ });
+ notenApi.getNoteByPunkte(MITTE, ctx.lvId, ctx.semKurzbz).then((r) => {
+ noteMitte = r.body.data;
+ expect(noteMitte, `${MITTE} Punkte müssen eine Note ergeben`).to.not.be.null;
+ expect(String(noteMitte), "die beiden Punktewerte müssen verschiedene Noten liefern").to.not.eq(
+ String(noteOben),
+ );
+ });
+ });
+ });
+
+ beforeEach(function () {
+ requirePunkteModus(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");
+ // die Note kommt aus den Punkten, sie darf nicht direkt gewählt werden
+ page.expectNotenvorschlagGesperrt(student.uid);
+ });
+
+ it("leitet den Notenvorschlag beim Tippen aus den Punkten ab", () => {
+ const student = ctx.students[0];
+
+ resetNotenState(ctx);
+ page.visitAndWaitForTable(ctx);
+
+ page.setPunkteInCell(student.uid, OBEN);
+
+ page.expectNotenvorschlag(student.uid, page.bezeichnungOf(ctx, noteOben));
+ });
+
+ it("schreibt Note und Punkte, wenn der Vorschlag übernommen wird", () => {
+ const student = ctx.students[0];
+
+ resetNotenState(ctx);
+ page.visitAndWaitForTable(ctx);
+
+ page.setPunkteInCell(student.uid, MITTE);
+ page.uebernehmen(student.uid);
+
+ page.expectLvNote(student.uid, page.bezeichnungOf(ctx, noteMitte));
+ page.expectFreigabeState(student.uid, "changed");
+
+ readLvGesamtnote(ctx, student.uid).then((row) => {
+ expect(String(row.note), "abgeleitete Note").to.eq(String(noteMitte));
+ expect(Number(row.punkte), "die Punkte werden mitgeschrieben").to.eq(MITTE);
+ });
+ });
+
+ it("zeigt gespeicherte Punkte nach einem Reload wieder an", () => {
+ const student = ctx.students[0];
+
+ resetNotenState(ctx);
+ page.visitAndWaitForTable(ctx);
+
+ page.setPunkteInCell(student.uid, OBEN);
+ page.uebernehmen(student.uid);
+
+ page.visitAndWaitForTable(ctx);
+
+ page.expectPunkte(student.uid, OBEN);
+ page.expectLvNote(student.uid, page.bezeichnungOf(ctx, noteOben));
+ });
+
+ it("sperrt die Punktespalte, sobald ein Termin existiert", () => {
+ const student = ctx.students[1];
+
+ resetNotenState(ctx);
+ seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: true });
+
+ page.visitAndWaitForTable(ctx);
+
+ page.expectPunkteZelleGesperrt(student.uid);
+ });
+ });
+
+ describe("Prüfungsdialog", () => {
+ it("bietet ein Punktefeld statt der Notenauswahl", () => {
+ const student = ctx.students[1];
+
+ resetNotenState(ctx);
+ seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], 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.expectNoteFeldGesperrt("pruefung-note");
+ });
+
+ it("legt den Termin mit der aus den Punkten abgeleiteten Note an", () => {
+ const student = ctx.students[1];
+
+ resetNotenState(ctx);
+ seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: true });
+
+ page.visitAndWaitForTable(ctx);
+ page.getPruefungAddButton(student.uid, "antritt_2").click();
+ page.getPruefungModal().should("be.visible");
+
+ page.setDatum("pruefung-datum", page.toDDMMYYYY(attemptDate(ctx, 1)));
+ page.setPruefungPunkte(OBEN);
+
+ cy.get("[data-cy='pruefung-submit']").click();
+ cy.wait("@saveStudentPruefung").its("response.statusCode").should("eq", 200);
+ page.getPruefungModal().should("not.be.visible");
+
+ page.expectPruefung(student.uid, "antritt_2", { note: noteOben, antritt: 2 });
+ page.expectLvNote(student.uid, page.bezeichnungOf(ctx, noteOben));
+ });
+ });
+
+ describe("Sammelanlage", () => {
+ it("legt für mehrere Studierende Termine mit der abgeleiteten Note an", () => {
+ const [a, b] = ctx.students;
+
+ resetNotenState(ctx);
+ seedBaseline(ctx, a.uid, { note: ctx.gradeNotes[0], freigegeben: true });
+ seedBaseline(ctx, b.uid, { note: ctx.gradeNotes[0], freigegeben: true });
+
+ page.visitAndWaitForTable(ctx);
+
+ cy.get("[data-cy='btn-neue-pruefung']").click();
+ page.getNeuePruefungModal().should("be.visible");
+
+ // dort gibt es kein Notenfeld zum Auswählen, nur Punkte
+ cy.get("[data-cy='neue-pruefung-punkte']").should("be.visible");
+ page.expectNoteFeldGesperrt("neue-pruefung-note");
+
+ page.setDatum("neue-pruefung-datum", page.toDDMMYYYY(attemptDate(ctx, 1)));
+ page.setNeuePruefungPunkte(MITTE);
+
+ cy.get("[data-cy='neue-pruefung-studenten']").click();
+ [a, b].forEach((s) =>
+ cy.contains(".p-multiselect-panel .p-multiselect-item", s.uid).click(),
+ );
+ cy.get("[data-cy='neue-pruefung-studenten']").click();
+ cy.get(".p-multiselect-panel").should("not.exist");
+
+ cy.get("[data-cy='neue-pruefung-submit']").click();
+ cy.wait("@createPruefungen").its("response.statusCode").should("eq", 200);
+ page.getNeuePruefungModal().should("not.be.visible");
+
+ [a, b].forEach((s) => {
+ page.expectPruefung(s.uid, "antritt_2", { note: noteMitte, antritt: 2 });
+ page.expectLvNote(s.uid, page.bezeichnungOf(ctx, noteMitte));
+ });
+ });
+ });
+});
diff --git a/tests/cypress/e2e/specs/unit/notenRules.cy.js b/tests/cypress/e2e/specs/unit/notenRules.cy.js
new file mode 100644
index 000000000..e6b01f92a
--- /dev/null
+++ b/tests/cypress/e2e/specs/unit/notenRules.cy.js
@@ -0,0 +1,159 @@
+/**
+ * Die §1-Regeln auf Clientseite. Pure functions - kein Server, keine Fixture.
+ *
+ * Die Regeln liegen im Server und erreichen den Client als `student.verlauf`; hier wird nur das
+ * Auswerten und der lokale Fallback (Verlauf noch nicht geladen) festgenagelt.
+ */
+
+import {
+ antrittCountStudent,
+ brauchtNeueLvNote,
+ canAddPruefung,
+ checkFreigabe,
+ maxAntrittCount,
+} from "../../../../../public/js/components/Cis/Benotungstool/notenRules.js";
+
+const ENTSCHULDIGT = 14;
+const NOCH_NICHT = 9;
+const NICHT_BEURTEILT = 7;
+
+/** Mirrors getCisConfig: maxAntritte is derived server-side, the client only reads the number. */
+const configWith = (maxAntritte) => ({
+ CIS_GESAMTNOTE_MAX_ANTRITTE: maxAntritte,
+ NOTEN_OHNE_ANTRITT: [NOCH_NICHT, ENTSCHULDIGT, NICHT_BEURTEILT],
+});
+
+const ours = configWith(2);
+
+const notenOptions = [
+ { note: 1, lehre: true },
+ { note: 5, lehre: true },
+ { note: ENTSCHULDIGT, lehre: true },
+ { note: NOCH_NICHT, lehre: true },
+ { note: NICHT_BEURTEILT, lehre: true }, // "Nicht beurteilt" - a grade on paper only
+ { note: 20, lehre: false }, // "intern angerechnet" - no participation
+];
+
+const student = (props) => ({ pruefungen: [], note: null, hoechsterAntritt: 0, ...props });
+
+/** A row whose Verlauf the server has already delivered. */
+const withVerlauf = (verlauf, props = {}) => student({ verlauf: { maxAntritte: 2, ...verlauf }, ...props });
+
+describe("Benotungstool rules (client)", () => {
+ describe("maxAntrittCount", () => {
+ it("reads the server-derived maximum", () => {
+ expect(maxAntrittCount(configWith(1)), "no retakes").to.eq(1);
+ expect(maxAntrittCount(ours), "our installation").to.eq(2);
+ expect(maxAntrittCount(configWith(3)), "a raised limit").to.eq(3);
+ });
+
+ it("falls back to a single attempt when the config is missing", () => {
+ expect(maxAntrittCount({}), "no key at all").to.eq(1);
+ expect(maxAntrittCount(null), "no config at all").to.eq(1);
+ });
+ });
+
+ describe("antrittCountStudent", () => {
+ it("takes the count from the Verlauf when the server delivered one", () => {
+ const s = withVerlauf({ antrittCount: 2 }, { note: 1, pruefungen: [{ note: 5 }] });
+ expect(antrittCountStudent(s, ours, notenOptions), "server wins over the local fallback").to.eq(2);
+ });
+
+ describe("local fallback (Verlauf not loaded yet)", () => {
+ it("does not count entschuldigt, 'noch nicht eingetragen' or 'nicht beurteilt'", () => {
+ // still just the original note: none of these consumed an attempt, which is what lets a
+ // second retake be entered
+ const s = student({
+ note: 1,
+ pruefungen: [{ note: ENTSCHULDIGT }, { note: NOCH_NICHT }, { note: NICHT_BEURTEILT }],
+ });
+ expect(antrittCountStudent(s, ours, notenOptions)).to.eq(1);
+ });
+
+ it("counts a real grade on a pruefung", () => {
+ const s = student({ note: 1, pruefungen: [{ note: ENTSCHULDIGT }, { note: 5 }] });
+ expect(antrittCountStudent(s, ours, notenOptions)).to.eq(1);
+ });
+
+ it("counts the original LV note as the first Antritt", () => {
+ expect(antrittCountStudent(student({ note: 1 }), ours, notenOptions)).to.eq(1);
+ });
+
+ it("does not count a non-lehre note as an Antritt", () => {
+ // "intern angerechnet" means no participation, so no attempt was used
+ expect(antrittCountStudent(student({ note: 20 }), ours, notenOptions)).to.eq(0);
+ });
+
+ it("does not count a 'Nicht beurteilt' note as an Antritt", () => {
+ expect(antrittCountStudent(student({ note: NICHT_BEURTEILT }), ours, notenOptions)).to.eq(0);
+ });
+
+ it("counts nothing for a student without any grade", () => {
+ expect(antrittCountStudent(student({}), ours, notenOptions)).to.eq(0);
+ });
+
+ // notenOptions holds only ACTIVE notes; an unknown note must not take the grid render with it
+ it("survives a note that is no longer an active option", () => {
+ expect(() => antrittCountStudent(student({ note: 999 }), ours, notenOptions)).to.not.throw();
+ });
+ });
+ });
+
+ describe("canAddPruefung", () => {
+ it("follows the Verlauf", () => {
+ expect(canAddPruefung(withVerlauf({ canAdd: true, antrittCount: 1 }), ours)).to.be.true;
+ expect(canAddPruefung(withVerlauf({ canAdd: false, antrittCount: 2 }), ours)).to.be.false;
+ });
+
+ it("blocks an angerechnet row even though attempts are left", () => {
+ // the server sets canAdd:false for an Anrechnung; the client must not second-guess it
+ const s = withVerlauf({ canAdd: false, antrittCount: 0, angerechnet: true });
+ expect(canAddPruefung(s, ours)).to.be.false;
+ });
+
+ it("falls back to the cap and the terminal attempt without a Verlauf", () => {
+ expect(canAddPruefung(student({ note: 1 }), ours), "one attempt of two used").to.be.true;
+ expect(
+ canAddPruefung(student({ note: 1, pruefungen: [{ note: 5 }, { note: 4 }] }), ours),
+ "cap reached",
+ ).to.be.false;
+ expect(
+ canAddPruefung(student({ note: 1, kommPruef: { note: 5 } }), ours),
+ "terminal attempt exists",
+ ).to.be.false;
+ });
+
+ // Bekannte Lücke, hier festgenagelt: die LV-Note zählt nur solange gar keine Prüfung
+ // existiert. Deshalb schreibt die Freigabe Antritt 1 als echte Zeile.
+ it("stops counting the LV note once a Prüfung exists", () => {
+ const s = student({ note: 1, pruefungen: [{ note: 5 }] });
+ expect(antrittCountStudent(s, ours, notenOptions), "the LV note is no longer added").to.eq(1);
+ expect(canAddPruefung(s, ours), "so another attempt still looks available").to.be.true;
+ });
+ });
+
+ describe("brauchtNeueLvNote", () => {
+ it("uses hatLvNote from the Verlauf, not the displayed lv_note", () => {
+ // hatLvNote ist massgeblich, lv_note kann noch leer sein
+ const entered = withVerlauf({ hatLvNote: true }, { lv_note: null });
+ expect(brauchtNeueLvNote(entered), "an existing note must not read as missing").to.be.false;
+
+ const missing = withVerlauf({ hatLvNote: false }, { lv_note: null });
+ expect(brauchtNeueLvNote(missing)).to.be.true;
+ });
+
+ it("falls back to lv_note without a Verlauf", () => {
+ expect(brauchtNeueLvNote(student({ lv_note: 1 }))).to.be.false;
+ expect(brauchtNeueLvNote(student({ lv_note: null }))).to.be.true;
+ });
+ });
+
+ describe("checkFreigabe", () => {
+ it("derives the state from the two timestamps", () => {
+ expect(checkFreigabe(null, null), "no grade at all").to.eq("offen");
+ expect(checkFreigabe(null, "2026-01-10 08:00:00"), "never released").to.eq("changed");
+ expect(checkFreigabe("2026-01-10 08:00:00", "2026-01-20 08:00:00"), "re-graded").to.eq("changed");
+ expect(checkFreigabe("2026-01-10 08:00:00", "2026-01-10 08:00:00"), "unchanged").to.eq("ok");
+ });
+ });
+});
diff --git a/tests/cypress/suites/noten.js b/tests/cypress/suites/noten.js
new file mode 100644
index 000000000..4869d3371
--- /dev/null
+++ b/tests/cypress/suites/noten.js
@@ -0,0 +1,28 @@
+/**
+ * Wires the Gesamtnoteneingabe suite into cypress.config.js: its cy.task handlers plus the
+ * Cypress.env() values its specs read.
+ *
+ * The split is the point -- nothing NOTEN_* belongs in the config, and nothing generic belongs
+ * here. A second suite adds its own file next to this one and the config changes by one require.
+ */
+
+const { registerNotenDbTasks } = require("../tasks/notenDb");
+
+module.exports = {
+ registerTasks: registerNotenDbTasks,
+
+ env: {
+ // Optional pins; semester and LV are discovered at runtime when unset.
+ NOTEN_SEM: process.env.NOTEN_SEM || null,
+ NOTEN_LV_ID: process.env.NOTEN_LV_ID || null,
+
+ // saveStudentenNoten always sends the Notenfreigabe mail - opt in explicitly.
+ NOTEN_FREIGABE_ENABLED: process.env.NOTEN_FREIGABE_ENABLED || "false",
+ NOTEN_FREIGABE_PASSWORD: process.env.NOTEN_FREIGABE_PASSWORD || null,
+
+ // Access-control specs need real per-role credentials: this API has no impersonation.
+ NOTEN_TEACHER_USER: process.env.NOTEN_TEACHER_USER || null,
+ NOTEN_TEACHER_PASSWORD: process.env.NOTEN_TEACHER_PASSWORD || null,
+ NOTEN_FOREIGN_LV_ID: process.env.NOTEN_FOREIGN_LV_ID || null,
+ },
+};
diff --git a/tests/cypress/suites/readme_benotungstool.txt b/tests/cypress/suites/readme_benotungstool.txt
new file mode 100644
index 000000000..310417a81
--- /dev/null
+++ b/tests/cypress/suites/readme_benotungstool.txt
@@ -0,0 +1,112 @@
+BENOTUNGSTOOL - TESTSUITE
+=========================
+
+WARNUNG: Die Suite löscht und schreibt Noten in der Datenbank. Starte sie nur gegen eine
+Testinstanz.
+
+
+1. WAS HIER LIEGT
+-----------------
+
+e2e/specs/unit/ Regeln als reine Funktion. Kein Server, keine Datenbank.
+e2e/specs/api/noten/ Die API. Ein Test ruft einen Endpunkt auf und prüft die Antwort.
+e2e/specs/ui/ Die Oberfläche im Browser.
+
+support/api/ Die API-Aufrufe an einer Stelle. Spiegelt public/js/api/factory/noten.js.
+support/pages/ Die Oberfläche an einer Stelle. Selektoren und Klickwege.
+support/helpers/ Testdaten, Fehlermeldungen, Fixture-Reset.
+tasks/ Datenbankverbindung und SSH-Tunnel. Läuft in Node, nicht im Browser.
+suites/noten.js Verbindet die Suite mit cypress.config.js.
+tools/dbCheck.js Prüft die Fixture ohne HTTP. Gutes erstes Kommando bei Problemen.
+
+Die Konfiguration steht in cypress.config.js. Die Zugangsdaten stehen in
+tests/cypress/.env.
+
+
+2. VORBEREITUNG
+---------------
+
+Einmalig:
+
+ npm install
+
+Dann die Zugangsdaten anlegen:
+
+ cp tests/cypress/.env.example tests/cypress/.env
+
+Fülle die Datei aus. Die Kommentare darin erklären jeden Wert. Drei Punkte sind wichtig:
+
+- .env darf nie in Git landen.
+- USER_NAME ist der LEKTOR, nicht ein Administrator. Ein Administrator unterrichtet meist nichts,
+ dann findet die Suite keine Lehrveranstaltung.
+- Von einer Arbeitsstation aus brauchst du den SSH-Tunnel. Die Datenbank nimmt nur den
+ Applikationsserver an. Setze NOTEN_SSH_TUNNEL=true und die drei NOTEN_SSH_*-Werte.
+
+Beide Seiten müssen auf dieselbe Datenbank zeigen: die Webinstanz (config/system.config.inc.php,
+DB_NAME) und die Suite (NOTEN_DB_NAME in .env). Sonst seedet die Suite die eine Datenbank und prüft
+gegen die andere.
+
+
+3. STARTEN
+----------
+
+ npm run noten:check Fixture prüfen.
+ npm run cy:noten:api Unit- und API-Tests.
+ npm run cy:noten:ui Oberfläche in Chrome.
+ npm run cy:open Cypress interaktiv.
+
+Zwei weitere Skripte prüfen den Punktemodus:
+
+ npm run cy:noten:punkte-on erwartet CIS_GESAMTNOTE_PUNKTE = true
+ npm run cy:noten:punkte-off erwartet CIS_GESAMTNOTE_PUNKTE = false
+
+Passt die Instanz nicht zur Erwartung, bricht der Lauf sofort ab.
+
+
+4. DAS ERGEBNIS
+---------------------
+
+Am Ende steht eine Tabelle mit vier Spalten.
+
+Passing Der Test lief und war erfolgreich.
+Failing Der Test lief und fand einen Fehler. Nur das ist ein Problem.
+Pending Der Test hat sich selbst übersprungen. Das ist kein Fehler.
+Skipped Ein Test lief nicht, weil vorher etwas abgebrochen ist.
+
+Pending ist normal. Ein Test überspringt sich, wenn seine Voraussetzung fehlt. Beispiele: der
+Punktemodus ist aus, die Frist ist nicht aktiv, die Freigabemail ist nicht erlaubt. Den Grund
+schreibt der Test ins Protokoll.
+
+Der aktuelle Sollzustand: API und Unit 84 Tests, 69 grün, 15 pending. UI 30 Tests, 18 grün,
+12 pending. Kein roter Test.
+
+
+5. WENN ETWAS SCHIEFGEHT
+------------------------
+
+401 bei allen Tests
+ Die Webinstanz und .env zeigen auf verschiedene Datenbanken. Siehe Abschnitt 2.
+
+"Fixture reset unavailable"
+ Die Datenbankverbindung fehlt. Prüfe den SSH-Tunnel. Einen Schlüssel mit einem Namen ausserhalb
+ der OpenSSH-Standardnamen probiert SSH nie automatisch. Trage ihn in NOTEN_SSH_KEY ein.
+
+Ein Test erwartet eine Fehlermeldung und bekommt eine andere
+ Die Phrase fehlt in der Datenbank. Lass system/phrasesupdate.php auf dem Server laufen.
+
+Der nächste Lauf startet nicht
+ Ein Cypress-Prozess hängt noch. Beende ihn: Stop-Process -Name Cypress -Force
+
+Ein Screenshot zeigt eine leere Seite
+ Bei API-Tests ist das normal. Diese Tests öffnen keine Seite.
+
+
+6. KONVENTIONEN
+---------------
+
+- Keine festen Ids im Test. Die Noten kommen aus getNoten, die Regelwerte aus getCisConfig. Eine
+ andere Installation hat andere Zahlen.
+- Warte nach jeder schreibenden Aktion auf den Request, nicht auf die Oberfläche.
+- Sprich die Oberfläche über data-cy an, nie über eine CSS-Klasse.
+- Setze den Zustand vor dem Test über givenBaseline oder resetNotenState. Verlass dich nie auf den
+ Zustand, den ein Test davor hinterlassen hat.
diff --git a/tests/cypress/support/api/notenApi.js b/tests/cypress/support/api/notenApi.js
new file mode 100644
index 000000000..69de8a1e4
--- /dev/null
+++ b/tests/cypress/support/api/notenApi.js
@@ -0,0 +1,87 @@
+/**
+ * Wrappers over the Benotungstool API. Mirrors public/js/api/factory/noten.js.
+ */
+
+const NOTEN_API = "/index.ci.php/api/frontend/v1/Noten";
+
+const authOptions = () => ({
+ username: Cypress.env("adminusername"),
+ password: Cypress.env("adminpassword"),
+});
+
+// Basic auth on every call rather than relying on the session cookie, so requests stay independent.
+const apiGet = (path, qs) =>
+ cy.request({ method: "GET", url: `${NOTEN_API}/${path}`, qs, auth: authOptions(), failOnStatusCode: false });
+
+const apiPost = (path, body) =>
+ cy.request({ method: "POST", url: `${NOTEN_API}/${path}`, body, auth: authOptions(), 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 }),
+
+ /** data ist POSITIONAL: [studenten, pruefungen, DOMAIN, grades-by-uid, anwesenheiten] */
+ getStudentenNoten: (lv_id, sem_kurzbz) => apiGet("getStudentenNoten", { lv_id, sem_kurzbz }),
+
+ /** Single-student read through the unfiltered getter. uid -> caller. */
+ getNotenvorschlagStudent: (lv_id, sem_kurzbz, uid = null) =>
+ apiGet("getNotenvorschlagStudent", { lv_id, sem_kurzbz, uid }),
+
+ /** data -> [lvgesamtnote] */
+ saveNotenvorschlag: (lv_id, sem_kurzbz, student_uid, note, punkte = null) =>
+ apiPost("saveNotenvorschlag", { lv_id, sem_kurzbz, student_uid, note, punkte }),
+
+ /** data -> [savedPruefung, lvgesamtnote, verlauf]. Kein `typ` auf der Leitung. */
+ saveStudentPruefung: ({
+ student_uid, note, punkte = null, datum, lva_id, lehreinheit_id, sem_kurzbz, pruefung_id = null,
+ }) =>
+ apiPost("saveStudentPruefung", {
+ student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, pruefung_id,
+ }),
+
+ /** LDAP-password gated. data -> [{uid, freigabedatum, benotungsdatum}] */
+ saveStudentenNoten: (password, noten, lv_id, sem_kurzbz) =>
+ apiPost("saveStudentenNoten", { password, noten, lv_id, sem_kurzbz }),
+
+ getNoteByPunkte: (punkte, lv_id, sem_kurzbz) =>
+ apiPost("getNoteByPunkte", { punkte, lv_id, sem_kurzbz }),
+
+ // Bulk-Pfade antworten 200 und melden Fehler je Zeile in data[uid]
+ saveNotenvorschlagBulk: (lv_id, sem_kurzbz, noten) =>
+ apiPost("saveNotenvorschlagBulk", { lv_id, sem_kurzbz, noten }),
+
+ savePruefungenBulk: (lv_id, sem_kurzbz, pruefungen) =>
+ apiPost("savePruefungenBulk", { lv_id, sem_kurzbz, pruefungen }),
+
+ // note/punkte are optional; without them the Prüfung is created as "Noch nicht eingetragen"
+ createPruefungen: (uids, datum, lva_id, sem_kurzbz, note = null, punkte = null) =>
+ apiPost("createPruefungen", { uids, datum, lva_id, sem_kurzbz, note, punkte }),
+};
+
+// --- selectors over the getStudentenNoten payload ---
+
+export const pruefungenOf = (data, uid) =>
+ (data[1] || []).filter((p) => p.student_uid === uid);
+
+/** Antritte in Verlaufsreihenfolge. Specs prüfen position/zaehlt/antritt_nr/terminal, nie den Typ. */
+export const attemptsOf = (data, uid) =>
+ [...pruefungenOf(data, uid)].sort((a, b) => Number(a.position) - Number(b.position));
+
+/** Only the attempts that consume one - excused / "noch nicht eingetragen" / nicht beurteilt do not. */
+export const countingAttemptsOf = (data, uid) => attemptsOf(data, uid).filter((p) => p.zaehlt);
+
+/** Legacy projection written for old reports. Asserted in exactly one spec, never used as a rule. */
+export const pruefungenOfTyp = (data, uid, typ) =>
+ pruefungenOf(data, uid).filter((p) => p.pruefungstyp_kurzbz === typ);
+
+export const gradesOf = (data, uid) => (data[3] || {})[uid];
+
+/** Server-derived rule state per student: antrittCount, maxAntritte, canAdd, terminal, angerechnet. */
+export const verlaufOf = (data, uid) => (gradesOf(data, uid) || {}).verlauf;
diff --git a/tests/cypress/support/commands.js b/tests/cypress/support/commands.js
new file mode 100644
index 000000000..69f2b641c
--- /dev/null
+++ b/tests/cypress/support/commands.js
@@ -0,0 +1,50 @@
+// 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 für die UI-Specs.
+ * cy.session hält es über Specs hinweg, damit nicht jeder Test neu anmeldet.
+ */
+Cypress.Commands.add("login", () => {
+ const { adminusername, adminpassword } = Cypress.env();
+ const auth = { username: adminusername, password: adminpassword };
+ const probe = () =>
+ cy
+ .request({ url: "/index.ci.php/api/frontend/v1/AuthInfo/getAuthUID", auth })
+ .its("status")
+ .should("eq", 200);
+
+ cy.session(["benotungstool-login", adminusername], probe, {
+ cacheAcrossSpecs: true,
+ validate: probe,
+ });
+});
+
+// Anwendungsfehler sollen den Test nicht abbrechen, aber sichtbar sein.
+Cypress.on("uncaught:exception", (err) => {
+ Cypress.log({ name: "app error", message: err.message, consoleProps: () => ({ error: err }) });
+ return false;
+});
diff --git a/tests/cypress/support/e2e.js b/tests/cypress/support/e2e.js
new file mode 100644
index 000000000..b36e2e7bd
--- /dev/null
+++ b/tests/cypress/support/e2e.js
@@ -0,0 +1,2 @@
+// Loaded automatically before every spec.
+import "./commands";
diff --git a/tests/cypress/support/helpers/network.js b/tests/cypress/support/helpers/network.js
new file mode 100644
index 000000000..7a9a7b2cb
--- /dev/null
+++ b/tests/cypress/support/helpers/network.js
@@ -0,0 +1,2 @@
+export const waitForOk = (alias) =>
+ cy.wait(alias).its("response.statusCode").should("eq", 200);
diff --git a/tests/cypress/support/helpers/notenConfig.js b/tests/cypress/support/helpers/notenConfig.js
new file mode 100644
index 000000000..685f68888
--- /dev/null
+++ b/tests/cypress/support/helpers/notenConfig.js
@@ -0,0 +1,49 @@
+/**
+ * Konfigurationsabhängige Specs.
+ *
+ * CIS_GESAMTNOTE_PUNKTE ist ein PHP define() in config/global.config.inc.php und damit eine
+ * Eigenschaft der INSTANZ, nicht des Requests - ein Testlauf kann den Modus nicht umschalten.
+ * Die Suite deckt deshalb beide Modi ab und überspringt jeweils den unpassenden Teil.
+ *
+ * Für die Pipeline heisst das: zwei Jobs gegen je eine Instanz. Damit ein Job nicht grün meldet,
+ * weil in Wahrheit alles übersprungen wurde, gibt NOTEN_PUNKTE_MODUS ('on'|'off') die Erwartung
+ * vor - passt die Instanz nicht dazu, scheitert der Lauf sofort und laut.
+ */
+
+/** Ist der Punktemodus auf dieser Instanz aktiv? */
+export const punkteModus = (ctx) => Boolean(ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE);
+
+/**
+ * In before() aufrufen: prüft die Instanz gegen NOTEN_PUNKTE_MODUS. Ohne gesetzte Variable
+ * (lokaler Lauf) wird nur geloggt.
+ */
+export const assertPunkteModus = (ctx) => {
+ const erwartet = String(Cypress.env("NOTEN_PUNKTE_MODUS") || "").toLowerCase();
+ const ist = punkteModus(ctx);
+
+ if (erwartet !== "on" && erwartet !== "off") {
+ cy.log(`CIS_GESAMTNOTE_PUNKTE = ${ist} (NOTEN_PUNKTE_MODUS nicht gesetzt)`);
+ return;
+ }
+
+ expect(
+ ist,
+ `NOTEN_PUNKTE_MODUS=${erwartet}, die Instanz steht aber auf CIS_GESAMTNOTE_PUNKTE=${ist}. ` +
+ "Der Lauf würde sonst grün melden, obwohl der halbe Umfang übersprungen wurde. " +
+ "Flag sitzt in config/global.config.inc.php - cis.config.inc.php wird zu spät geladen.",
+ ).to.eq(erwartet === "on");
+};
+
+/** Skip, wenn der Punktemodus aus ist. Als erste Zeile in beforeEach(function(){...}) aufrufen. */
+export const requirePunkteModus = (testContext, ctx) => {
+ if (punkteModus(ctx)) return;
+ cy.log("Übersprungen: CIS_GESAMTNOTE_PUNKTE ist aus.");
+ testContext.skip();
+};
+
+/** Skip, wenn der Punktemodus an ist. */
+export const requireNotenModus = (testContext, ctx) => {
+ if (!punkteModus(ctx)) return;
+ cy.log("Übersprungen: CIS_GESAMTNOTE_PUNKTE ist aktiv.");
+ testContext.skip();
+};
diff --git a/tests/cypress/support/helpers/notenErrors.js b/tests/cypress/support/helpers/notenErrors.js
new file mode 100644
index 000000000..a4049083e
--- /dev/null
+++ b/tests/cypress/support/helpers/notenErrors.js
@@ -0,0 +1,127 @@
+/**
+ * Assertions für die Noten-Fehlerhülle.
+ *
+ * Die Fehler tragen keinen errorCode, nur eine lokalisierte Meldung - daher der Abgleich gegen die
+ * de/en-Templates aus system/phrasesupdate.php. Ein errorCode serverseitig würde das erübrigen.
+ */
+
+const PHRASES = {
+ maxAntritteReached: {
+ de: "Für Studierenden {0} sind bereits die maximal zulässigen Prüfungsantritte ({1}) vergeben. Es wurde keine Prüfung angelegt.",
+ en: "Student {0} has already used the maximum number of exam attempts ({1}). No exam was created.",
+ },
+ pruefungDatumBeforeExisting: {
+ de: "Das Prüfungsdatum für Studierenden {0} darf nicht vor oder am selben Tag wie ein bereits bestehender Prüfungstermin liegen.",
+ en: "The exam date for student {0} must not be on or before an existing exam date.",
+ },
+ noteOccuranceLimitReached: {
+ de: "Die gewählte Note darf für Studierenden {0} nicht so oft vergeben werden. Es wurde keine Prüfung angelegt.",
+ en: "The selected grade may not be assigned that many times for student {0}. No exam was created.",
+ },
+ pruefungNoteLocked: {
+ de: "Die Note für Studierenden {0} kann nicht mehr geändert werden, da bereits eine spätere Prüfung oder eine Prüfung mit höherem Antritt existiert. Das Prüfungsdatum kann weiterhin angepasst werden.",
+ en: "The grade for student {0} can no longer be changed because a later exam or an exam of a higher attempt already exists. The exam date can still be adjusted.",
+ },
+ pruefungDatumOutOfRange: {
+ de: "Das Prüfungsdatum für Studierenden {0} muss zwischen dem vorherigen und dem nachfolgenden Prüfungstermin liegen.",
+ en: "The exam date for student {0} must be between the previous and the following exam date.",
+ },
+ noteneintragungsfristVorbei: {
+ de: "Die Noteneintragungsfrist ({0}) für dieses Studiensemester ist abgelaufen. Es können keine Noten oder Prüfungen mehr eingetragen werden.",
+ en: "The grade entry deadline ({0}) for this study semester has passed. Grades and exams can no longer be entered.",
+ },
+ c4keineLvNoteEingetragen: {
+ de: "Keine LV Note eingetragen",
+ en: "No Subject Grade entered",
+ },
+ c4angerechnetKeinePruefung: {
+ de: "Für {0} ist die Lehrveranstaltung angerechnet. Dafür können keine Prüfungen eingetragen werden.",
+ en: "The course is credited for {0}. No exams can be recorded for it.",
+ },
+ c4pruefungNichtGespeichert: {
+ de: "Die Prüfung für {0} konnte nicht gespeichert werden.",
+ en: "The exam for {0} could not be saved.",
+ },
+ pruefungsdatumNachFrist: {
+ de: "Das Prüfungsdatum für Studierenden {0} liegt nach dem Ende der Noteneintragungsfrist ({1}). Es wurde keine Prüfung angelegt.",
+ en: "The exam date for student {0} is after the grade entry deadline ({1}). No exam was created.",
+ },
+ c4punkteKeineNoteErmittelt: {
+ de: "Für Studierenden {0} konnte aus den Punkten keine Note ermittelt werden. Die Zeile wurde übersprungen.",
+ en: "No grade could be derived from the points for student {0}. The row was skipped.",
+ },
+ c4noteNichtInLehre: {
+ de: "Die gewählte Note ist für eine Lehrveranstaltung nicht zugelassen. Die LV-Note für {0} bleibt unverändert.",
+ en: "The selected grade is not permitted for a course. The course grade for {0} stays unchanged.",
+ },
+ c4notenvorschlagGesperrt: {
+ de: "Für {0} existiert bereits eine Prüfung. Ändern Sie die LV-Note über den Prüfungstermin.",
+ en: "An exam already exists for {0}. Change the course grade through the exam.",
+ },
+ c4zeugnisnoteGesperrt: {
+ de: "Die Zeugnisnote von {0} ist für Lehrende gesperrt. Die LV-Note bleibt unverändert.",
+ en: "The transcript grade of {0} is locked for teachers. The course grade stays unchanged.",
+ },
+ keineBerechtigungNoten: {
+ de: 'Keine Berechtigung, um in der Lehrveranstaltung "{0}" im Semester {1} Noten einzutragen.',
+ en: 'No permission to enter grades for the course "{0}" in semester {1}.',
+ },
+ wrongPassword: { de: "Falsches Passwort", en: "Wrong password" },
+};
+
+const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+
+// Split on the {n} placeholders first, then escape - the other order breaks on the braces.
+const toPattern = (text) =>
+ new RegExp(text.split(/\{\d+\}/).map(escapeRegExp).join("[\\s\\S]*?"));
+
+const patternsFor = (key) => {
+ const phrase = PHRASES[key];
+ if (!phrase) throw new Error(`Unknown phrase key "${key}". Known: ${Object.keys(PHRASES).join(", ")}`);
+ return [toPattern(phrase.de), toPattern(phrase.en)];
+};
+
+export const messageMatchesPhrase = (message, key) =>
+ typeof message === "string" && patternsFor(key).some((re) => re.test(message));
+
+export const expectNotenError = (response, key) => {
+ expect(response.status, `HTTP status for expected "${key}"`).to.eq(500);
+ expect(response.body).to.have.nested.property("meta.status", "error");
+ expect(response.body.errors).to.be.an("array").and.not.be.empty;
+
+ const messages = response.body.errors.map((e) => e.message);
+ expect(
+ messages.some((m) => messageMatchesPhrase(m, key)),
+ `expected phrase "${key}", got: ${JSON.stringify(messages)}`,
+ ).to.be.true;
+};
+
+export const expectNotenSuccess = (response, context = "request") => {
+ // 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 for ${context}${errors ? ` -- ${errors}` : ""}`).to.eq(200);
+ expect(response.body, context).to.have.nested.property("meta.status", "success");
+ return response.body.data;
+};
+
+/** Auth failures short-circuit before any error is added, so the body carries only meta. */
+export const expectAuthError = (response) => {
+ expect(response.status, "HTTP status for auth rejection").to.eq(401);
+ expect(response.body).to.have.nested.property("meta.status", "error");
+};
+
+export const expectBulkRowError = (data, uid, key) => {
+ expect(data).to.have.property(uid);
+ expect(
+ messageMatchesPhrase(data[uid], key),
+ `expected row "${uid}" to carry "${key}", got: ${JSON.stringify(data[uid])}`,
+ ).to.be.true;
+};
+
+export const expectBulkRowAccepted = (data, uid) => {
+ expect(data).to.have.property(uid);
+ const value = data[uid];
+ const isError =
+ typeof value === "string" && Object.keys(PHRASES).some((k) => messageMatchesPhrase(value, k));
+ expect(isError, `expected row "${uid}" accepted, got: ${JSON.stringify(value)}`).to.be.false;
+};
diff --git a/tests/cypress/support/helpers/notenReset.js b/tests/cypress/support/helpers/notenReset.js
new file mode 100644
index 000000000..af3ff1c06
--- /dev/null
+++ b/tests/cypress/support/helpers/notenReset.js
@@ -0,0 +1,95 @@
+/**
+ * Fixture-Reset ausschliesslich über eine direkte DB-Verbindung - ein HTTP-Endpunkt, der
+ * Notendaten löscht, ist durch keine Absicherung so sicher wie sein Nichtvorhandensein.
+ * Von der Workstation aus braucht das den SSH-Tunnel (cypress/tasks/sshTunnel.js).
+ */
+
+let available = null;
+
+/** Probes the DB once per run. Never throws; the caller decides how loudly to fail. */
+export const resolveResetStrategy = () => {
+ if (available) return cy.wrap(available, { log: false });
+
+ return cy.task("noten:db:available", null, { log: false }).then((status) => {
+ available = { strategy: status.available ? "db" : null, reason: status.reason };
+ if (status.available) cy.log("Fixture reset via **direct database**");
+ return cy.wrap(available, { log: false });
+ });
+};
+
+export const describeFailure = (state) =>
+ ` ${state.reason}\n\n` +
+ " Needs a database connection. From a workstation: NOTEN_SSH_TUNNEL=true plus\n" +
+ " NOTEN_SSH_HOST / NOTEN_SSH_USER (and NOTEN_SSH_KEY, unless an agent holds the key).";
+
+/**
+ * uid to stamp as mitarbeiter_uid / freigabevon_uid.
+ * Not Cypress.env("adminusername"): LDAP accepts "Demolektor1" but tbl_benutzer.uid is
+ * "demolektor1" and the FKs are case sensitive.
+ */
+let cachedAuthUid = null;
+
+const authUid = () => {
+ if (cachedAuthUid) return cy.wrap(cachedAuthUid, { log: false });
+
+ return cy
+ .request({
+ method: "GET",
+ url: "/index.ci.php/api/frontend/v1/AuthInfo/getAuthUID",
+ auth: { username: Cypress.env("adminusername"), password: Cypress.env("adminpassword") },
+ failOnStatusCode: false,
+ })
+ .then((response) => {
+ const uid = response.body?.data?.uid;
+ expect(uid, "AuthInfo/getAuthUID uid").to.be.a("string").and.not.be.empty;
+ cachedAuthUid = uid;
+ return uid;
+ });
+};
+
+export const performReset = (context, studentUids) =>
+ cy.task("noten:db:reset", {
+ lvId: context.lvId,
+ semKurzbz: context.semKurzbz,
+ studentUids,
+ });
+
+export const performSeed = (context, studentUid, payload) =>
+ authUid().then((uid) =>
+ cy.task("noten:db:seedLvGesamtnote", {
+ lvId: context.lvId,
+ semKurzbz: context.semKurzbz,
+ studentUid,
+ mitarbeiterUid: uid,
+ ...payload,
+ }),
+ );
+
+export const performSeedPruefung = (context, studentUid, payload) =>
+ authUid().then((uid) =>
+ cy.task("noten:db:seedPruefung", {
+ lvId: context.lvId,
+ semKurzbz: context.semKurzbz,
+ studentUid,
+ mitarbeiterUid: uid,
+ ...payload,
+ }),
+ );
+
+export const performSeedZeugnisnote = (context, studentUid, payload) =>
+ authUid().then((uid) =>
+ cy.task("noten:db:seedZeugnisnote", {
+ lvId: context.lvId,
+ semKurzbz: context.semKurzbz,
+ studentUid,
+ mitarbeiterUid: uid,
+ ...payload,
+ }),
+ );
+
+export const performRead = (context, studentUid) =>
+ cy.task("noten:db:readLvGesamtnote", {
+ lvId: context.lvId,
+ semKurzbz: context.semKurzbz,
+ studentUid,
+ });
diff --git a/tests/cypress/support/helpers/notenScenario.js b/tests/cypress/support/helpers/notenScenario.js
new file mode 100644
index 000000000..67e3acb2a
--- /dev/null
+++ b/tests/cypress/support/helpers/notenScenario.js
@@ -0,0 +1,56 @@
+/** Building attempt histories and reading the resulting state. */
+
+import { notenApi, attemptsOf, countingAttemptsOf, gradesOf, verlaufOf } from "../api/notenApi";
+import { expectNotenSuccess } from "./notenErrors";
+import { resetNotenState, seedBaseline } from "./notenTestData";
+
+/**
+ * Neuer Antritt (ohne pruefung_id -> validatePruefungAdd). Kein typ auf der Leitung.
+ *
+ * punkte bleibt null: mit CIS_GESAMTNOTE_PUNKTE würde ein punkte >= 0 die Note aus dem
+ * Notenschlüssel neu ableiten und die zu testende Note überschreiben.
+ */
+export const addPruefung = (context, student, { note, datum }) =>
+ notenApi.saveStudentPruefung({
+ student_uid: student.uid,
+ note,
+ punkte: null,
+ datum,
+ lva_id: context.lvId,
+ lehreinheit_id: student.lehreinheit_id,
+ sem_kurzbz: context.semKurzbz,
+ pruefung_id: null,
+ });
+
+/** Edits an attempt (pruefung_id set -> server runs validatePruefungEdit). */
+export const editPruefung = (context, student, { pruefungId, note, datum }) =>
+ notenApi.saveStudentPruefung({
+ student_uid: student.uid,
+ note,
+ punkte: null,
+ datum,
+ lva_id: context.lvId,
+ lehreinheit_id: student.lehreinheit_id,
+ sem_kurzbz: context.semKurzbz,
+ pruefung_id: pruefungId,
+ });
+
+export const readState = (context) =>
+ notenApi
+ .getStudentenNoten(context.lvId, context.semKurzbz)
+ .then((response) => expectNotenSuccess(response, "getStudentenNoten"));
+
+/** All attempts of one student, in Verlauf order (position ascending). */
+export const attemptsOfStudent = (data, uid) => attemptsOf(data, uid);
+
+/** Attempts that consume an Antritt, in Verlauf order. */
+export const countingAttemptsOfStudent = (data, uid) => countingAttemptsOf(data, uid);
+
+/** The server's rule state for one student. */
+export const verlaufOfStudent = (data, uid) => verlaufOf(data, uid);
+
+export const lvNoteOf = (data, uid) => gradesOf(data, uid);
+
+/** Clears the suite's rows and re-seeds the Antritt-1 baseline for one student. */
+export const givenBaseline = (context, student, options = {}) =>
+ resetNotenState(context).then(() => seedBaseline(context, student.uid, options));
diff --git a/tests/cypress/support/helpers/notenTestData.js b/tests/cypress/support/helpers/notenTestData.js
new file mode 100644
index 000000000..882bba9e3
--- /dev/null
+++ b/tests/cypress/support/helpers/notenTestData.js
@@ -0,0 +1,263 @@
+/**
+ * Runtime discovery + Fixture-Helfer.
+ *
+ * Keine fixen Ids: maxAntritte und die Sondernoten-PKs stehen in Server-Config, die nicht im Repo
+ * liegt, und werden daher zur Laufzeit über die API gelesen. Override: NOTEN_SEM / NOTEN_LV_ID.
+ */
+
+import { notenApi } from "../api/notenApi";
+import { expectNotenSuccess } from "./notenErrors";
+// the client rule; it now only reads back what the server derived, so a mismatch is a config bug
+import { maxAntrittCount as computeMaxAntritte } from "../../../../public/js/components/Cis/Benotungstool/notenRules.js";
+import {
+ describeFailure, performRead, performReset, performSeed, performSeedPruefung,
+ performSeedZeugnisnote, resolveResetStrategy,
+} from "./notenReset";
+import { assertPunkteModus } from "./notenConfig";
+
+const BEZ_ENTSCHULDIGT = "entschuldigt";
+const BEZ_NOCH_NICHT = "Noch nicht eingetragen";
+
+let cachedContext = 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())}`;
+};
+
+/** Deadline the server derives (computeNoteneintragungsfrist): SS -> 15.11.yyyy, WS -> 15.05.yyyy+1. */
+export const expectedFristString = (
+ semKurzbz,
+ ssConfig = { month: 11, day: 15 },
+ wsConfig = { month: 5, day: 15 },
+) => {
+ 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}`;
+};
+
+export const fristHasPassed = (semKurzbz) => {
+ const type = semKurzbz.slice(0, 2).toUpperCase();
+ const year = Number(semKurzbz.slice(2, 6));
+ if (!["SS", "WS"].includes(type) || !year) return false;
+ const deadline =
+ type === "SS" ? new Date(year, 10, 15, 23, 59, 59) : new Date(year + 1, 4, 15, 23, 59, 59);
+ return new Date() > deadline;
+};
+
+export const semesterWithPastFrist = (type = "SS") => {
+ const year = new Date().getFullYear();
+ for (let y = year; y >= year - 6; y -= 1) {
+ if (fristHasPassed(`${type}${y}`)) return `${type}${y}`;
+ }
+ return `${type}${year - 6}`;
+};
+
+// --- discovery ---
+
+const resolveSemester = () => {
+ const configured = Cypress.env("NOTEN_SEM");
+ 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: { username: Cypress.env("adminusername"), password: Cypress.env("adminpassword") },
+ 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, `an active studiensemester starting on or before ${today}`).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,
+ `No Lehrveranstaltungen for ${semKurzbz}: the logged-in user teaches nothing here ` +
+ "(admins usually don't). Set NOTEN_LV_ID.",
+ ).to.be.greaterThan(0);
+ return lvs[0].lehrveranstaltung_id;
+ });
+};
+
+
+/** -> { semKurzbz, lvId, cisConfig, maxAntritte, notes, gradeNotes, students, studentUids } */
+export const loadNotenContext = () => {
+ if (cachedContext) return cy.wrap(cachedContext, { log: false });
+
+ const context = {};
+
+ return resolveSemester()
+ .then((sem) => {
+ context.semKurzbz = sem;
+ return resolveLehrveranstaltung(sem);
+ })
+ .then((lvId) => {
+ context.lvId = lvId;
+ return notenApi.getCisConfig();
+ })
+ .then((response) => {
+ context.cisConfig = expectNotenSuccess(response, "getCisConfig");
+ context.maxAntritte = computeMaxAntritte(context.cisConfig);
+ // ein Lauf, der den falschen Konfigurationsmodus erwartet, soll hier scheitern und nicht
+ // alles stillschweigend überspringen
+ assertPunkteModus(context);
+ 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 needs bezeichnung "${BEZ_ENTSCHULDIGT}"`).to.exist;
+ expect(nochNicht, `tbl_note needs bezeichnung "${BEZ_NOCH_NICHT}"`).to.exist;
+
+ // Restricted to the 1..5 scale: the wider lehre-Noten set contains note 0 ("Teilnote"),
+ // and saveStudentPruefung's `if($note=='')` guard rewrites a 0 to "Noch nicht eingetragen".
+ 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));
+
+ context.gradeNotes =
+ ordinary.length > 1
+ ? ordinary
+ : usable.map((n) => n.note).filter((n) => Number(n) !== 0).sort((a, b) => Number(a) - Number(b));
+
+ expect(context.gradeNotes.length, "need two ordinary grades to vary one on edit").to.be.greaterThan(1);
+
+ // administrative note the editor list excludes ("intern angerechnet" / "nicht zugelassen")
+ const nichtLehre = noten.find((n) => n.lehre === false);
+
+ // as a ZEUGNISnote this one locks the LV note, in the client and in the server
+ const nichtUeberschreibbar = noten.find((n) => n.lkt_ueberschreibbar === false);
+
+ // Anrechnungen block every Prüfung for the LV - keyed on the ZEUGNISnote
+ const angerechnet = byBezeichnung("angerechnet");
+ const internAngerechnet = byBezeichnung("intern angerechnet");
+
+ context.notes = {
+ entschuldigt: entschuldigt.note,
+ nochNichtEingetragen: nochNicht.note,
+ nichtLehre: nichtLehre ? nichtLehre.note : null,
+ nichtUeberschreibbar: nichtUeberschreibbar ? nichtUeberschreibbar.note : null,
+ angerechnet: angerechnet ? angerechnet.note : null,
+ internAngerechnet: internAngerechnet ? internAngerechnet.note : null,
+ };
+
+ // die GUI-Specs wählen und lesen über die Bezeichnung, nicht über die PK
+ context.notenOptions = noten;
+
+ return notenApi.getStudentenNoten(context.lvId, context.semKurzbz);
+ })
+ .then((response) => {
+ const data = expectNotenSuccess(response, `getStudentenNoten(${context.lvId})`);
+ const students = data[0] || [];
+
+ expect(students.length, `LV ${context.lvId} needs >=3 enrolled students`).to.be.greaterThan(2);
+ students.forEach((s) => expect(s.lehreinheit_id, `lehreinheit_id of ${s.uid}`).to.exist);
+
+ context.students = students;
+ context.studentUids = students.map((s) => s.uid);
+
+ cachedContext = context;
+ return context;
+ });
+};
+
+// --- fixture state ---
+
+export const resetNotenState = (context, studentUids) =>
+ performReset(context, studentUids || context.studentUids);
+
+/**
+ * Baseline für Antritt 1: freigegebene LV-Note PLUS die Prüfungszeile dazu - das ist, was die
+ * Freigabe produziert. Ohne die Zeile zählt die LV-Note nur solange gar kein Termin existiert, und
+ * der erste hinzugefügte Antritt würde Antritt 1 ersetzen statt Antritt 2 zu werden.
+ *
+ * `erstantritt: false` (bzw. `freigegeben: false`) seedet die Altdaten-Form ohne diese Zeile.
+ */
+export const seedBaseline = (context, studentUid, options = {}) => {
+ const note = options.note !== undefined ? options.note : context.gradeNotes[0];
+ const freigegeben = options.freigegeben !== undefined ? options.freigegeben : true;
+
+ return performSeed(context, studentUid, {
+ note,
+ punkte: options.punkte !== undefined ? options.punkte : null,
+ benotungsdatum: options.benotungsdatum || baselineBenotungsdatum(context),
+ freigegeben,
+ freigabedatum: options.freigabedatum || null,
+ }).then((seeded) => {
+ if (options.erstantritt === false || !freigegeben) return cy.wrap(seeded, { log: false });
+
+ const student = context.students.find((s) => s.uid === studentUid);
+ expect(student, `student ${studentUid} in the loaded LV`).to.exist;
+
+ return performSeedPruefung(context, studentUid, {
+ lehreinheitId: student.lehreinheit_id,
+ note,
+ datum: baselineDate(context),
+ typ: "Termin1", // legacy projection of Antritt 1; the rules never read it back
+ }).then(() => seeded);
+ });
+};
+
+/** -> { pruefungId, note, datum, typ }. For attempt states the API cannot build. */
+export const seedPruefung = (context, student, { note, datum, typ }) =>
+ performSeedPruefung(context, student.uid, {
+ lehreinheitId: student.lehreinheit_id,
+ note,
+ datum,
+ typ,
+ });
+
+/** Zeugnisnote setzen. Nur die Studierendenverwaltung schreibt sie, kein Endpunkt dieses Tools. */
+export const seedZeugnisnote = (context, studentUid, note) =>
+ performSeedZeugnisnote(context, studentUid, { note });
+
+/** Raw row, bypassing the freigabedatum filter of getLvGesamtNoten. */
+export const readLvGesamtnote = (context, studentUid) => performRead(context, studentUid);
+
+/** Anchor date; attempt dates derive from it so ordering is known. */
+export const baselineBenotungsdatum = (context) => `${Number(context.semKurzbz.slice(2, 6))}-01-10 08:00:00`;
+
+export const baselineDate = (context) => baselineBenotungsdatum(context).slice(0, 10);
+
+/** attemptDate(ctx,1) < attemptDate(ctx,2) < ... , all after the baseline. */
+export const attemptDate = (context, index) => shiftDate(baselineDate(context), 30 * index);
+
+export const requireDbReset = () =>
+ resolveResetStrategy().then((state) => {
+ expect(
+ state.strategy,
+ `Fixture reset unavailable.\n\n${describeFailure(state)}\n\n See tests/cypress/.env.example.\n`,
+ ).to.not.be.null;
+ });
diff --git a/tests/cypress/support/pages/benotungstool.po.js b/tests/cypress/support/pages/benotungstool.po.js
new file mode 100644
index 000000000..0535183cc
--- /dev/null
+++ b/tests/cypress/support/pages/benotungstool.po.js
@@ -0,0 +1,306 @@
+import { waitForOk } from "../helpers/network";
+
+/**
+ * Page object für das Benotungstool.
+ *
+ * Selektoren sind data-cy-Attribute aus Benotungstool.js. Die Tabelle ist Tabulator: Zeilen tragen
+ * data-cy="student-row-", Zellen das von Tabulator gesetzte tabulator-field, und der
+ * Freigabestatus steckt als data-state am Zellinhalt statt am Icon.
+ *
+ * Nach jeder schreibenden Aktion wird auf den zugehörigen Request gewartet (waitForOk), nicht auf
+ * eine DOM-Änderung - sonst prüfen die Specs gegen den Stand vor der Antwort.
+ */
+
+const TABLE_TIMEOUT = 60_000;
+const API = "**/api/frontend/v1/Noten";
+
+/**
+ * Exakter Textvergleich für Optionslisten. Nötig, weil die Notenbezeichnungen einander enthalten:
+ * contains("Gut") trifft zuerst "Sehr Gut", contains("Genügend") zuerst "Nicht Genügend".
+ */
+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}/saveNotenvorschlag` }).as("saveNotenvorschlag");
+ cy.intercept({ method: "POST", url: `${API}/saveNotenvorschlagBulk` }).as("saveNotenvorschlagBulk");
+ cy.intercept({ method: "POST", url: `${API}/saveStudentPruefung` }).as("saveStudentPruefung");
+ cy.intercept({ method: "POST", url: `${API}/createPruefungen` }).as("createPruefungen");
+ cy.intercept({ method: "POST", url: `${API}/savePruefungenBulk` }).as("savePruefungenBulk");
+ cy.intercept({ method: "POST", url: `${API}/saveStudentenNoten` }).as("saveStudentenNoten");
+ cy.intercept({ method: "POST", url: `${API}/getNoteByPunkte` }).as("getNoteByPunkte");
+ };
+
+ /**
+ * Deep-Link auf LV + Semester - die vier Dropdowns müssen dafür nicht bedient werden.
+ * Die Spaltenaufteilung wird vor dem Laden gesetzt: die Komponente liest den localStorage beim
+ * Initialisieren, und vor dem ersten visit gehört er noch about:blank.
+ */
+ visit = (ctx, { spalten = "antritt" } = {}) =>
+ cy.visit(`/cis.php/Cis/Benotungstool/${ctx.lvId}/${ctx.semKurzbz}`, {
+ onBeforeLoad(win) {
+ win.localStorage.setItem("notenToolPruefungsspalten", spalten);
+ win.localStorage.removeItem("notenToolStickyCols");
+ },
+ });
+
+ visitAndWaitForTable = (ctx, options) => {
+ cy.login();
+ this.setupIntercepts();
+ this.visit(ctx, options);
+
+ waitForOk("@getStudentenNoten");
+ this.getTable().should("be.visible");
+ this.getRows().should("have.length.greaterThan", 0);
+ };
+
+ // --- Grundelemente ---------------------------------------------------------------------------
+
+ 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, "freigegeben").find("[data-cy='freigabe-state']");
+ getPruefungCell = (uid, spalte) => this.getCell(uid, spalte).find("[data-cy='pruefung-cell']");
+ getUebernehmenButton = (uid) => this.getCell(uid, "übernehmen").find("[data-cy='btn-uebernehmen']");
+ getPruefungAddButton = (uid, spalte) =>
+ this.getCell(uid, spalte).find("[data-cy='btn-pruefung-add']");
+ getPruefungEditButton = (uid, spalte) =>
+ this.getCell(uid, spalte).find("[data-cy='btn-pruefung-edit']");
+
+ getPruefungModal = () => cy.get("[data-cy='modal-pruefung']");
+ getNeuePruefungModal = () => cy.get("[data-cy='modal-neue-pruefung']");
+ getFreigabeModal = () => cy.get("[data-cy='modal-freigabe']");
+ getNotenImportModal = () => cy.get("[data-cy='modal-noten-import']");
+ getPruefungImportModal = () => cy.get("[data-cy='modal-pruefung-import']");
+ getFreigabeSummaryRow = (uid) => cy.get(`[data-cy='freigabe-row-${uid}']`);
+
+ // --- Zustand prüfen --------------------------------------------------------------------------
+
+ /** offen | changed | ok */
+ expectFreigabeState = (uid, state) =>
+ this.getFreigabeState(uid).should("have.attr", "data-state", state);
+
+ expectLvNote = (uid, bezeichnung) => this.getCell(uid, "lv_note").should("contain.text", bezeichnung);
+
+ expectNotenvorschlag = (uid, bezeichnung) =>
+ this.getCell(uid, "note_vorschlag").should("contain.text", bezeichnung);
+
+ /** centeredTextFormatter prüft auf falsy, eine 0 rendert daher als leere Zelle. */
+ expectAntrittCount = (uid, count) =>
+ Number(count) === 0
+ ? this.getCell(uid, "hoechsterAntritt").invoke("text").invoke("trim").should("eq", "")
+ : this.getCell(uid, "hoechsterAntritt").should("contain.text", String(count));
+
+ expectPruefung = (uid, spalte, { note, antritt } = {}) => {
+ // je Assertion neu abfragen, damit Cypress ein Re-Render der Zelle erneut versucht
+ if (note !== undefined) {
+ this.getPruefungCell(uid, spalte).should("have.attr", "data-note", String(note));
+ }
+ if (antritt !== undefined) {
+ this.getPruefungCell(uid, spalte).should("have.attr", "data-attempt", String(antritt));
+ }
+ };
+
+ expectKeinePruefung = (uid, spalte) =>
+ this.getCell(uid, spalte).find("[data-cy='pruefung-cell']").should("not.exist");
+
+ expectKeineAntrittsspalte = (nr) => cy.get(`[tabulator-field='antritt_${nr}']`).should("not.exist");
+
+ // --- Notenvorschlag --------------------------------------------------------------------------
+
+ /** Öffnet den Tabulator-Listeneditor der Vorschlagsspalte und wählt die Bezeichnung. */
+ setNotenvorschlag = (uid, bezeichnung) => {
+ this.getCell(uid, "note_vorschlag").click();
+ cy.get(".tabulator-edit-list-item").contains(bezeichnung).click();
+ };
+
+ uebernehmen = (uid) => {
+ this.getUebernehmenButton(uid).click();
+ waitForOk("@saveNotenvorschlag");
+ };
+
+ // --- Prüfungen -------------------------------------------------------------------------------
+
+ /** PrimeVue hängt seine Panels an den body, daher nicht innerhalb des Modals suchen. */
+ selectDropdownOption = (dataCy, label) => {
+ cy.get(`[data-cy='${dataCy}']`).click();
+ cy.contains(".p-dropdown-panel .p-dropdown-item", exactText(label)).click();
+ };
+
+ setDatum = (dataCy, ddmmyyyy) =>
+ cy.get(`[data-cy='${dataCy}'] input`).first().clear().type(`${ddmmyyyy}{enter}`);
+
+ /** Dialog aus der Tabellenzelle: neuer Antritt für EINEN Studenten. */
+ addPruefungInCell = (uid, spalte, { note, datum } = {}) => {
+ this.getPruefungAddButton(uid, spalte).click();
+ this.getPruefungModal().should("be.visible");
+
+ if (datum) this.setDatum("pruefung-datum", datum);
+ if (note) this.selectDropdownOption("pruefung-note", note);
+
+ cy.get("[data-cy='pruefung-submit']").click();
+ waitForOk("@saveStudentPruefung");
+ this.getPruefungModal().should("not.be.visible");
+ };
+
+ /** Bestehenden Antritt bearbeiten. Ohne `note` bleibt sie unverändert (Datumskorrektur). */
+ editPruefungInCell = (uid, spalte, { note, datum } = {}) => {
+ this.getPruefungEditButton(uid, spalte).click();
+ this.getPruefungModal().should("be.visible");
+
+ if (datum) this.setDatum("pruefung-datum", datum);
+ if (note) this.selectDropdownOption("pruefung-note", note);
+
+ cy.get("[data-cy='pruefung-submit']").click();
+ waitForOk("@saveStudentPruefung");
+ this.getPruefungModal().should("not.be.visible");
+ };
+
+ openPruefungModalForEdit = (uid, spalte) => {
+ this.getPruefungEditButton(uid, spalte).click();
+ this.getPruefungModal().should("be.visible");
+ };
+
+ /** Sammelanlage über die Toolbar, für mehrere Studierende auf einmal. */
+ addPruefungBulk = ({ uids, note, datum }) => {
+ cy.get("[data-cy='btn-neue-pruefung']").click();
+ this.getNeuePruefungModal().should("be.visible");
+
+ if (datum) this.setDatum("neue-pruefung-datum", datum);
+ if (note) this.selectDropdownOption("neue-pruefung-note", note);
+
+ cy.get("[data-cy='neue-pruefung-studenten']").click();
+ // das Label ist "uid – Nachname Vorname – Antritte: n", daher hier bewusst per Teilstring
+ uids.forEach((uid) => cy.contains(".p-multiselect-panel .p-multiselect-item", uid).click());
+
+ // Panel über den Trigger schliessen, NICHT mit Escape: das schliesst das Bootstrap-Modal mit
+ cy.get("[data-cy='neue-pruefung-studenten']").click();
+ cy.get(".p-multiselect-panel").should("not.exist");
+
+ cy.get("[data-cy='neue-pruefung-submit']").click();
+ waitForOk("@createPruefungen");
+ this.getNeuePruefungModal().should("not.be.visible");
+ };
+
+ // --- Freigabe --------------------------------------------------------------------------------
+
+ openFreigabeModal = () => {
+ cy.get("[data-cy='btn-freigabe']").click();
+ this.getFreigabeModal().should("be.visible");
+ };
+
+ expectFreigabeSummaryRow = (uid, releasedBezeichnung) =>
+ this.getFreigabeSummaryRow(uid)
+ .find("[data-cy='freigabe-row-released']")
+ .should("contain.text", releasedBezeichnung);
+
+ typeFreigabePasswort = (password) =>
+ cy.get("[data-cy='freigabe-passwort'] input").type(password, { log: false });
+
+ submitFreigabe = () => cy.get("[data-cy='freigabe-submit']").click();
+
+ freigeben = (password) => {
+ this.typeFreigabePasswort(password);
+ this.submitFreigabe();
+ waitForOk("@saveStudentenNoten");
+ this.getFreigabeModal().should("not.be.visible");
+ };
+
+ // --- Import ----------------------------------------------------------------------------------
+
+ /** rows: [[uid, note], ...] -> "uidnote" je Zeile */
+ importNoten = (rows) => {
+ cy.get("[data-cy='btn-noten-import']").click();
+ this.getNotenImportModal().should("be.visible");
+
+ cy.get("[data-cy='noten-import-text']").type(rows.map((r) => r.join("\t")).join("\n"));
+ cy.get("[data-cy='noten-import-submit']").click();
+ waitForOk("@saveNotenvorschlagBulk");
+ this.getNotenImportModal().should("not.be.visible");
+ };
+
+ /** rows: [[uid, "dd.MM.yyyy", note], ...] */
+ importPruefungen = (rows) => {
+ 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();
+ waitForOk("@savePruefungenBulk");
+ this.getPruefungImportModal().should("not.be.visible");
+ };
+
+ // --- Punktemodus -----------------------------------------------------------------------------
+ // Die Punktespalte und die beiden Punktefelder in den Dialogen existieren nur mit
+ // CIS_GESAMTNOTE_PUNKTE. Die Note wird dann aus dem Notenschlüssel abgeleitet, nicht gewählt.
+
+ getPunkteCell = (uid) => this.getCell(uid, "punkte");
+
+ /**
+ * Punkte in die Zelle tippen; die Note holt der Client debounced über getNoteByPunkte nach.
+ *
+ * Über cy.focused(): der liveNumberEditor fokussiert sein Input in onRendered, und die Zeile
+ * wird beim Klick neu formatiert (cellClick -> undoSelection), sodass ein zweiter Zugriff über
+ * die Zelle ins Leere greifen kann.
+ */
+ setPunkteInCell = (uid, punkte) => {
+ this.getPunkteCell(uid).click();
+
+ // Über cy.focused(), weil der liveNumberEditor sein Input in onRendered fokussiert und die
+ // Zeile beim Klick neu formatiert wird. Enter committet den Wert (success() im Editor);
+ // die Note holt der Client danach debounced über getNoteByPunkte nach - deren Rendern
+ // schliesst den Editor, ein Blur danach ginge ins Leere.
+ cy.focused().should("have.attr", "type", "number").clear().type(`${punkte}{enter}`);
+ waitForOk("@getNoteByPunkte");
+ };
+
+ expectPunkte = (uid, punkte) =>
+ this.getPunkteCell(uid).should("contain.text", String(punkte));
+
+ /** Sobald ein Termin existiert, ist die Punktespalte gesperrt (editable-Guard der Spalte). */
+ expectPunkteZelleGesperrt = (uid) => {
+ this.getPunkteCell(uid).click();
+ this.getPunkteCell(uid).find("input").should("not.exist");
+ };
+
+ /** Im Punktemodus ist die Vorschlagsspalte nicht editierbar - die Note kommt aus den Punkten. */
+ expectNotenvorschlagGesperrt = (uid) => {
+ this.getCell(uid, "note_vorschlag").click();
+ cy.get(".tabulator-edit-list").should("not.exist");
+ };
+
+ expectNoteFeldGesperrt = (dataCy) => cy.get(`[data-cy='${dataCy}']`).should("have.class", "p-disabled");
+
+ /** Punktefeld im Einzeldialog; löst die Ableitung der Note aus. */
+ setPruefungPunkte = (punkte) => {
+ cy.get("[data-cy='pruefung-punkte'] input").clear().type(String(punkte));
+ waitForOk("@getNoteByPunkte");
+ };
+
+ /** Punktefeld der Sammelanlage; dort leitet erst der Server beim Speichern ab. */
+ setNeuePruefungPunkte = (punkte) =>
+ cy.get("[data-cy='neue-pruefung-punkte'] input").clear().type(String(punkte));
+
+ // --- Hilfen ----------------------------------------------------------------------------------
+
+ /** Die Bezeichnung zu einer Noten-PK, wie sie in Dropdowns und Zellen steht. */
+ 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}`;
+ };
+}
+
+export const benotungstoolPage = new BenotungstoolPage();
diff --git a/tests/cypress/tasks/db.js b/tests/cypress/tasks/db.js
new file mode 100644
index 000000000..8bcd3f09f
--- /dev/null
+++ b/tests/cypress/tasks/db.js
@@ -0,0 +1,144 @@
+/**
+ * Postgres plumbing for cy.task fixture resets. Suite-agnostic.
+ *
+ * Connection, write guard, pooling and teardown live here. What to delete or seed belongs in a
+ * suite-specific task file -- see notenDb.js.
+ *
+ * Settings come from _DB_* and _SSH_*, where PREFIX is TEST_ENV_PREFIX. That
+ * indirection is the point: another suite sets its own prefix and reuses this file untouched.
+ */
+
+const { ensureTunnel, closeTunnel, tunnelPort } = require("./sshTunnel");
+
+const PREFIX = process.env.TEST_ENV_PREFIX || "TEST";
+
+const env = (key) => process.env[`${PREFIX}_DB_${key}`];
+const sshEnv = (key) => process.env[`${PREFIX}_SSH_${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
+ // eslint-disable-next-line global-require
+ const { Pool } = require("pg");
+
+ // with a tunnel up, bind to its local end - that is what gets us past pg_hba
+ const forwarded = tunnelPort();
+
+ pool = new Pool({
+ host: forwarded ? "127.0.0.1" : env("HOST"),
+ port: forwarded || Number(env("PORT") || 5432),
+ database: env("NAME"),
+ user: env("USER"),
+ password: env("PASSWORD"),
+ max: 4,
+ ssl: String(env("SSL")).toLowerCase() === "true" ? { rejectUnauthorized: false } : undefined,
+ });
+ return pool;
+};
+
+const assertWritable = () => {
+ if (!dbConfigured()) {
+ throw new Error(`db refused - set ${PREFIX}_DB_HOST / ${PREFIX}_DB_NAME / ${PREFIX}_DB_USER.`);
+ }
+ if (!writesAllowed()) {
+ throw new Error(`db refused - set ${PREFIX}_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. ` +
+ `Use the SSH tunnel (${PREFIX}_SSH_TUNNEL=true) 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 `${PREFIX}_DB_NAME does not exist. Details: ${msg}`;
+ if (/ECONNREFUSED/i.test(msg)) return `nothing listening on ${PREFIX}_DB_HOST:${PREFIX}_DB_PORT. Details: ${msg}`;
+ if (/ETIMEDOUT|EHOSTUNREACH|ENETUNREACH/i.test(msg)) return `host unreachable (firewall/VPN?). Details: ${msg}`;
+ return msg;
+};
+
+/** Opens the tunnel if configured, then proves the connection works. */
+const checkAvailability = async ({ requireWrites = true } = {}) => {
+ if (!dbConfigured()) return { available: false, reason: "not-configured" };
+ if (requireWrites && !writesAllowed()) return { available: false, reason: "writes-disabled" };
+
+ if (String(sshEnv("TUNNEL")).toLowerCase() === "true") {
+ const tunnel = await ensureTunnel({
+ sshHost: sshEnv("HOST"),
+ sshPort: Number(sshEnv("PORT") || 22),
+ sshUser: sshEnv("USER"),
+ keyPath: sshEnv("KEY"),
+ passphrase: sshEnv("PASSPHRASE"),
+ agent: sshEnv("AGENT"),
+ dbHost: env("HOST"),
+ dbPort: Number(env("PORT") || 5432),
+ });
+ if (!tunnel.tunnelled) return { available: false, reason: `ssh-tunnel-failed: ${tunnel.reason}` };
+ }
+
+ 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) {
+ try { await pool.end(); } catch (e) { /* ignore */ }
+ pool = null;
+ }
+ return { available: false, reason: explainFailure(error) };
+ }
+};
+
+const closeDb = async () => {
+ if (pool) {
+ await pool.end();
+ pool = null;
+ }
+ await closeTunnel();
+ return null;
+};
+
+module.exports = {
+ PREFIX,
+ env,
+ dbConfigured,
+ writesAllowed,
+ assertWritable,
+ withClient,
+ inTransaction,
+ checkAvailability,
+ closeDb,
+};
diff --git a/tests/cypress/tasks/notenDb.js b/tests/cypress/tasks/notenDb.js
new file mode 100644
index 000000000..7df57326a
--- /dev/null
+++ b/tests/cypress/tasks/notenDb.js
@@ -0,0 +1,215 @@
+/**
+ * cy.task implementations for the Gesamtnoteneingabe fixture. Connection and guards come from
+ * db.js; this file only knows which rows the §1 specs write and how to undo them.
+ *
+ * Needed because the Noten API has no delete endpoint, while every §1 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, checkAvailability, closeDb,
+} = require("./db");
+
+const REQUIRED_SCOPE = ["lvId", "semKurzbz", "studentUids"];
+
+const assertScope = (scope) => {
+ if (!scope || typeof scope !== "object") throw new Error("noten:db scope object is required");
+
+ REQUIRED_SCOPE.forEach((key) => {
+ if (scope[key] === undefined || scope[key] === null || scope[key] === "") {
+ throw new Error(`noten:db refused - missing scope key "${key}"`);
+ }
+ });
+
+ 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 resetStudents = 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 Antritt-1 baseline. `freigegeben` matters: getLvGesamtNoten() filters
+ * `freigabedatum < NOW()`, so an offene note is invisible to validatePruefungAdd.
+ */
+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 seed - note is required");
+ if (!benotungsdatum) throw new Error("noten:db seed - benotungsdatum is required");
+ if (!mitarbeiterUid) throw new Error("noten:db seed - 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 a single Prüfung row. Needed for states no endpoint can produce: an excused 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, typ, 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 (!typ) throw new Error("noten:db seedPruefung - typ 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, typ, datum],
+ );
+
+ return { pruefungId: res.rows[0].pruefung_id, note, datum, typ };
+ });
+};
+
+/**
+ * Seeds the transcript grade. The student administration writes it, no endpoint of this tool does.
+ * It decides two rules: an Anrechnung blocks every exam, 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 registerNotenDbTasks = (on) => {
+ on("task", {
+ "noten:db:available": () => checkAvailability(),
+ "noten:db:reset": (scope) => resetStudents(scope),
+ "noten:db:seedLvGesamtnote": (scope) => seedLvGesamtnote(scope),
+ "noten:db:seedPruefung": (scope) => seedPruefung(scope),
+ "noten:db:seedZeugnisnote": (scope) => seedZeugnisnote(scope),
+ "noten:db:readLvGesamtnote": (scope) => readLvGesamtnote(scope),
+ "noten:db:close": () => closeDb(),
+ });
+};
+
+module.exports = { registerNotenDbTasks };
diff --git a/tests/cypress/tasks/sshTunnel.js b/tests/cypress/tasks/sshTunnel.js
new file mode 100644
index 000000000..6407f0d8a
--- /dev/null
+++ b/tests/cypress/tasks/sshTunnel.js
@@ -0,0 +1,137 @@
+/**
+ * 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;
+
+/** Local end of the forward, or null. Lets the pool bind without going through the environment. */
+const tunnelPort = () => localPort;
+
+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."));
+
+ // eslint-disable-next-line global-require
+ 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) => {
+ sshClient.forwardOut(
+ socket.remoteAddress || "127.0.0.1",
+ socket.remotePort || 0,
+ cfg.dbHost,
+ cfg.dbPort,
+ (err, stream) => {
+ if (err) return 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;
+ // eslint-disable-next-line no-console
+ 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 () => {
+ if (server) {
+ await new Promise((resolve) => server.close(resolve));
+ server = null;
+ }
+ if (sshClient) {
+ sshClient.end();
+ sshClient = 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, tunnelPort };
diff --git a/tests/cypress/tools/checks/noten.js b/tests/cypress/tools/checks/noten.js
new file mode 100644
index 000000000..adeef4627
--- /dev/null
+++ b/tests/cypress/tools/checks/noten.js
@@ -0,0 +1,117 @@
+/**
+ * Fixture preconditions for the Gesamtnoteneingabe suite. Data only -- dbCheck.js runs it.
+ *
+ * Every precondition of the chain
+ * getBenotungstoolContext -> getStudentenNoten -> saveStudentPruefung
+ * 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 = (name) => path.join(__dirname, "..", "..", "..", "..", "system", "seeders", name);
+
+/** Test semester: the running one, else the last started. Same rule as the suite and seeder 016. */
+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: {
+ seed: seeder("016_benotungstool_noten.sql"),
+ },
+
+ 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 + 003_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 002_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: "Apply 013_studiengang_5.sql.",
+ },
+ {
+ 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. The 013 seeder pins its " +
+ "Lehreinheiten to the semester that was active when it ran. Apply 016_benotungstool_noten.sql.",
+ },
+ {
+ 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 016_benotungstool_noten.sql.",
+ },
+ {
+ 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 016_benotungstool_noten.sql.",
+ },
+ {
+ 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 >= 3,
+ hint: "The suite needs at least 3 enrolled students. Apply 016_benotungstool_noten.sql.",
+ },
+ {
+ 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 with system/setup_testinstance.php --setup.",
+ },
+ {
+ 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. 016 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 016_benotungstool_noten.sql.",
+ },
+ ],
+};
diff --git a/tests/cypress/tools/dbCheck.js b/tests/cypress/tools/dbCheck.js
new file mode 100644
index 000000000..ca032ef5b
--- /dev/null
+++ b/tests/cypress/tools/dbCheck.js
@@ -0,0 +1,193 @@
+#!/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/.js exporting { title, checks, sqlFiles }. Connection,
+ * tunnel and output are shared and live here.
+ *
+ * `check` is read-only; `seed` needs _DB_ALLOW_WRITES=true and an owner role.
+ */
+
+const fs = require("fs");
+const path = require("path");
+
+try {
+ require("dotenv").config({ path: path.join(__dirname, "..", ".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);
+ }
+ // eslint-disable-next-line global-require, import/no-dynamic-require
+ 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;
+};
+
+const applySqlFile = async (client, file) => {
+ if (!db.writesAllowed()) {
+ console.error(`\nRefusing to write: set ${db.PREFIX}_DB_ALLOW_WRITES=true first.\n`);
+ return 2;
+ }
+ if (!fs.existsSync(file)) {
+ console.error(`\nFile not found: ${file}\n`);
+ return 2;
+ }
+
+ console.log(`\nApplying ${path.relative(process.cwd(), file)} ...\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(fs.readFileSync(file, "utf8"));
+ } 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 ${db.PREFIX}_DB_USER is "${db.env("USER")}".`);
+ console.error("Point it at the owner role, or seed via system/setup_testinstance.php.\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: ${path.basename(file)}\n`);
+ return 0;
+};
+
+const printEnv = () => {
+ const p = db.PREFIX;
+ console.log(`
+Add to tests/cypress/.env (gitignored):
+
+ TEST_ENV_PREFIX=${p}
+ ${p}_DB_HOST=
+ ${p}_DB_PORT=5432
+ ${p}_DB_NAME=
+ ${p}_DB_USER=
+ ${p}_DB_PASSWORD=
+ ${p}_DB_ALLOW_WRITES=true
+
+If pg_hba.conf does not admit this machine, add the tunnel:
+
+ ${p}_SSH_TUNNEL=true
+ ${p}_SSH_HOST=
+ ${p}_SSH_USER=
+ ${p}_SSH_KEY=
+`);
+};
+
+const main = async () => {
+ const suiteName = process.argv[2];
+ const mode = process.argv[3] || "check";
+
+ if (!suiteName || suiteName.startsWith("-")) {
+ console.error(`\n ${usage("", "[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;
+ }
+
+ // Opens the SSH tunnel when configured, so a workstation run needs no manual forward.
+ const status = await db.checkAvailability({ 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") {
+ const code = await applySqlFile(client, suite.sqlFiles[mode]);
+ 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);
+ });