diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3bf923f3e82095d5184f4131e4dcd725dd847262..5cb605d57647a7083c60cb64af9db563b906601c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -29,18 +29,15 @@ cache: migrations: extends: .before_script_template script: - - source venv/bin/activate - ./manage.py makemigrations --dry-run --check test: extends: .before_script_template script: - - source venv/bin/activate - echo "GRANT ALL on *.* to '${MYSQL_USER}';"| mysql -u root --password="${MYSQL_ROOT_PASSWORD}" -h mysql - pip install pytest-cov unittest-xml-reporting - coverage run --source='.' manage.py test --settings AKPlanning.settings_ci after_script: - - source venv/bin/activate - coverage report - coverage xml coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/' diff --git a/AKDashboard/locale/de_DE/LC_MESSAGES/django.po b/AKDashboard/locale/de_DE/LC_MESSAGES/django.po index feb56e89e977b73e6bc069f546008f063f1fa290..75b92633f39f1c658d216594c9fc6516a0c250f8 100644 --- a/AKDashboard/locale/de_DE/LC_MESSAGES/django.po +++ b/AKDashboard/locale/de_DE/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-01-01 17:28+0100\n" +"POT-Creation-Date: 2025-02-27 15:13+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" @@ -113,22 +113,22 @@ msgstr "AK-Einreichung" msgid "AK History" msgstr "AK-Verlauf" -#: AKDashboard/views.py:69 +#: AKDashboard/views.py:70 #, python-format msgid "New AK: %(ak)s." msgstr "Neuer AK: %(ak)s." -#: AKDashboard/views.py:72 +#: AKDashboard/views.py:73 #, python-format msgid "AK \"%(ak)s\" edited." msgstr "AK \"%(ak)s\" bearbeitet." -#: AKDashboard/views.py:75 +#: AKDashboard/views.py:76 #, python-format msgid "AK \"%(ak)s\" deleted." msgstr "AK \"%(ak)s\" gelöscht." -#: AKDashboard/views.py:90 +#: AKDashboard/views.py:91 #, python-format msgid "AK \"%(ak)s\" (re-)scheduled." msgstr "AK \"%(ak)s\" (um-)geplant." diff --git a/AKDashboard/tests.py b/AKDashboard/tests.py index 0cd3e62690f58a6d1d76487f19bf17a10a6a5c83..59328adf517d22a1793df63f67782254b0958f94 100644 --- a/AKDashboard/tests.py +++ b/AKDashboard/tests.py @@ -7,7 +7,7 @@ from django.utils.timezone import now from AKDashboard.models import DashboardButton from AKModel.models import AK, AKCategory, Event -from AKModel.tests import BasicViewTests +from AKModel.tests.test_views import BasicViewTests class DashboardTests(TestCase): diff --git a/AKModel/availability/forms.py b/AKModel/availability/forms.py index 994949a8ef98eadad4eef68b5a94c3abfdf9979b..24a7c4f031974cad0c89ddb6afebf86a485a08fd 100644 --- a/AKModel/availability/forms.py +++ b/AKModel/availability/forms.py @@ -183,7 +183,7 @@ class AvailabilitiesFormMixin(forms.Form): for avail in availabilities: setattr(avail, reference_name, instance.id) - def _replace_availabilities(self, instance, availabilities: [Availability]): + def _replace_availabilities(self, instance, availabilities: list[Availability]): """ Replace the existing list of availabilities belonging to an entity with a new, updated one diff --git a/AKModel/availability/models.py b/AKModel/availability/models.py index 7ce794dcda52fcbde462584379e1447ad2b124f2..e2a64b225d2ca4a3da117003e29f5ff399a76976 100644 --- a/AKModel/availability/models.py +++ b/AKModel/availability/models.py @@ -151,9 +151,12 @@ class Availability(models.Model): if not other.overlaps(self, strict=False): raise Exception('Only overlapping Availabilities can be merged.') - return Availability( + avail = Availability( start=min(self.start, other.start), end=max(self.end, other.end) ) + if self.event == other.event: + avail.event = self.event + return avail def __or__(self, other: 'Availability') -> 'Availability': """Performs the merge operation: ``availability1 | availability2``""" @@ -168,9 +171,12 @@ class Availability(models.Model): if not other.overlaps(self, False): raise Exception('Only overlapping Availabilities can be intersected.') - return Availability( + avail = Availability( start=max(self.start, other.start), end=min(self.end, other.end) ) + if self.event == other.event: + avail.event = self.event + return avail def __and__(self, other: 'Availability') -> 'Availability': """Performs the intersect operation: ``availability1 & @@ -247,7 +253,14 @@ class Availability(models.Model): f'{self.end.astimezone(self.event.timezone).strftime("%a %H:%M")}') @classmethod - def with_event_length(cls, event, person=None, room=None, ak=None, ak_category=None): + def with_event_length( + cls, + event: Event, + person: AKOwner | None = None, + room: Room | None = None, + ak: AK | None = None, + ak_category: AKCategory | None = None, + ) -> "Availability": """ Create an availability covering exactly the time between event start and event end. Can e.g., be used to create default availabilities. @@ -267,6 +280,30 @@ class Availability(models.Model): return Availability(start=timeframe_start, end=timeframe_end, event=event, person=person, room=room, ak=ak, ak_category=ak_category) + def is_covered(self, availabilities: List['Availability']): + """Check if list of availibilities cover this object. + + :param availabilities: availabilities to check. + :return: whether the availabilities cover full event. + :rtype: bool + """ + avail_union = Availability.union(availabilities) + return any(avail.contains(self) for avail in avail_union) + + @classmethod + def is_event_covered(cls, event: Event, availabilities: List['Availability']) -> bool: + """Check if list of availibilities cover whole event. + + :param event: event to check. + :param availabilities: availabilities to check. + :return: whether the availabilities cover full event. + :rtype: bool + """ + # NOTE: Cannot use `Availability.with_event_length` as its end is the + # event end + 1 day + full_event = Availability(event=event, start=event.start, end=event.end) + return full_event.is_covered(availabilities) + class Meta: verbose_name = _('Availability') verbose_name_plural = _('Availabilities') diff --git a/AKModel/fixtures/model.json b/AKModel/fixtures/model.json index 3ef181bcb612f7eb3708077069f597a90ef44a7e..7694f2c901b0a056025fcba292b26b9b66af7f19 100644 --- a/AKModel/fixtures/model.json +++ b/AKModel/fixtures/model.json @@ -93,7 +93,7 @@ "model": "AKModel.akcategory", "pk": 1, "fields": { - "name": "Spa▀", + "name": "Spaß", "color": "275246", "description": "", "present_by_default": true, @@ -115,7 +115,7 @@ "model": "AKModel.akcategory", "pk": 3, "fields": { - "name": "Spa▀/Kultur", + "name": "Spaß/Kultur", "color": "333333", "description": "", "present_by_default": true, @@ -437,6 +437,62 @@ ] } }, +{ + "model": "AKModel.ak", + "pk": 4, + "fields": { + "name": "Test AK fixed slots", + "short_name": "testfixed", + "description": "", + "link": "", + "protocol_link": "", + "category": 4, + "track": null, + "reso": false, + "present": true, + "notes": "", + "interest": -1, + "interest_counter": 0, + "include_in_export": false, + "event": 2, + "owners": [ + 1 + ], + "requirements": [ + 3 + ], + "conflicts": [], + "prerequisites": [] + } +}, +{ + "model": "AKModel.ak", + "pk": 5, + "fields": { + "name": "Test AK Ernst", + "short_name": "testernst", + "description": "", + "link": "", + "protocol_link": "", + "category": 2, + "track": null, + "reso": false, + "present": true, + "notes": "", + "interest": -1, + "interest_counter": 0, + "include_in_export": false, + "event": 1, + "owners": [ + 3 + ], + "requirements": [ + 2 + ], + "conflicts": [], + "prerequisites": [] + } +}, { "model": "AKModel.room", "pk": 1, @@ -461,6 +517,19 @@ "properties": [] } }, +{ + "model": "AKModel.room", + "pk": 3, + "fields": { + "name": "BBB Session 1", + "location": "", + "capacity": -1, + "event": 1, + "properties": [ + 2 + ] + } +}, { "model": "AKModel.akslot", "pk": 1, @@ -526,6 +595,58 @@ "updated": "2022-12-02T12:23:11.856Z" } }, +{ + "model": "AKModel.akslot", + "pk": 6, + "fields": { + "ak": 4, + "room": null, + "start": "2020-11-08T18:30:00Z", + "duration": "2.00", + "fixed": true, + "event": 2, + "updated": "2022-12-02T12:23:11.856Z" + } +}, +{ + "model": "AKModel.akslot", + "pk": 7, + "fields": { + "ak": 4, + "room": 2, + "start": null, + "duration": "2.00", + "fixed": true, + "event": 2, + "updated": "2022-12-02T12:23:11.856Z" + } +}, +{ + "model": "AKModel.akslot", + "pk": 8, + "fields": { + "ak": 4, + "room": 2, + "start": "2020-11-07T16:00:00Z", + "duration": "2.00", + "fixed": true, + "event": 2, + "updated": "2022-12-02T12:23:11.856Z" + } +}, +{ + "model": "AKModel.akslot", + "pk": 9, + "fields": { + "ak": 5, + "room": null, + "start": null, + "duration": "2.00", + "fixed": false, + "event": 1, + "updated": "2022-12-02T12:23:11.856Z" + } +}, { "model": "AKModel.constraintviolation", "pk": 1, @@ -669,5 +790,71 @@ "start": "2020-11-07T18:30:00Z", "end": "2020-11-07T21:30:00Z" } +}, +{ + "model": "AKModel.availability", + "pk": 7, + "fields": { + "event": 1, + "person": null, + "room": null, + "ak": 5, + "ak_category": null, + "start": "2020-10-01T17:41:22Z", + "end": "2020-10-04T17:41:30Z" + } +}, +{ + "model": "AKModel.availability", + "pk": 8, + "fields": { + "event": 1, + "person": null, + "room": 3, + "ak": null, + "ak_category": null, + "start": "2020-10-01T17:41:22Z", + "end": "2020-10-04T17:41:30Z" + } +}, +{ + "model": "AKModel.defaultslot", + "pk": 1, + "fields": { + "event": 2, + "start": "2020-11-07T08:00:00Z", + "end": "2020-11-07T12:00:00Z", + "primary_categories": [5] + } +}, +{ + "model": "AKModel.defaultslot", + "pk": 2, + "fields": { + "event": 2, + "start": "2020-11-07T14:00:00Z", + "end": "2020-11-07T17:00:00Z", + "primary_categories": [4] + } +}, +{ + "model": "AKModel.defaultslot", + "pk": 3, + "fields": { + "event": 2, + "start": "2020-11-08T08:00:00Z", + "end": "2020-11-08T19:00:00Z", + "primary_categories": [4, 5] + } +}, +{ + "model": "AKModel.defaultslot", + "pk": 4, + "fields": { + "event": 2, + "start": "2020-11-09T17:00:00Z", + "end": "2020-11-10T01:00:00Z", + "primary_categories": [4, 5, 3] + } } ] diff --git a/AKModel/forms.py b/AKModel/forms.py index be4929c4fd8c8af10eaafecd00c2fa9295e07284..9ec3d24ec5f73148be7f135b8aa9f97965b3a4cc 100644 --- a/AKModel/forms.py +++ b/AKModel/forms.py @@ -281,3 +281,13 @@ class RoomFormWithAvailabilities(AvailabilitiesFormMixin, RoomForm): # Filter possible values for m2m when event is specified if hasattr(self.instance, "event") and self.instance.event is not None: self.fields["properties"].queryset = AKRequirement.objects.filter(event=self.instance.event) + + +class JSONScheduleImportForm(AdminIntermediateForm): + """Form to import an AK schedule from a json file.""" + json_data = forms.CharField( + required=True, + widget=forms.Textarea, + label=_("JSON data"), + help_text=_("JSON data from the scheduling solver"), + ) diff --git a/AKModel/locale/de_DE/LC_MESSAGES/django.po b/AKModel/locale/de_DE/LC_MESSAGES/django.po index c44a1fab5c315f6ae5193186726c0b651c87d54b..9ef95b03bc63a8bf36540a2e4b674d6c9e2b259a 100644 --- a/AKModel/locale/de_DE/LC_MESSAGES/django.po +++ b/AKModel/locale/de_DE/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-03-03 20:47+0100\n" +"POT-Creation-Date: 2025-03-04 14:49+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" @@ -25,18 +25,18 @@ msgstr "Status" msgid "Toggle plan visibility" msgstr "Plansichtbarkeit ändern" -#: AKModel/admin.py:112 AKModel/admin.py:123 AKModel/views/manage.py:138 +#: AKModel/admin.py:112 AKModel/admin.py:123 AKModel/views/manage.py:140 msgid "Publish plan" msgstr "Plan veröffentlichen" -#: AKModel/admin.py:115 AKModel/admin.py:131 AKModel/views/manage.py:151 +#: AKModel/admin.py:115 AKModel/admin.py:131 AKModel/views/manage.py:153 msgid "Unpublish plan" msgstr "Plan verbergen" -#: AKModel/admin.py:170 AKModel/models.py:396 AKModel/models.py:736 +#: AKModel/admin.py:170 AKModel/models.py:890 AKModel/models.py:1348 #: AKModel/templates/admin/AKModel/aks_by_user.html:12 #: AKModel/templates/admin/AKModel/status/event_aks.html:10 -#: AKModel/views/manage.py:73 AKModel/views/status.py:102 +#: AKModel/views/manage.py:75 AKModel/views/status.py:102 msgid "AKs" msgstr "AKs" @@ -60,11 +60,11 @@ msgstr "In Wiki-Syntax exportieren" msgid "Cannot export AKs from more than one event at the same time." msgstr "Kann nicht AKs von mehreren Events zur selben Zeit exportieren." -#: AKModel/admin.py:335 AKModel/views/ak.py:99 +#: AKModel/admin.py:335 AKModel/views/ak.py:120 msgid "Reset interest in AKs" msgstr "Interesse an AKs zurücksetzen" -#: AKModel/admin.py:345 AKModel/views/ak.py:114 +#: AKModel/admin.py:345 AKModel/views/ak.py:135 msgid "Reset AKs' interest counters" msgstr "Interessenszähler der AKs zurücksetzen" @@ -72,15 +72,15 @@ msgstr "Interessenszähler der AKs zurücksetzen" msgid "AK Details" msgstr "AK-Details" -#: AKModel/admin.py:520 AKModel/views/manage.py:99 +#: AKModel/admin.py:520 AKModel/views/manage.py:101 msgid "Mark Constraint Violations as manually resolved" msgstr "Markiere Constraintverletzungen als manuell behoben" -#: AKModel/admin.py:529 AKModel/views/manage.py:112 +#: AKModel/admin.py:529 AKModel/views/manage.py:114 msgid "Set Constraint Violations to level \"violation\"" msgstr "Constraintverletzungen auf Level \"Violation\" setzen" -#: AKModel/admin.py:538 AKModel/views/manage.py:125 +#: AKModel/admin.py:538 AKModel/views/manage.py:127 msgid "Set Constraint Violations to level \"warning\"" msgstr "Constraintverletzungen auf Level \"Warning\" setzen" @@ -100,7 +100,7 @@ msgstr "Ausgewählte Benutzer*innen deaktivieren" msgid "The selected users have been deactivated." msgstr "Benutzer*innen deaktiviert" -#: AKModel/availability/forms.py:25 AKModel/availability/models.py:271 +#: AKModel/availability/forms.py:25 AKModel/availability/models.py:308 msgid "Availability" msgstr "Verfügbarkeit" @@ -125,19 +125,19 @@ msgstr "Die eingegebene Verfügbarkeit enthält ein ungültiges Datum." msgid "Please fill in your availabilities!" msgstr "Bitte Verfügbarkeiten eintragen!" -#: AKModel/availability/models.py:43 AKModel/models.py:71 AKModel/models.py:187 -#: AKModel/models.py:264 AKModel/models.py:283 AKModel/models.py:309 -#: AKModel/models.py:328 AKModel/models.py:386 AKModel/models.py:546 -#: AKModel/models.py:585 AKModel/models.py:675 AKModel/models.py:732 -#: AKModel/models.py:923 +#: AKModel/availability/models.py:43 AKModel/models.py:177 +#: AKModel/models.py:667 AKModel/models.py:744 AKModel/models.py:777 +#: AKModel/models.py:803 AKModel/models.py:822 AKModel/models.py:880 +#: AKModel/models.py:1039 AKModel/models.py:1116 AKModel/models.py:1286 +#: AKModel/models.py:1344 AKModel/models.py:1536 msgid "Event" msgstr "Event" -#: AKModel/availability/models.py:44 AKModel/models.py:188 -#: AKModel/models.py:265 AKModel/models.py:284 AKModel/models.py:310 -#: AKModel/models.py:329 AKModel/models.py:387 AKModel/models.py:547 -#: AKModel/models.py:586 AKModel/models.py:676 AKModel/models.py:733 -#: AKModel/models.py:924 +#: AKModel/availability/models.py:44 AKModel/models.py:668 +#: AKModel/models.py:745 AKModel/models.py:778 AKModel/models.py:804 +#: AKModel/models.py:823 AKModel/models.py:881 AKModel/models.py:1040 +#: AKModel/models.py:1117 AKModel/models.py:1287 AKModel/models.py:1345 +#: AKModel/models.py:1537 msgid "Associated event" msgstr "Zugehöriges Event" @@ -149,8 +149,8 @@ msgstr "Person" msgid "Person whose availability this is" msgstr "Person deren Verfügbarkeit hier abgebildet wird" -#: AKModel/availability/models.py:61 AKModel/models.py:550 -#: AKModel/models.py:575 AKModel/models.py:742 +#: AKModel/availability/models.py:61 AKModel/models.py:1043 +#: AKModel/models.py:1106 AKModel/models.py:1354 msgid "Room" msgstr "Raum" @@ -158,8 +158,8 @@ msgstr "Raum" msgid "Room whose availability this is" msgstr "Raum dessen Verfügbarkeit hier abgebildet wird" -#: AKModel/availability/models.py:70 AKModel/models.py:395 -#: AKModel/models.py:574 AKModel/models.py:670 +#: AKModel/availability/models.py:70 AKModel/models.py:889 +#: AKModel/models.py:1105 AKModel/models.py:1281 msgid "AK" msgstr "AK" @@ -167,8 +167,8 @@ msgstr "AK" msgid "AK whose availability this is" msgstr "Verfügbarkeiten" -#: AKModel/availability/models.py:79 AKModel/models.py:268 -#: AKModel/models.py:748 +#: AKModel/availability/models.py:79 AKModel/models.py:748 +#: AKModel/models.py:1360 msgid "AK Category" msgstr "AK-Kategorie" @@ -176,7 +176,7 @@ msgstr "AK-Kategorie" msgid "AK Category whose availability this is" msgstr "AK-Kategorie, deren Verfügbarkeit hier abgebildet wird" -#: AKModel/availability/models.py:272 +#: AKModel/availability/models.py:309 msgid "Availabilities" msgstr "Verfügbarkeiten" @@ -242,7 +242,7 @@ msgstr "" "fürWünsche markieren, z.B. um während der Präsentation auf einem Touchscreen " "ausgefüllt zu werden?" -#: AKModel/forms.py:198 AKModel/models.py:917 +#: AKModel/forms.py:198 AKModel/models.py:1530 msgid "Default Slots" msgstr "Standardslots" @@ -281,7 +281,15 @@ msgstr "Standardverfügbarkeiten für alle Räume anlegen?" msgid "CSV must contain a name column" msgstr "CSV muss eine name-Spalte enthalten" -#: AKModel/metaviews/admin.py:156 AKModel/models.py:40 +#: AKModel/forms.py:291 +msgid "JSON data" +msgstr "JSON-Daten" + +#: AKModel/forms.py:292 +msgid "JSON data from the scheduling solver" +msgstr "JSON-Daten, die der scheduling-solver produziert hat" + +#: AKModel/metaviews/admin.py:156 AKModel/models.py:140 msgid "Start" msgstr "Start" @@ -306,75 +314,75 @@ msgstr "Aktivieren?" msgid "Finish" msgstr "Abschluss" -#: AKModel/models.py:21 +#: AKModel/models.py:24 msgid "May not contain quotation marks" msgstr "Darf keine Anführungszeichen enthalten" -#: AKModel/models.py:24 +#: AKModel/models.py:27 msgid "Must contain at least one letter or digit" msgstr "Muss mindestens einen Buchstaben oder eine Ziffer enthalten" -#: AKModel/models.py:31 AKModel/models.py:256 AKModel/models.py:280 -#: AKModel/models.py:307 AKModel/models.py:326 AKModel/models.py:344 -#: AKModel/models.py:536 +#: AKModel/models.py:131 AKModel/models.py:736 AKModel/models.py:774 +#: AKModel/models.py:801 AKModel/models.py:820 AKModel/models.py:838 +#: AKModel/models.py:1031 msgid "Name" msgstr "Name" -#: AKModel/models.py:32 +#: AKModel/models.py:132 msgid "Name or iteration of the event" msgstr "Name oder Iteration des Events" -#: AKModel/models.py:33 +#: AKModel/models.py:133 msgid "Short Form" msgstr "Kurzer Name" -#: AKModel/models.py:34 +#: AKModel/models.py:134 msgid "Short name of letters/numbers/dots/dashes/underscores used in URLs." msgstr "" "Kurzname bestehend aus Buchstaben, Nummern, Punkten und Unterstrichen zur " "Nutzung in URLs" -#: AKModel/models.py:36 +#: AKModel/models.py:136 msgid "Place" msgstr "Ort" -#: AKModel/models.py:37 +#: AKModel/models.py:137 msgid "City etc. the event takes place in" msgstr "Stadt o.ä. in der das Event stattfindet" -#: AKModel/models.py:39 +#: AKModel/models.py:139 msgid "Time Zone" msgstr "Zeitzone" -#: AKModel/models.py:39 +#: AKModel/models.py:139 msgid "Time Zone where this event takes place in" msgstr "Zeitzone in der das Event stattfindet" -#: AKModel/models.py:40 +#: AKModel/models.py:140 msgid "Time the event begins" msgstr "Zeit zu der das Event beginnt" -#: AKModel/models.py:41 +#: AKModel/models.py:141 msgid "End" msgstr "Ende" -#: AKModel/models.py:41 +#: AKModel/models.py:141 msgid "Time the event ends" msgstr "Zeit zu der das Event endet" -#: AKModel/models.py:42 +#: AKModel/models.py:142 msgid "Resolution Deadline" msgstr "Resolutionsdeadline" -#: AKModel/models.py:43 +#: AKModel/models.py:143 msgid "When should AKs with intention to submit a resolution be done?" msgstr "Wann sollen AKs mit Resolutionsabsicht stattgefunden haben?" -#: AKModel/models.py:45 +#: AKModel/models.py:145 msgid "Interest Window Start" msgstr "Beginn Interessensbekundung" -#: AKModel/models.py:47 +#: AKModel/models.py:147 msgid "" "Opening time for expression of interest. When left blank, no interest " "indication will be possible." @@ -382,71 +390,83 @@ msgstr "" "Öffnungszeitpunkt für die Angabe von Interesse an AKs.Wenn das Feld leer " "bleibt, wird keine Abgabe von Interesse möglich sein." -#: AKModel/models.py:49 +#: AKModel/models.py:150 msgid "Interest Window End" msgstr "Ende Interessensbekundung" -#: AKModel/models.py:50 +#: AKModel/models.py:151 msgid "Closing time for expression of interest." msgstr "Öffnungszeitpunkt für die Angabe von Interesse an AKs." -#: AKModel/models.py:52 +#: AKModel/models.py:153 msgid "Public event" msgstr "Öffentliches Event" -#: AKModel/models.py:53 +#: AKModel/models.py:154 msgid "Show this event on overview page." msgstr "Zeige dieses Event auf der Übersichtseite an" -#: AKModel/models.py:55 +#: AKModel/models.py:156 msgid "Active State" msgstr "Aktiver Status" -#: AKModel/models.py:55 +#: AKModel/models.py:156 msgid "Marks currently active events" msgstr "Markiert aktuell aktive Events" -#: AKModel/models.py:56 +#: AKModel/models.py:157 msgid "Plan Hidden" msgstr "Plan verborgen" -#: AKModel/models.py:56 +#: AKModel/models.py:157 msgid "Hides plan for non-staff users" msgstr "Verbirgt den Plan für Nutzer*innen ohne erweiterte Rechte" -#: AKModel/models.py:58 +#: AKModel/models.py:159 msgid "Plan published at" msgstr "Plan veröffentlicht am/um" -#: AKModel/models.py:59 +#: AKModel/models.py:160 msgid "Timestamp at which the plan was published" msgstr "Zeitpunkt, zu dem der Plan veröffentlicht wurde" -#: AKModel/models.py:61 +#: AKModel/models.py:162 msgid "Base URL" msgstr "URL-Prefix" -#: AKModel/models.py:61 +#: AKModel/models.py:162 msgid "Prefix for wiki link construction" msgstr "Prefix für die automatische Generierung von Wiki-Links" -#: AKModel/models.py:62 +#: AKModel/models.py:163 msgid "Wiki Export Template Name" msgstr "Wiki-Export Templatename" -#: AKModel/models.py:63 +#: AKModel/models.py:164 msgid "Default Slot Length" msgstr "Standardslotlänge" -#: AKModel/models.py:64 +#: AKModel/models.py:165 msgid "Default length in hours that is assumed for AKs in this event." msgstr "Standardlänge von Slots (in Stunden) für dieses Event" -#: AKModel/models.py:66 +#: AKModel/models.py:166 +msgid "Export Slot Length" +msgstr "Export-Slotlänge" + +#: AKModel/models.py:168 +msgid "" +"Slot duration in hours that is used in the timeslot discretization, when " +"this event is exported for the solver." +msgstr "" +"Länge von Slots (in Stunden) in der Zeitslot-Diskretisierung beim JSON-" +"Export dieses Events." + +#: AKModel/models.py:172 msgid "Contact email address" msgstr "E-Mail Kontaktadresse" -#: AKModel/models.py:67 +#: AKModel/models.py:173 msgid "" "An email address that is displayed on every page and can be used for all " "kinds of questions" @@ -454,75 +474,108 @@ msgstr "" "Eine Mailadresse die auf jeder Seite angezeigt wird und für alle Arten von " "Fragen genutzt werden kann" -#: AKModel/models.py:72 +#: AKModel/models.py:178 msgid "Events" msgstr "Events" -#: AKModel/models.py:180 +#: AKModel/models.py:455 +#, python-brace-format +msgid "AK {ak_name} is not assigned any timeslot by the solver" +msgstr "Dem AK {ak_name} wurde vom Solver kein Zeitslot zugewiesen" + +#: AKModel/models.py:465 +#, python-brace-format +msgid "" +"Duration of AK {ak_name} assigned by solver ({solver_duration} hours) is " +"less than the duration required by the slot ({slot_duration} hours)" +msgstr "" +"Die dem AK {ak_name} vom Solver zugewiesene Dauer ({solver_duration} " +"Stunden) ist kürzer als die aktuell vorgesehene Dauer des Slots " +"({slot_duration} Stunden)" + +#: AKModel/models.py:479 +#, python-brace-format +msgid "" +"Fixed AK {ak_name} assigned by solver to room {solver_room} is fixed to room " +"{slot_room}" +msgstr "" +"Dem fix geplanten AK {ak_name} wurde vom Solver Raum {solver_room} " +"zugewiesen, dabei ist der AK bereits fix in Raum {slot_room} eingeplant." + +#: AKModel/models.py:490 +#, python-brace-format +msgid "" +"Fixed AK {ak_name} assigned by solver to start at {solver_start} is fixed to " +"start at {slot_start}" +msgstr "" +"Dem fix geplanten AK {ak_name} wurde vom Solver die Startzeit {solver_start} " +"zugewiesen, dabei ist der AK bereits für {slot_start} eingeplant." + +#: AKModel/models.py:660 msgid "Nickname" msgstr "Spitzname" -#: AKModel/models.py:182 +#: AKModel/models.py:662 msgid "Name to identify an AK owner by" msgstr "Name, durch den eine AK-Leitung identifiziert wird" -#: AKModel/models.py:183 +#: AKModel/models.py:663 msgid "Slug" msgstr "Slug" -#: AKModel/models.py:183 +#: AKModel/models.py:663 msgid "Slug for URL generation" msgstr "Slug für URL-Generierung" -#: AKModel/models.py:184 +#: AKModel/models.py:664 msgid "Institution" msgstr "Instutution" -#: AKModel/models.py:184 +#: AKModel/models.py:664 msgid "Uni etc." msgstr "Universität o.ä." -#: AKModel/models.py:185 AKModel/models.py:355 +#: AKModel/models.py:665 AKModel/models.py:849 msgid "Web Link" msgstr "Internet Link" -#: AKModel/models.py:185 +#: AKModel/models.py:665 msgid "Link to Homepage" msgstr "Link zu Homepage oder Webseite" -#: AKModel/models.py:191 AKModel/models.py:741 +#: AKModel/models.py:671 AKModel/models.py:1353 msgid "AK Owner" msgstr "AK-Leitung" -#: AKModel/models.py:192 +#: AKModel/models.py:672 msgid "AK Owners" msgstr "AK-Leitungen" -#: AKModel/models.py:256 +#: AKModel/models.py:736 msgid "Name of the AK Category" msgstr "Name der AK-Kategorie" -#: AKModel/models.py:257 AKModel/models.py:281 +#: AKModel/models.py:737 AKModel/models.py:775 msgid "Color" msgstr "Farbe" -#: AKModel/models.py:257 AKModel/models.py:281 +#: AKModel/models.py:737 AKModel/models.py:775 msgid "Color for displaying" msgstr "Farbe für die Anzeige" -#: AKModel/models.py:258 AKModel/models.py:349 +#: AKModel/models.py:738 AKModel/models.py:843 msgid "Description" msgstr "Beschreibung" -#: AKModel/models.py:259 +#: AKModel/models.py:739 msgid "Short description of this AK Category" msgstr "Beschreibung der AK-Kategorie" -#: AKModel/models.py:260 +#: AKModel/models.py:740 msgid "Present by default" msgstr "Defaultmäßig präsentieren" -#: AKModel/models.py:261 +#: AKModel/models.py:741 msgid "" "Present AKs of this category by default if AK owner did not specify whether " "this AK should be presented?" @@ -530,152 +583,152 @@ msgstr "" "AKs dieser Kategorie standardmäßig vorstellen, wenn die Leitungen das für " "ihren AK nicht explizit spezifiziert haben?" -#: AKModel/models.py:269 +#: AKModel/models.py:749 msgid "AK Categories" msgstr "AK-Kategorien" -#: AKModel/models.py:280 +#: AKModel/models.py:774 msgid "Name of the AK Track" msgstr "Name des AK-Tracks" -#: AKModel/models.py:287 +#: AKModel/models.py:781 msgid "AK Track" msgstr "AK-Track" -#: AKModel/models.py:288 +#: AKModel/models.py:782 msgid "AK Tracks" msgstr "AK-Tracks" -#: AKModel/models.py:307 +#: AKModel/models.py:801 msgid "Name of the Requirement" msgstr "Name der Anforderung" -#: AKModel/models.py:313 AKModel/models.py:745 +#: AKModel/models.py:807 AKModel/models.py:1357 msgid "AK Requirement" msgstr "AK-Anforderung" -#: AKModel/models.py:314 +#: AKModel/models.py:808 msgid "AK Requirements" msgstr "AK-Anforderungen" -#: AKModel/models.py:326 +#: AKModel/models.py:820 msgid "Name describing the type" msgstr "Name, der den Typ beschreibt" -#: AKModel/models.py:332 +#: AKModel/models.py:826 msgid "AK Type" msgstr "AK Typ" -#: AKModel/models.py:333 +#: AKModel/models.py:827 msgid "AK Types" msgstr "AK-Typen" -#: AKModel/models.py:344 +#: AKModel/models.py:838 msgid "Name of the AK" msgstr "Name des AKs" -#: AKModel/models.py:346 +#: AKModel/models.py:840 msgid "Short Name" msgstr "Kurzer Name" -#: AKModel/models.py:348 +#: AKModel/models.py:842 msgid "Name displayed in the schedule" msgstr "Name zur Anzeige im AK-Plan" -#: AKModel/models.py:349 +#: AKModel/models.py:843 msgid "Description of the AK" msgstr "Beschreibung des AKs" -#: AKModel/models.py:351 +#: AKModel/models.py:845 msgid "Owners" msgstr "Leitungen" -#: AKModel/models.py:352 +#: AKModel/models.py:846 msgid "Those organizing the AK" msgstr "Menschen, die den AK organisieren und halten" -#: AKModel/models.py:355 +#: AKModel/models.py:849 msgid "Link to wiki page" msgstr "Link zur Wiki Seite" -#: AKModel/models.py:356 +#: AKModel/models.py:850 msgid "Protocol Link" msgstr "Protokolllink" -#: AKModel/models.py:356 +#: AKModel/models.py:850 msgid "Link to protocol" msgstr "Link zum Protokoll" -#: AKModel/models.py:358 +#: AKModel/models.py:852 msgid "Category" msgstr "Kategorie" -#: AKModel/models.py:359 +#: AKModel/models.py:853 msgid "Category of the AK" msgstr "Kategorie des AKs" -#: AKModel/models.py:360 +#: AKModel/models.py:854 msgid "Types" msgstr "Typen" -#: AKModel/models.py:361 +#: AKModel/models.py:855 msgid "This AK is" msgstr "Dieser AK ist" -#: AKModel/models.py:362 +#: AKModel/models.py:856 msgid "Track" msgstr "Track" -#: AKModel/models.py:363 +#: AKModel/models.py:857 msgid "Track the AK belongs to" msgstr "Track zu dem der AK gehört" -#: AKModel/models.py:365 +#: AKModel/models.py:859 msgid "Resolution Intention" msgstr "Resolutionsabsicht" -#: AKModel/models.py:366 +#: AKModel/models.py:860 msgid "Intends to submit a resolution" msgstr "Beabsichtigt eine Resolution einzureichen" -#: AKModel/models.py:367 +#: AKModel/models.py:861 msgid "Present this AK" msgstr "AK präsentieren" -#: AKModel/models.py:368 +#: AKModel/models.py:862 msgid "Present results of this AK" msgstr "Die Ergebnisse dieses AKs vorstellen" -#: AKModel/models.py:370 AKModel/views/status.py:167 +#: AKModel/models.py:864 AKModel/views/status.py:175 msgid "Requirements" msgstr "Anforderungen" -#: AKModel/models.py:371 +#: AKModel/models.py:865 msgid "AK's Requirements" msgstr "Anforderungen des AKs" -#: AKModel/models.py:373 +#: AKModel/models.py:867 msgid "Conflicting AKs" msgstr "AK-Konflikte" -#: AKModel/models.py:374 +#: AKModel/models.py:868 msgid "AKs that conflict and thus must not take place at the same time" msgstr "" "AKs, die Konflikte haben und deshalb nicht gleichzeitig stattfinden dürfen" -#: AKModel/models.py:375 +#: AKModel/models.py:869 msgid "Prerequisite AKs" msgstr "Vorausgesetzte AKs" -#: AKModel/models.py:376 +#: AKModel/models.py:870 msgid "AKs that should precede this AK in the schedule" msgstr "AKs die im AK-Plan vor diesem AK stattfinden müssen" -#: AKModel/models.py:378 +#: AKModel/models.py:872 msgid "Organizational Notes" msgstr "Notizen zur Organisation" -#: AKModel/models.py:379 +#: AKModel/models.py:873 msgid "" "Notes to organizers. These are public. For private notes, please use the " "button for private messages on the detail page of this AK (after creation/" @@ -685,291 +738,291 @@ msgstr "" "Anmerkungen bitte den Button für Direktnachrichten verwenden (nach dem " "Anlegen/Bearbeiten)." -#: AKModel/models.py:382 +#: AKModel/models.py:876 msgid "Interest" msgstr "Interesse" -#: AKModel/models.py:382 +#: AKModel/models.py:876 msgid "Expected number of people" msgstr "Erwartete Personenzahl" -#: AKModel/models.py:383 +#: AKModel/models.py:877 msgid "Interest Counter" msgstr "Interessenszähler" -#: AKModel/models.py:384 +#: AKModel/models.py:878 msgid "People who have indicated interest online" msgstr "Anzahl Personen, die online Interesse bekundet haben" -#: AKModel/models.py:389 +#: AKModel/models.py:883 msgid "Export?" msgstr "Export?" -#: AKModel/models.py:390 +#: AKModel/models.py:884 msgid "Include AK in wiki export?" msgstr "AK bei Wiki-Export berücksichtigen?" -#: AKModel/models.py:536 +#: AKModel/models.py:1031 msgid "Name or number of the room" msgstr "Name oder Nummer des Raums" -#: AKModel/models.py:537 +#: AKModel/models.py:1032 msgid "Location" msgstr "Ort" -#: AKModel/models.py:538 +#: AKModel/models.py:1033 msgid "Name or number of the location" msgstr "Name oder Nummer des Ortes" -#: AKModel/models.py:539 +#: AKModel/models.py:1034 msgid "Capacity" msgstr "Kapazität" -#: AKModel/models.py:540 +#: AKModel/models.py:1035 msgid "Maximum number of people (-1 for unlimited)." msgstr "Maximale Personenzahl (-1 wenn unbeschränkt)." -#: AKModel/models.py:541 +#: AKModel/models.py:1036 msgid "Properties" msgstr "Eigenschaften" -#: AKModel/models.py:542 +#: AKModel/models.py:1037 msgid "AK requirements fulfilled by the room" msgstr "AK-Anforderungen, die dieser Raum erfüllt" -#: AKModel/models.py:551 AKModel/views/status.py:59 +#: AKModel/models.py:1044 AKModel/views/status.py:59 msgid "Rooms" msgstr "Räume" -#: AKModel/models.py:574 +#: AKModel/models.py:1105 msgid "AK being mapped" msgstr "AK, der zugeordnet wird" -#: AKModel/models.py:576 +#: AKModel/models.py:1107 msgid "Room the AK will take place in" msgstr "Raum in dem der AK stattfindet" -#: AKModel/models.py:577 AKModel/models.py:920 +#: AKModel/models.py:1108 AKModel/models.py:1533 msgid "Slot Begin" msgstr "Beginn des Slots" -#: AKModel/models.py:577 AKModel/models.py:920 +#: AKModel/models.py:1108 AKModel/models.py:1533 msgid "Time and date the slot begins" msgstr "Zeit und Datum zu der der AK beginnt" -#: AKModel/models.py:579 +#: AKModel/models.py:1110 msgid "Duration" msgstr "Dauer" -#: AKModel/models.py:580 +#: AKModel/models.py:1111 msgid "Length in hours" msgstr "Länge in Stunden" -#: AKModel/models.py:582 +#: AKModel/models.py:1113 msgid "Scheduling fixed" msgstr "Planung fix" -#: AKModel/models.py:583 +#: AKModel/models.py:1114 msgid "Length and time of this AK should not be changed" msgstr "Dauer und Zeit dieses AKs sollten nicht verändert werden" -#: AKModel/models.py:588 +#: AKModel/models.py:1119 msgid "Last update" msgstr "Letzte Aktualisierung" -#: AKModel/models.py:591 +#: AKModel/models.py:1122 msgid "AK Slot" msgstr "AK-Slot" -#: AKModel/models.py:592 AKModel/models.py:738 +#: AKModel/models.py:1123 AKModel/models.py:1350 msgid "AK Slots" msgstr "AK-Slot" -#: AKModel/models.py:614 AKModel/models.py:623 +#: AKModel/models.py:1145 AKModel/models.py:1154 msgid "Not scheduled yet" msgstr "Noch nicht geplant" -#: AKModel/models.py:671 +#: AKModel/models.py:1282 msgid "AK this message belongs to" msgstr "AK zu dem die Nachricht gehört" -#: AKModel/models.py:672 +#: AKModel/models.py:1283 msgid "Message text" msgstr "Nachrichtentext" -#: AKModel/models.py:673 +#: AKModel/models.py:1284 msgid "Message to the organizers. This is not publicly visible." msgstr "" "Nachricht an die Organisator*innen. Diese ist nicht öffentlich sichtbar." -#: AKModel/models.py:677 +#: AKModel/models.py:1288 msgid "Resolved" msgstr "Erledigt" -#: AKModel/models.py:678 +#: AKModel/models.py:1289 msgid "This message has been resolved (no further action needed)" msgstr "" "Diese Nachricht wurde vollständig bearbeitet (keine weiteren Aktionen " "notwendig)" -#: AKModel/models.py:681 +#: AKModel/models.py:1292 msgid "AK Orga Message" msgstr "AK-Organachricht" -#: AKModel/models.py:682 +#: AKModel/models.py:1293 msgid "AK Orga Messages" msgstr "AK-Organachrichten" -#: AKModel/models.py:699 +#: AKModel/models.py:1311 msgid "Constraint Violation" msgstr "Constraintverletzung" -#: AKModel/models.py:700 +#: AKModel/models.py:1312 msgid "Constraint Violations" msgstr "Constraintverletzungen" -#: AKModel/models.py:707 +#: AKModel/models.py:1319 msgid "Owner has two parallel slots" msgstr "Leitung hat zwei Slots parallel" -#: AKModel/models.py:708 +#: AKModel/models.py:1320 msgid "AK Slot was scheduled outside the AK's availabilities" msgstr "AK Slot wurde außerhalb der Verfügbarkeit des AKs platziert" -#: AKModel/models.py:709 +#: AKModel/models.py:1321 msgid "Room has two AK slots scheduled at the same time" msgstr "Raum hat zwei AK Slots gleichzeitig" -#: AKModel/models.py:710 +#: AKModel/models.py:1322 msgid "Room does not satisfy the requirement of the scheduled AK" msgstr "Room erfüllt die Anforderungen des platzierten AKs nicht" -#: AKModel/models.py:711 +#: AKModel/models.py:1323 msgid "AK Slot is scheduled at the same time as an AK listed as a conflict" msgstr "" "AK Slot wurde wurde zur gleichen Zeit wie ein Konflikt des AKs platziert" -#: AKModel/models.py:712 +#: AKModel/models.py:1324 msgid "AK Slot is scheduled before an AK listed as a prerequisite" msgstr "AK Slot wurde vor einem als Voraussetzung gelisteten AK platziert" -#: AKModel/models.py:714 +#: AKModel/models.py:1326 msgid "" "AK Slot for AK with intention to submit a resolution is scheduled after " "resolution deadline" msgstr "" "AK Slot eines AKs mit Resoabsicht wurde nach der Resodeadline platziert" -#: AKModel/models.py:715 +#: AKModel/models.py:1327 msgid "AK Slot in a category is outside that categories availabilities" msgstr "AK Slot wurde außerhalb der Verfügbarkeiten seiner Kategorie" -#: AKModel/models.py:716 +#: AKModel/models.py:1328 msgid "Two AK Slots for the same AK scheduled at the same time" msgstr "Zwei AK Slots eines AKs wurden zur selben Zeit platziert" -#: AKModel/models.py:717 +#: AKModel/models.py:1329 msgid "Room does not have enough space for interest in scheduled AK Slot" msgstr "Room hat nicht genug Platz für das Interesse am geplanten AK-Slot" -#: AKModel/models.py:718 +#: AKModel/models.py:1330 msgid "AK Slot is scheduled outside the event's availabilities" msgstr "AK Slot wurde außerhalb der Verfügbarkeit des Events platziert" -#: AKModel/models.py:724 +#: AKModel/models.py:1336 msgid "Warning" msgstr "Warnung" -#: AKModel/models.py:725 +#: AKModel/models.py:1337 msgid "Violation" msgstr "Verletzung" -#: AKModel/models.py:727 +#: AKModel/models.py:1339 msgid "Type" msgstr "Art" -#: AKModel/models.py:728 +#: AKModel/models.py:1340 msgid "Type of violation, i.e. what kind of constraint was violated" msgstr "Art der Verletzung, gibt an welche Art Constraint verletzt wurde" -#: AKModel/models.py:729 +#: AKModel/models.py:1341 msgid "Level" msgstr "Level" -#: AKModel/models.py:730 +#: AKModel/models.py:1342 msgid "Severity level of the violation" msgstr "Schweregrad der Verletzung" -#: AKModel/models.py:737 +#: AKModel/models.py:1349 msgid "AK(s) belonging to this constraint" msgstr "AK(s), die zu diesem Constraint gehören" -#: AKModel/models.py:739 +#: AKModel/models.py:1351 msgid "AK Slot(s) belonging to this constraint" msgstr "AK Slot(s), die zu diesem Constraint gehören" -#: AKModel/models.py:741 +#: AKModel/models.py:1353 msgid "AK Owner belonging to this constraint" msgstr "AK Leitung(en), die zu diesem Constraint gehören" -#: AKModel/models.py:743 +#: AKModel/models.py:1355 msgid "Room belonging to this constraint" msgstr "Raum, der zu diesem Constraint gehört" -#: AKModel/models.py:746 +#: AKModel/models.py:1358 msgid "AK Requirement belonging to this constraint" msgstr "AK Anforderung, die zu diesem Constraint gehört" -#: AKModel/models.py:748 +#: AKModel/models.py:1360 msgid "AK Category belonging to this constraint" msgstr "AK Kategorie, di zu diesem Constraint gehört" -#: AKModel/models.py:750 +#: AKModel/models.py:1362 msgid "Comment" msgstr "Kommentar" -#: AKModel/models.py:750 +#: AKModel/models.py:1362 msgid "Comment or further details for this violation" msgstr "Kommentar oder weitere Details zu dieser Vereletzung" -#: AKModel/models.py:753 +#: AKModel/models.py:1365 msgid "Timestamp" msgstr "Timestamp" -#: AKModel/models.py:753 +#: AKModel/models.py:1365 msgid "Time of creation" msgstr "Zeitpunkt der ERstellung" -#: AKModel/models.py:754 +#: AKModel/models.py:1366 msgid "Manually Resolved" msgstr "Manuell behoben" -#: AKModel/models.py:755 +#: AKModel/models.py:1367 msgid "Mark this violation manually as resolved" msgstr "Markiere diese Verletzung manuell als behoben" -#: AKModel/models.py:782 AKModel/templates/admin/AKModel/aks_by_user.html:22 +#: AKModel/models.py:1394 AKModel/templates/admin/AKModel/aks_by_user.html:22 #: AKModel/templates/admin/AKModel/requirements_overview.html:27 msgid "Details" msgstr "Details" -#: AKModel/models.py:916 +#: AKModel/models.py:1529 msgid "Default Slot" msgstr "Standardslot" -#: AKModel/models.py:921 +#: AKModel/models.py:1534 msgid "Slot End" msgstr "Ende des Slots" -#: AKModel/models.py:921 +#: AKModel/models.py:1534 msgid "Time and date the slot ends" msgstr "Zeit und Datum zu der der Slot endet" -#: AKModel/models.py:926 +#: AKModel/models.py:1539 msgid "Primary categories" msgstr "Primäre Kategorien" -#: AKModel/models.py:927 +#: AKModel/models.py:1541 msgid "Categories that should be assigned to this slot primarily" msgstr "Kategorieren, die diesem Slot primär zugewiesen werden sollen" @@ -1090,7 +1143,7 @@ msgid "No AKs with this requirement" msgstr "Kein AK mit dieser Anforderung" #: AKModel/templates/admin/AKModel/requirements_overview.html:45 -#: AKModel/views/status.py:183 +#: AKModel/views/status.py:191 msgid "Add Requirement" msgstr "Anforderung hinzufügen" @@ -1176,43 +1229,47 @@ msgstr "Login" msgid "Register" msgstr "Registrieren" -#: AKModel/views/ak.py:17 +#: AKModel/views/ak.py:19 msgid "Requirements for Event" msgstr "Anforderungen für das Event" -#: AKModel/views/ak.py:34 +#: AKModel/views/ak.py:36 msgid "AK CSV Export" msgstr "AK-CSV-Export" -#: AKModel/views/ak.py:48 +#: AKModel/views/ak.py:49 +msgid "AK JSON Export" +msgstr "AK-JSON-Export" + +#: AKModel/views/ak.py:69 msgid "AK Wiki Export" msgstr "AK-Wiki-Export" -#: AKModel/views/ak.py:59 AKModel/views/manage.py:53 +#: AKModel/views/ak.py:80 AKModel/views/manage.py:55 msgid "Wishes" msgstr "Wünsche" -#: AKModel/views/ak.py:71 +#: AKModel/views/ak.py:92 msgid "Delete AK Orga Messages" msgstr "AK-Organachrichten löschen" -#: AKModel/views/ak.py:89 +#: AKModel/views/ak.py:110 msgid "AK Orga Messages successfully deleted" msgstr "AK-Organachrichten erfolgreich gelöscht" -#: AKModel/views/ak.py:101 +#: AKModel/views/ak.py:122 msgid "Interest of the following AKs will be set to not filled (-1):" msgstr "Interesse an den folgenden AKs wird auf nicht ausgefüllt (-1) gesetzt:" -#: AKModel/views/ak.py:102 +#: AKModel/views/ak.py:123 msgid "Reset of interest in AKs successful." msgstr "Interesse an AKs erfolgreich zurückgesetzt." -#: AKModel/views/ak.py:116 +#: AKModel/views/ak.py:137 msgid "Interest counter of the following AKs will be set to 0:" msgstr "Interessensbekundungszähler der folgenden AKs wird auf 0 gesetzt:" -#: AKModel/views/ak.py:117 +#: AKModel/views/ak.py:138 msgid "AKs' interest counters set back to 0." msgstr "Interessenszähler der AKs zurückgesetzt" @@ -1226,90 +1283,103 @@ msgstr "'%(obj)s' kopiert" msgid "Could not copy '%(obj)s' (%(error)s)" msgstr "'%(obj)s' konnte nicht kopiert werden (%(error)s)" -#: AKModel/views/manage.py:35 AKModel/views/status.py:150 +#: AKModel/views/manage.py:37 AKModel/views/status.py:158 msgid "Export AK Slides" msgstr "AK-Folien exportieren" -#: AKModel/views/manage.py:48 +#: AKModel/views/manage.py:50 msgid "Symbols" msgstr "Symbole" -#: AKModel/views/manage.py:49 +#: AKModel/views/manage.py:51 msgid "Who?" msgstr "Wer?" -#: AKModel/views/manage.py:50 +#: AKModel/views/manage.py:52 msgid "Duration(s)" msgstr "Dauer(n)" -#: AKModel/views/manage.py:51 +#: AKModel/views/manage.py:53 msgid "Reso intention?" msgstr "Resolutionsabsicht?" -#: AKModel/views/manage.py:52 +#: AKModel/views/manage.py:54 msgid "Category (for Wishes)" msgstr "Kategorie (für Wünsche)" -#: AKModel/views/manage.py:101 +#: AKModel/views/manage.py:103 msgid "The following Constraint Violations will be marked as manually resolved" msgstr "" "Die folgenden Constraintverletzungen werden als manuell behoben markiert." -#: AKModel/views/manage.py:102 +#: AKModel/views/manage.py:104 msgid "Constraint Violations marked as resolved" msgstr "Constraintverletzungen als manuell behoben markiert" -#: AKModel/views/manage.py:114 +#: AKModel/views/manage.py:116 msgid "The following Constraint Violations will be set to level 'violation'" msgstr "" "Die folgenden Constraintverletzungen werden auf das Level \"Violation\" " "gesetzt." -#: AKModel/views/manage.py:115 +#: AKModel/views/manage.py:117 msgid "Constraint Violations set to level 'violation'" msgstr "Constraintverletzungen auf Level \"Violation\" gesetzt" -#: AKModel/views/manage.py:127 +#: AKModel/views/manage.py:129 msgid "The following Constraint Violations will be set to level 'warning'" msgstr "" "Die folgenden Constraintverletzungen werden auf das Level 'warning' gesetzt." -#: AKModel/views/manage.py:128 +#: AKModel/views/manage.py:130 msgid "Constraint Violations set to level 'warning'" msgstr "Constraintverletzungen auf Level \"Warning\" gesetzt" -#: AKModel/views/manage.py:140 +#: AKModel/views/manage.py:142 msgid "Publish the plan(s) of:" msgstr "Den Plan/die Pläne veröffentlichen von:" -#: AKModel/views/manage.py:141 +#: AKModel/views/manage.py:143 msgid "Plan published" msgstr "Plan veröffentlicht" -#: AKModel/views/manage.py:153 +#: AKModel/views/manage.py:155 msgid "Unpublish the plan(s) of:" msgstr "Den Plan/die Pläne verbergen von:" -#: AKModel/views/manage.py:154 +#: AKModel/views/manage.py:156 msgid "Plan unpublished" msgstr "Plan verborgen" -#: AKModel/views/manage.py:166 AKModel/views/status.py:134 +#: AKModel/views/manage.py:168 AKModel/views/status.py:134 msgid "Edit Default Slots" msgstr "Standardslots bearbeiten" -#: AKModel/views/manage.py:204 +#: AKModel/views/manage.py:206 #, python-brace-format msgid "Could not update slot {id} since it does not belong to {event}" msgstr "" "Konnte Slot {id} nicht bearbeiten, da er nicht zum Event {event} gehört" -#: AKModel/views/manage.py:235 +#: AKModel/views/manage.py:237 #, python-brace-format msgid "Updated {u} slot(s). created {c} new slot(s) and deleted {d} slot(s)" msgstr "" "{u} Slot(s) aktualisiert, {c} Slot(s) hinzugefügt und {d} Slot(s) gelöscht" +#: AKModel/views/manage.py:257 +msgid "AK Schedule JSON Import" +msgstr "AK-Plan JSON-Import" + +#: AKModel/views/manage.py:265 +#, python-brace-format +msgid "Successfully imported {n} slot(s)" +msgstr "Erfolgreich {n} Slot(s) importiert" + +#: AKModel/views/manage.py:271 +msgid "Importing an AK schedule failed! Reason: " +msgstr "AK-Plan importieren fehlgeschlagen! Grund: " + #: AKModel/views/room.py:37 #, python-format msgid "Created Room '%(room)s'" @@ -1362,21 +1432,35 @@ msgid "Manage ak tracks" msgstr "AK-Tracks verwalten" #: AKModel/views/status.py:142 +msgid "Import AK schedule from JSON" +msgstr "AK-Plan aus JSON importieren" + +#: AKModel/views/status.py:146 msgid "Export AKs as CSV" msgstr "AKs als CSV exportieren" -#: AKModel/views/status.py:146 +#: AKModel/views/status.py:150 +msgid "Export AKs as JSON" +msgstr "AKs als JSON exportieren" + +#: AKModel/views/status.py:154 msgid "Export AKs for Wiki" msgstr "AKs im Wiki-Format exportieren" -#: AKModel/views/status.py:179 +#: AKModel/views/status.py:187 msgid "Show AKs for requirements" msgstr "Zu Anforderungen gehörige AKs anzeigen" -#: AKModel/views/status.py:193 +#: AKModel/views/status.py:201 msgid "Event Status" msgstr "Eventstatus" +#~ msgid "Conflicts" +#~ msgstr "Konflikte" + +#~ msgid "Prerequisites" +#~ msgstr "Voraussetzungen" + #~ msgid "Opening time for expression of interest." #~ msgstr "Öffnungszeitpunkt für die Angabe von Interesse an AKs." diff --git a/AKModel/migrations/0061_event_export_slot.py b/AKModel/migrations/0061_event_export_slot.py new file mode 100644 index 0000000000000000000000000000000000000000..3b40b88cafa836b16077c81117f81fbc95146808 --- /dev/null +++ b/AKModel/migrations/0061_event_export_slot.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.13 on 2025-02-06 16:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("AKModel", "0060_orga_message_resolved"), + ] + + operations = [ + migrations.AddField( + model_name="event", + name="export_slot", + field=models.DecimalField( + decimal_places=2, + default=1, + help_text="Slot duration in hours that is used in the timeslot discretization, when this event is exported for the solver.", + max_digits=4, + verbose_name="Export Slot Length", + ), + ), + ] diff --git a/AKModel/migrations/0063_merge_0061_event_export_slot_0062_interest_no_history.py b/AKModel/migrations/0063_merge_0061_event_export_slot_0062_interest_no_history.py new file mode 100644 index 0000000000000000000000000000000000000000..1e64026c1e18c08a82126443030c598da0ddf8ed --- /dev/null +++ b/AKModel/migrations/0063_merge_0061_event_export_slot_0062_interest_no_history.py @@ -0,0 +1,13 @@ +# Generated by Django 4.2.13 on 2025-02-27 18:10 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("AKModel", "0061_event_export_slot"), + ("AKModel", "0062_interest_no_history"), + ] + + operations = [] diff --git a/AKModel/migrations/0064_merge_20250304_1416.py b/AKModel/migrations/0064_merge_20250304_1416.py new file mode 100644 index 0000000000000000000000000000000000000000..456a64455182a03bd08b5c030bed1284adaccc67 --- /dev/null +++ b/AKModel/migrations/0064_merge_20250304_1416.py @@ -0,0 +1,14 @@ +# Generated by Django 5.1.6 on 2025-03-04 14:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('AKModel', '0063_field_validators'), + ('AKModel', '0063_merge_0061_event_export_slot_0062_interest_no_history'), + ] + + operations = [ + ] diff --git a/AKModel/models.py b/AKModel/models.py index 1b23b8732566652abf022f159bd8dbe063c68bb7..9d7b6c2e15dc332fec9472a0553878670a7288a2 100644 --- a/AKModel/models.py +++ b/AKModel/models.py @@ -1,9 +1,14 @@ +import decimal import itertools +import json +import math +from dataclasses import dataclass from datetime import datetime, timedelta +from typing import Any, Generator, Iterable -from django.core.validators import RegexValidator from django.apps import apps -from django.db import models +from django.core.validators import RegexValidator +from django.db import models, transaction from django.db.models import Count from django.urls import reverse_lazy from django.utils import timezone @@ -12,7 +17,6 @@ from django.utils.translation import gettext_lazy as _ from simple_history.models import HistoricalRecords from timezone_field import TimeZoneField - # Custom validators to be used for some of the fields # Prevent inclusion of the quotation marks ' " ´ ` # This may be necessary to prevent javascript issues @@ -23,6 +27,103 @@ no_quotation_marks_validator = RegexValidator(regex=r"['\"´`]+", inverse_match= slugable_validator = RegexValidator(regex=r"[\w\s]+", message=_('Must contain at least one letter or digit')) +@dataclass +class OptimizerTimeslot: + """Class describing a discrete timeslot. Used to interface with an optimizer.""" + + avail: "Availability" + """The availability object corresponding to this timeslot.""" + + idx: int + """The unique index of this optimizer timeslot.""" + + constraints: set[str] + """The set of time constraints fulfilled by this object.""" + + def merge(self, other: "OptimizerTimeslot") -> "OptimizerTimeslot": + """Merge with other OptimizerTimeslot. + + Creates a new OptimizerTimeslot object. + Its availability is constructed by merging the availabilities of self and other, + its constraints by taking the union of both constraint sets. + As an index, the index of self is used. + """ + avail = self.avail.merge_with(other.avail) + constraints = self.constraints.union(other.constraints) + return OptimizerTimeslot( + avail=avail, idx=self.idx, constraints=constraints + ) + + def __repr__(self) -> str: + return f"({self.avail.simplified}, {self.idx}, {self.constraints})" + + +TimeslotBlock = list[OptimizerTimeslot] + + +def merge_blocks( + blocks: Iterable[TimeslotBlock] +) -> Iterable[TimeslotBlock]: + """Merge iterable of blocks together. + + The timeslots of all blocks are grouped into maximal blocks. + Timeslots with the same start and end are identified with each other + and merged (cf `OptimizerTimeslot.merge`). + Throws a ValueError if any timeslots are overlapping but do not + share the same start and end, i.e. partial overlap is not allowed. + + :param blocks: iterable of blocks to merge. + :return: iterable of merged blocks. + :rtype: iterable over lists of OptimizerTimeslot objects + """ + if not blocks: + return [] + + # flatten timeslot iterables to single chain + timeslot_chain = itertools.chain.from_iterable(blocks) + + # sort timeslots according to start + timeslots = sorted( + timeslot_chain, + key=lambda slot: slot.avail.start + ) + + if not timeslots: + return [] + + all_blocks = [] + current_block = [timeslots[0]] + timeslots = timeslots[1:] + + for slot in timeslots: + if current_block and slot.avail.overlaps(current_block[-1].avail, strict=True): + if ( + slot.avail.start == current_block[-1].avail.start + and slot.avail.end == current_block[-1].avail.end + ): + # the same timeslot -> merge + current_block[-1] = current_block[-1].merge(slot) + else: + # partial overlap of interiors -> not supported + # TODO: Show comprehensive message in production + raise ValueError( + "Partially overlapping timeslots are not supported!" + f" ({current_block[-1].avail.simplified}, {slot.avail.simplified})" + ) + elif not current_block or slot.avail.overlaps(current_block[-1].avail, strict=False): + # only endpoints in intersection -> same block + current_block.append(slot) + else: + # no overlap at all -> new block + all_blocks.append(current_block) + current_block = [slot] + + if current_block: + all_blocks.append(current_block) + + return all_blocks + + class Event(models.Model): """ An event supplies the frame for all Aks. @@ -62,6 +163,11 @@ class Event(models.Model): wiki_export_template_name = models.CharField(verbose_name=_("Wiki Export Template Name"), blank=True, max_length=50) default_slot = models.DecimalField(max_digits=4, decimal_places=2, default=2, verbose_name=_('Default Slot Length'), help_text=_('Default length in hours that is assumed for AKs in this event.')) + export_slot = models.DecimalField(max_digits=4, decimal_places=2, default=1, verbose_name=_('Export Slot Length'), + help_text=_( + 'Slot duration in hours that is used in the timeslot discretization, ' + 'when this event is exported for the solver.' + )) contact_email = models.EmailField(verbose_name=_("Contact email address"), blank=True, help_text=_("An email address that is displayed on every page " @@ -173,6 +279,380 @@ class Event(models.Model): .filter(availabilities__count=0, owners__count__gt=0) ) + def _generate_slots_from_block( + self, + start: datetime, + end: datetime, + slot_duration: timedelta, + *, + slot_index: int = 0, + constraints: set[str] | None = None, + ) -> Generator[TimeslotBlock, None, int]: + """Discretize a time range into timeslots. + + Uses a uniform discretization into discrete slots of length `slot_duration`, + starting at `start`. No incomplete timeslots are generated, i.e. + if (`end` - `start`) is not a whole number multiple of `slot_duration` + then the last incomplete timeslot is dropped. + + :param start: Start of the time range. + :param end: Start of the time range. + :param slot_duration: Duration of a single timeslot in the discretization. + :param slot_index: index of the first timeslot. Defaults to 0. + + :yield: Block of optimizer timeslots as the discretization result. + :ytype: list of OptimizerTimeslot + + :return: The first slot index after the yielded blocks, i.e. + `slot_index` + total # generated timeslots + :rtype: int + """ + # local import to prevent cyclic import + # pylint: disable=import-outside-toplevel + from AKModel.availability.models import Availability + + current_slot_start = start + previous_slot_start: datetime | None = None + + if constraints is None: + constraints = set() + + current_block = [] + + room_availabilities = list({ + availability + for room in Room.objects.filter(event=self) + for availability in room.availabilities.all() + }) + + while current_slot_start + slot_duration <= end: + slot = Availability( + event=self, + start=current_slot_start, + end=current_slot_start + slot_duration, + ) + + if any((availability.contains(slot) for availability in room_availabilities)): + # no gap in a block + if ( + previous_slot_start is not None + and previous_slot_start + slot_duration < current_slot_start + ): + yield current_block + current_block = [] + + current_block.append( + OptimizerTimeslot(avail=slot, idx=slot_index, constraints=constraints) + ) + previous_slot_start = current_slot_start + + slot_index += 1 + current_slot_start += slot_duration + + if current_block: + yield current_block + + return slot_index + + def uniform_time_slots(self, *, slots_in_an_hour: float) -> Iterable[TimeslotBlock]: + """Uniformly discretize the entire event into blocks of timeslots. + + Discretizes entire event uniformly. May not necessarily result in a single block + as slots with no room availability are dropped. + + :param slots_in_an_hour: The percentage of an hour covered by a single slot. + Determines the discretization granularity. + :yield: Block of optimizer timeslots as the discretization result. + :ytype: list of OptimizerTimeslot + """ + all_category_constraints = AKCategory.create_category_constraints( + AKCategory.objects.filter(event=self).all() + ) + + yield from self._generate_slots_from_block( + start=self.start, + end=self.end, + slot_duration=timedelta(hours=1.0 / slots_in_an_hour), + constraints=all_category_constraints, + ) + + def default_time_slots(self, *, slots_in_an_hour: float) -> Iterable[TimeslotBlock]: + """Discretize all default slots into blocks of timeslots. + + In the discretization each default slot corresponds to one block. + + :param slots_in_an_hour: The percentage of an hour covered by a single slot. + Determines the discretization granularity. + :yield: Block of optimizer timeslots as the discretization result. + :ytype: list of TimeslotBlock + """ + slot_duration = timedelta(hours=1.0 / slots_in_an_hour) + slot_index = 0 + + for block_slot in DefaultSlot.objects.filter(event=self).order_by("start", "end"): + category_constraints = AKCategory.create_category_constraints( + block_slot.primary_categories.all() + ) + + slot_index = yield from self._generate_slots_from_block( + start=block_slot.start, + end=block_slot.end, + slot_duration=slot_duration, + slot_index=slot_index, + constraints=category_constraints, + ) + + def discretize_timeslots(self, *, slots_in_an_hour: float | None = None) -> Iterable[TimeslotBlock]: + """"Choose discretization scheme. + + Uses default_time_slots if the event has any DefaultSlot, otherwise uniform_time_slots. + + :param slots_in_an_hour: The percentage of an hour covered by a single slot. + Determines the discretization granularity. + :yield: Block of optimizer timeslots as the discretization result. + :ytype: list of TimeslotBlock + """ + + if slots_in_an_hour is None: + slots_in_an_hour = 1.0 / float(self.export_slot) + + if DefaultSlot.objects.filter(event=self).exists(): + # discretize default slots if they exists + yield from merge_blocks(self.default_time_slots(slots_in_an_hour=slots_in_an_hour)) + else: + yield from self.uniform_time_slots(slots_in_an_hour=slots_in_an_hour) + + @transaction.atomic + def schedule_from_json(self, schedule: str, *, check_for_data_inconsistency: bool = True) -> int: + """Load AK schedule from a json string. + + :param schedule: A string that can be decoded to json, describing + the AK schedule. The json data is assumed to be constructed + following the output specification of the KoMa conference optimizer, cf. + https://github.com/Die-KoMa/ak-plan-optimierung/wiki/Input-&-output-format + """ + schedule = json.loads(schedule) + export_dict = self.as_json_dict() + + if check_for_data_inconsistency and schedule["input"] != export_dict: + raise ValueError("Data has changed since the export. Reexport and run the solver again.") + + slots_in_an_hour = schedule["input"]["timeslots"]["info"]["duration"] + + timeslot_dict = { + timeslot.idx: timeslot + for block in self.discretize_timeslots(slots_in_an_hour=slots_in_an_hour) + for timeslot in block + } + + slots_updated = 0 + for scheduled_slot in schedule["scheduled_aks"]: + scheduled_slot["timeslot_ids"] = list(map(int, scheduled_slot["timeslot_ids"])) + slot = AKSlot.objects.get(id=int(scheduled_slot["ak_id"])) + + if not scheduled_slot["timeslot_ids"]: + raise ValueError( + _("AK {ak_name} is not assigned any timeslot by the solver").format(ak_name=slot.ak.name) + ) + + start_timeslot = timeslot_dict[min(scheduled_slot["timeslot_ids"])].avail + end_timeslot = timeslot_dict[max(scheduled_slot["timeslot_ids"])].avail + solver_duration = (end_timeslot.end - start_timeslot.start).total_seconds() / 3600.0 + + if solver_duration + 2e-4 < slot.duration: + raise ValueError( + _( + "Duration of AK {ak_name} assigned by solver ({solver_duration} hours) " + "is less than the duration required by the slot ({slot_duration} hours)" + ).format( + ak_name=slot.ak.name, + solver_duration=solver_duration, + slot_duration=slot.duration, + ) + ) + + if slot.fixed: + solver_room = Room.objects.get(id=int(scheduled_slot["room_id"])) + if slot.room != solver_room: + raise ValueError( + _( + "Fixed AK {ak_name} assigned by solver to room {solver_room} " + "is fixed to room {slot_room}" + ).format( + ak_name=slot.ak.name, + solver_room=solver_room.name, + slot_room=slot.room.name, + ) + ) + if slot.start != start_timeslot.start: + raise ValueError( + _( + "Fixed AK {ak_name} assigned by solver to start at {solver_start} " + "is fixed to start at {slot_start}" + ).format( + ak_name=slot.ak.name, + solver_start=start_timeslot.start, + slot_start=slot.start, + ) + ) + else: + slot.room = Room.objects.get(id=int(scheduled_slot["room_id"])) + slot.start = start_timeslot.start + slot.save() + slots_updated += 1 + + return slots_updated + + def as_json_dict(self) -> dict[str, Any]: + """Return the json representation of this Event. + + :return: The json dict representation is constructed + following the input specification of the KoMa conference optimizer, cf. + https://github.com/Die-KoMa/ak-plan-optimierung/wiki/Input-&-output-format + :rtype: dict[str, Any] + """ + + # local import to prevent cyclic import + # pylint: disable=import-outside-toplevel + from AKModel.availability.models import Availability + + def _test_event_not_covered(availabilities: list[Availability]) -> bool: + """Test if event is not covered by availabilities.""" + return not Availability.is_event_covered(self, availabilities) + + def _test_akslot_fixed_in_timeslot(ak_slot: AKSlot, timeslot: Availability) -> bool: + """Test if an AKSlot is fixed to overlap a timeslot slot.""" + if not ak_slot.fixed or ak_slot.start is None: + return False + + fixed_avail = Availability(event=self, start=ak_slot.start, end=ak_slot.end) + return fixed_avail.overlaps(timeslot, strict=True) + + def _test_add_constraint(slot: Availability, availabilities: list[Availability]) -> bool: + """Test if object is not available for whole event and may happen during slot.""" + return ( + _test_event_not_covered(availabilities) and slot.is_covered(availabilities) + ) + + def _generate_time_constraints( + avail_label: str, + avail_dict: dict, + timeslot_avail: Availability, + prefix: str = "availability", + ) -> list[str]: + return [ + f"{prefix}-{avail_label}-{pk}" + for pk, availabilities in avail_dict.items() + if _test_add_constraint(timeslot_avail, availabilities) + ] + + timeslots = { + "info": {"duration": float(self.export_slot)}, + "blocks": [], + } + + rooms = Room.objects.filter(event=self).order_by() + slots = AKSlot.objects.filter(event=self).order_by() + + ak_availabilities = { + ak.pk: Availability.union(ak.availabilities.all()) + for ak in AK.objects.filter(event=self).all() + } + room_availabilities = { + room.pk: Availability.union(room.availabilities.all()) + for room in rooms + } + person_availabilities = { + person.pk: Availability.union(person.availabilities.all()) + for person in AKOwner.objects.filter(event=self) + } + + blocks = list(self.discretize_timeslots()) + + block_names = [] + + for block_idx, block in enumerate(blocks): + current_block = [] + + if not block: + continue + + block_start = block[0].avail.start.astimezone(self.timezone) + block_end = block[-1].avail.end.astimezone(self.timezone) + + start_day = block_start.strftime("%A, %d. %b") + if block_start.date() == block_end.date(): + # same day + time_str = block_start.strftime("%H:%M") + " – " + block_end.strftime("%H:%M") + else: + # different days + time_str = block_start.strftime("%a %H:%M") + " – " + block_end.strftime("%a %H:%M") + block_names.append([start_day, time_str]) + + block_timeconstraints = [f"notblock{idx}" for idx in range(len(blocks)) if idx != block_idx] + + for timeslot in block: + time_constraints = [] + # if reso_deadline is set and timeslot ends before it, + # add fulfilled time constraint 'resolution' + if self.reso_deadline is None or timeslot.avail.end < self.reso_deadline: + time_constraints.append("resolution") + + # add fulfilled time constraints for all AKs that cannot happen during full event + time_constraints.extend( + _generate_time_constraints("ak", ak_availabilities, timeslot.avail) + ) + + # add fulfilled time constraints for all persons that are not available for full event + time_constraints.extend( + _generate_time_constraints("person", person_availabilities, timeslot.avail) + ) + + # add fulfilled time constraints for all rooms that are not available for full event + time_constraints.extend( + _generate_time_constraints("room", room_availabilities, timeslot.avail) + ) + + # add fulfilled time constraints for all AKSlots fixed to happen during timeslot + time_constraints.extend([ + f"fixed-akslot-{slot.id}" + for slot in AKSlot.objects.filter(event=self, fixed=True).exclude(start__isnull=True) + if _test_akslot_fixed_in_timeslot(slot, timeslot.avail) + ]) + + time_constraints.extend(timeslot.constraints) + time_constraints.extend(block_timeconstraints) + + current_block.append({ + "id": timeslot.idx, + "info": { + "start": timeslot.avail.start.astimezone(self.timezone).strftime("%Y-%m-%d %H:%M"), + "end": timeslot.avail.end.astimezone(self.timezone).strftime("%Y-%m-%d %H:%M"), + }, + "fulfilled_time_constraints": sorted(time_constraints), + }) + + timeslots["blocks"].append(current_block) + + timeslots["info"]["blocknames"] = block_names + + info_dict = { + "title": self.name, + "slug": self.slug + } + + for attr in ["contact_email", "place"]: + if hasattr(self, attr) and getattr(self, attr): + info_dict[attr] = getattr(self, attr) + + return { + "participants": [], + "rooms": [r.as_json_dict() for r in rooms], + "timeslots": timeslots, + "info": info_dict, + "aks": [ak.as_json_dict() for ak in slots], + } + class AKOwner(models.Model): """ An AKOwner describes the person organizing/holding an AK. @@ -273,6 +753,20 @@ class AKCategory(models.Model): def __str__(self): return self.name + @staticmethod + def create_category_constraints(categories: Iterable["AKCategory"]) -> set[str]: + """Create a set of constraint strings from an AKCategory iterable. + + :param categories: The iterable of categories to derive the constraint strings from. + :return: A set of category constraint strings, i.e. strings of the form + 'availability-cat-<cat.name>'. + :rtype: set of strings. + """ + return { + f"availability-cat-{cat.name}" + for cat in categories + } + class AKTrack(models.Model): """ An AKTrack describes a set of semantically related AKs. @@ -415,7 +909,7 @@ class AK(models.Model): availabilities = ', \n'.join(f'{a.simplified}' for a in Availability.objects.select_related('event') .filter(ak=self)) detail_string = f"""{self.name}{" (R)" if self.reso else ""}: - + {self.owners_list} {_('Interest')}: {self.interest}""" @@ -450,7 +944,7 @@ class AK(models.Model): Get a list of stringified representations of all owners :return: list of owners - :rtype: List[str] + :rtype: list[str] """ return ", ".join(str(owner) for owner in self.owners.all()) @@ -460,7 +954,7 @@ class AK(models.Model): Get a list of stringified representations of all durations of associated slots :return: list of durations - :rtype: List[str] + :rtype: list[str] """ return ", ".join(str(slot.duration_simplified) for slot in self.akslot_set.select_related('event').all()) @@ -566,6 +1060,44 @@ class Room(models.Model): def __str__(self): return self.title + def as_json_dict(self) -> dict[str, Any]: + """Return a json representation of this room object. + + :return: The json dict representation is constructed + following the input specification of the KoMa conference optimizer, cf. + https://github.com/Die-KoMa/ak-plan-optimierung/wiki/Input-&-output-format + :rtype: dict[str, Any] + """ + # local import to prevent cyclic import + # pylint: disable=import-outside-toplevel + from AKModel.availability.models import Availability + + # check if room is available for the whole event + # -> no time constraint needs to be introduced + if Availability.is_event_covered(self.event, self.availabilities.all()): + time_constraints = [] + else: + time_constraints = [f"availability-room-{self.pk}"] + + data = { + "id": self.pk, + "info": { + "name": self.name, + }, + "capacity": self.capacity, + "fulfilled_room_constraints": [constraint.name + for constraint in self.properties.all()], + "time_constraints": time_constraints + } + + data["fulfilled_room_constraints"].append(f"fixed-room-{self.pk}") + + if not any(constr.startswith("proxy") for constr in data["fulfilled_room_constraints"]): + data["fulfilled_room_constraints"].append("no-proxy") + + data["fulfilled_room_constraints"].sort() + return data + class AKSlot(models.Model): """ An AK Mapping matches an AK to a room during a certain time. @@ -662,6 +1194,85 @@ class AKSlot(models.Model): super().save(*args, force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields) + def as_json_dict(self) -> dict[str, Any]: + """Return a json representation of the AK object of this slot. + + :return: The json dict representation is constructed + following the input specification of the KoMa conference optimizer, cf. + https://github.com/Die-KoMa/ak-plan-optimierung/wiki/Input-&-output-format + :rtype: dict[str, Any] + """ + # local import to prevent cyclic import + # pylint: disable=import-outside-toplevel + from AKModel.availability.models import Availability + + # check if ak resp. owner is available for the whole event + # -> no time constraint needs to be introduced + + if self.fixed and self.start is not None: + ak_time_constraints = [f"fixed-akslot-{self.id}"] + elif not Availability.is_event_covered(self.event, self.ak.availabilities.all()): + ak_time_constraints = [f"availability-ak-{self.ak.pk}"] + else: + ak_time_constraints = [] + + def _owner_time_constraints(owner: AKOwner): + owner_avails = owner.availabilities.all() + if not owner_avails or Availability.is_event_covered(self.event, owner_avails): + return [] + return [f"availability-person-{owner.pk}"] + + conflict_slots = AKSlot.objects.filter(ak__in=self.ak.conflicts.all()) + dependency_slots = AKSlot.objects.filter(ak__in=self.ak.prerequisites.all()) + other_ak_slots = AKSlot.objects.filter(ak=self.ak).exclude(pk=self.pk) + + ceil_offet_eps = decimal.Decimal(1e-4) + + data = { + "id": self.pk, + "duration": math.ceil(self.duration / self.event.export_slot - ceil_offet_eps), + "properties": { + "conflicts": + sorted( + [conflict.pk for conflict in conflict_slots.all()] + + [second_slot.pk for second_slot in other_ak_slots.all()] + ), + "dependencies": sorted([dep.pk for dep in dependency_slots.all()]), + }, + "room_constraints": [constraint.name + for constraint in self.ak.requirements.all()], + "time_constraints": ["resolution"] if self.ak.reso else [], + "info": { + "name": self.ak.name, + "head": ", ".join([str(owner) + for owner in self.ak.owners.all()]), + "description": self.ak.description, + "reso": self.ak.reso, + "duration_in_hours": float(self.duration), + "django_ak_id": self.ak.pk, + "types": list(self.ak.types.values_list("name", flat=True).order_by()), + }, + } + + data["time_constraints"].extend(ak_time_constraints) + for owner in self.ak.owners.all(): + data["time_constraints"].extend(_owner_time_constraints(owner)) + + if self.ak.category: + category_constraints = AKCategory.create_category_constraints([self.ak.category]) + data["time_constraints"].extend(category_constraints) + + if self.fixed and self.room is not None: + data["room_constraints"].append(f"fixed-room-{self.room.pk}") + + if not any(constr.startswith("proxy") for constr in data["room_constraints"]): + data["room_constraints"].append("no-proxy") + + data["room_constraints"].sort() + data["time_constraints"].sort() + + return data + class AKOrgaMessage(models.Model): """ diff --git a/AKModel/templates/admin/AKModel/ak_json_export.html b/AKModel/templates/admin/AKModel/ak_json_export.html new file mode 100644 index 0000000000000000000000000000000000000000..65e459a5b3f7509ed98a1329e2d89f06fa2abb54 --- /dev/null +++ b/AKModel/templates/admin/AKModel/ak_json_export.html @@ -0,0 +1,20 @@ +{% extends "admin/base_site.html" %} + +{% load tz %} + +{% block content %} + +<p> +Exported JSON: +<pre> +{{ json_data_oneline }} +</pre> +</p> + +<p> +Exported JSON (indented for better readability): +<pre> +{{ json_data }} +</pre> +</p> +{% endblock %} diff --git a/AKModel/tests/__init__.py b/AKModel/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/AKModel/tests/test_json_export.py b/AKModel/tests/test_json_export.py new file mode 100644 index 0000000000000000000000000000000000000000..e8aac8fed36ba4f6917c3e22e87ae2ebec739775 --- /dev/null +++ b/AKModel/tests/test_json_export.py @@ -0,0 +1,860 @@ +import json +import math + +from collections import defaultdict +from collections.abc import Iterable +from datetime import datetime, timedelta +from itertools import chain + +from bs4 import BeautifulSoup +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse + +from AKModel.availability.models import Availability +from AKModel.models import ( + Event, + AKOwner, + AKCategory, + AK, + Room, + AKSlot, + DefaultSlot, +) + + +class JSONExportTest(TestCase): + """Test if JSON export is correct. + + It tests if the output conforms to the KoMa specification: + https://github.com/Die-KoMa/ak-plan-optimierung/wiki/Input-&-output-format + """ + + fixtures = ["model.json"] + + @classmethod + def setUpTestData(cls): + """Shared set up by initializing admin user.""" + cls.admin_user = get_user_model().objects.create( + username="Test Admin User", + email="testadmin@example.com", + password="adminpw", + is_staff=True, + is_superuser=True, + is_active=True, + ) + + def setUp(self): + self.client.force_login(self.admin_user) + self.export_dict = {} + self.export_objects = { + "aks": {}, + "rooms": {}, + "participants": {}, + } + + self.ak_slots: Iterable[AKSlot] = [] + self.rooms: Iterable[Room] = [] + self.slots_in_an_hour: float = 1.0 + self.event: Event | None = None + + def set_up_event(self, event: Event) -> None: + """Set up by retrieving json export and initializing data.""" + + export_url = reverse("admin:ak_json_export", kwargs={"event_slug": event.slug}) + response = self.client.get(export_url) + + self.assertEqual(response.status_code, 200, "Export not working at all") + + soup = BeautifulSoup(response.content, features="lxml") + self.export_dict = json.loads(soup.find("pre").string) + + self.export_objects["aks"] = {ak["id"]: ak for ak in self.export_dict["aks"]} + self.export_objects["rooms"] = { + room["id"]: room for room in self.export_dict["rooms"] + } + self.export_objects["participants"] = { + participant["id"]: participant + for participant in self.export_dict["participants"] + } + + self.ak_slots = ( + AKSlot.objects.filter(event__slug=event.slug) + .select_related("ak") + .prefetch_related("ak__conflicts") + .prefetch_related("ak__prerequisites") + .all() + ) + self.rooms = Room.objects.filter(event__slug=event.slug).all() + self.slots_in_an_hour = 1 / self.export_dict["timeslots"]["info"]["duration"] + self.event = event + + def test_all_aks_exported(self): + """Test if exported AKs match AKSlots of Event.""" + for event in Event.objects.all(): + with self.subTest(event=event): + self.set_up_event(event=event) + self.assertEqual( + {slot.pk for slot in self.ak_slots}, + self.export_objects["aks"].keys(), + "Exported AKs does not match the AKSlots of the event", + ) + + def _check_uniqueness(self, lst, name: str, key: str | None = "id"): + if key is not None: + lst = [entry[key] for entry in lst] + self.assertEqual(len(lst), len(set(lst)), f"{name} IDs not unique!") + + def _check_type(self, attr, cls, name: str, item: str) -> None: + self.assertTrue(isinstance(attr, cls), f"{item} {name} not a {cls}") + + def _check_lst( + self, lst: list[str], name: str, item: str, contained_type=str + ) -> None: + self.assertTrue(isinstance(lst, list), f"{item} {name} not a list") + self.assertTrue( + all(isinstance(c, contained_type) for c in lst), + f"{item} has non-{contained_type} {name}", + ) + if contained_type in {str, int}: + self._check_uniqueness(lst, name, key=None) + + def test_ak_conformity_to_spec(self): + """Test if AK JSON structure and types conform to standard.""" + for event in Event.objects.all(): + with self.subTest(event=event): + self.set_up_event(event=event) + + self._check_uniqueness(self.export_dict["aks"], "AK") + for ak in self.export_dict["aks"]: + item = f"AK {ak['id']}" + self.assertEqual( + ak.keys(), + { + "id", + "duration", + "properties", + "room_constraints", + "time_constraints", + "info", + }, + f"{item} keys not as expected", + ) + self.assertEqual( + ak["info"].keys(), + { + "name", + "head", + "description", + "reso", + "duration_in_hours", + "django_ak_id", + "types", + }, + f"{item} info keys not as expected", + ) + self.assertEqual( + ak["properties"].keys(), + {"conflicts", "dependencies"}, + f"{item} properties keys not as expected", + ) + + self._check_type(ak["id"], int, "id", item=item) + self._check_type(ak["duration"], int, "duration", item=item) + self._check_type(ak["info"]["name"], str, "info/name", item=item) + self._check_type(ak["info"]["head"], str, "info/head", item=item) + self._check_type( + ak["info"]["description"], str, "info/description", item=item + ) + self._check_type(ak["info"]["reso"], bool, "info/reso", item=item) + self._check_type( + ak["info"]["duration_in_hours"], + float, + "info/duration_in_hours", + item=item, + ) + self._check_type( + ak["info"]["django_ak_id"], + int, + "info/django_ak_id", + item=item, + ) + + self._check_lst( + ak["properties"]["conflicts"], + "conflicts", + item=item, + contained_type=int, + ) + self._check_lst( + ak["properties"]["dependencies"], + "dependencies", + item=item, + contained_type=int, + ) + self._check_lst( + ak["time_constraints"], "time_constraints", item=item + ) + self._check_lst( + ak["room_constraints"], "room_constraints", item=item + ) + + def test_room_conformity_to_spec(self): + """Test if Room JSON structure and types conform to standard.""" + for event in Event.objects.all(): + with self.subTest(event=event): + self.set_up_event(event=event) + + self._check_uniqueness(self.export_dict["rooms"], "Room") + for room in self.export_dict["rooms"]: + item = f"Room {room['id']}" + self.assertEqual( + room.keys(), + { + "id", + "info", + "capacity", + "fulfilled_room_constraints", + "time_constraints", + }, + f"{item} keys not as expected", + ) + self.assertEqual( + room["info"].keys(), + {"name"}, + f"{item} info keys not as expected", + ) + + self._check_type(room["id"], int, "id", item=item) + self._check_type(room["capacity"], int, "capacity", item=item) + self._check_type(room["info"]["name"], str, "info/name", item=item) + + self.assertTrue( + room["capacity"] > 0 or room["capacity"] == -1, + "invalid room capacity", + ) + + self._check_lst( + room["time_constraints"], "time_constraints", item=item + ) + self._check_lst( + room["fulfilled_room_constraints"], + "fulfilled_room_constraints", + item=item, + ) + + def test_timeslots_conformity_to_spec(self): + """Test if Timeslots JSON structure and types conform to standard.""" + for event in Event.objects.all(): + with self.subTest(event=event): + self.set_up_event(event=event) + + self._check_uniqueness( + chain.from_iterable(self.export_dict["timeslots"]["blocks"]), + "Timeslots", + ) + item = "timeslots" + self.assertEqual( + self.export_dict["timeslots"].keys(), + {"info", "blocks"}, + "timeslot keys not as expected", + ) + self.assertEqual( + self.export_dict["timeslots"]["info"].keys(), + {"duration", "blocknames"}, + "timeslot info keys not as expected", + ) + self._check_type( + self.export_dict["timeslots"]["info"]["duration"], + float, + "info/duration", + item=item, + ) + self._check_lst( + self.export_dict["timeslots"]["info"]["blocknames"], + "info/blocknames", + item=item, + contained_type=list, + ) + for blockname in self.export_dict["timeslots"]["info"]["blocknames"]: + self.assertEqual(len(blockname), 2) + self._check_lst( + blockname, + "info/blocknames/entry", + item=item, + contained_type=str, + ) + + self._check_lst( + self.export_dict["timeslots"]["blocks"], + "blocks", + item=item, + contained_type=list, + ) + + prev_id = None + for timeslot in chain.from_iterable( + self.export_dict["timeslots"]["blocks"] + ): + item = f"timeslot {timeslot['id']}" + self.assertEqual( + timeslot.keys(), + {"id", "info", "fulfilled_time_constraints"}, + f"{item} keys not as expected", + ) + self.assertEqual( + timeslot["info"].keys(), + {"start", "end"}, + f"{item} info keys not as expected", + ) + self._check_type(timeslot["id"], int, "id", item=item) + self._check_type( + timeslot["info"]["start"], str, "info/start", item=item + ) + self._check_lst( + timeslot["fulfilled_time_constraints"], + "fulfilled_time_constraints", + item=item, + ) + + if prev_id is not None: + self.assertLess( + prev_id, + timeslot["id"], + "timeslot ids must be increasing", + ) + prev_id = timeslot["id"] + + def test_general_conformity_to_spec(self): + """Test if rest of JSON structure and types conform to standard.""" + for event in Event.objects.all(): + with self.subTest(event=event): + self.set_up_event(event=event) + + self.assertEqual( + self.export_dict["participants"], + [], + "Empty participant list expected", + ) + + info_keys = {"title": "name", "slug": "slug"} + for attr in ["contact_email", "place"]: + if hasattr(self.event, attr) and getattr(self.event, attr): + info_keys[attr] = attr + self.assertEqual( + self.export_dict["info"].keys(), + info_keys.keys(), + "info keys not as expected", + ) + for attr, attr_field in info_keys.items(): + self.assertEqual( + getattr(self.event, attr_field), self.export_dict["info"][attr] + ) + + self._check_uniqueness(self.export_dict["participants"], "Participants") + + def test_ak_durations(self): + """Test if all AK durations are correct.""" + for event in Event.objects.all(): + with self.subTest(event=event): + self.set_up_event(event=event) + + for slot in self.ak_slots: + ak = self.export_objects["aks"][slot.pk] + + self.assertLessEqual( + float(slot.duration) * self.slots_in_an_hour - 1e-4, + ak["duration"], + "Slot duration is too short", + ) + + self.assertEqual( + math.ceil(float(slot.duration) * self.slots_in_an_hour - 1e-4), + ak["duration"], + "Slot duration is wrong", + ) + + self.assertEqual( + float(slot.duration), + ak["info"]["duration_in_hours"], + "Slot duration_in_hours is wrong", + ) + + def test_ak_conflicts(self): + """Test if all AK conflicts are correct.""" + for event in Event.objects.all(): + with self.subTest(event=event): + self.set_up_event(event=event) + + for slot in self.ak_slots: + ak = self.export_objects["aks"][slot.pk] + conflict_slots = set( + self.ak_slots.filter( + ak__in=slot.ak.conflicts.all() + ).values_list("pk", flat=True) + ) + + other_ak_slots = ( + self.ak_slots.filter(ak=slot.ak) + .exclude(pk=slot.pk) + .values_list("pk", flat=True) + ) + conflict_slots.update(other_ak_slots) + + self.assertEqual( + conflict_slots, + set(ak["properties"]["conflicts"]), + f"Conflicts for slot {slot.pk} not as expected", + ) + + def test_ak_depenedencies(self): + """Test if all AK dependencies are correct.""" + for event in Event.objects.all(): + with self.subTest(event=event): + self.set_up_event(event=event) + + for slot in self.ak_slots: + ak = self.export_objects["aks"][slot.pk] + dependency_slots = self.ak_slots.filter( + ak__in=slot.ak.prerequisites.all() + ).values_list("pk", flat=True) + + self.assertEqual( + set(dependency_slots), + set(ak["properties"]["dependencies"]), + f"Dependencies for slot {slot.pk} not as expected", + ) + + def test_ak_reso(self): + """Test if resolution intent of AKs is correctly exported.""" + for event in Event.objects.all(): + with self.subTest(event=event): + self.set_up_event(event=event) + + for slot in self.ak_slots: + ak = self.export_objects["aks"][slot.pk] + self.assertEqual(slot.ak.reso, ak["info"]["reso"]) + self.assertEqual( + slot.ak.reso, "resolution" in ak["time_constraints"] + ) + + def test_ak_info(self): + """Test if contents of AK info dict is correct.""" + for event in Event.objects.all(): + with self.subTest(event=event): + self.set_up_event(event=event) + + for slot in self.ak_slots: + ak = self.export_objects["aks"][slot.pk] + self.assertEqual(ak["info"]["name"], slot.ak.name) + self.assertEqual( + ak["info"]["head"], ", ".join(map(str, slot.ak.owners.all())) + ) + self.assertEqual(ak["info"]["description"], slot.ak.description) + self.assertEqual(ak["info"]["django_ak_id"], slot.ak.pk) + self.assertEqual( + ak["info"]["types"], + list(slot.ak.types.values_list("name", flat=True).order_by()), + ) + + def test_ak_room_constraints(self): + """Test if AK room constraints are exported as expected.""" + for event in Event.objects.all(): + with self.subTest(event=event): + self.set_up_event(event=event) + + for slot in self.ak_slots: + ak = self.export_objects["aks"][slot.pk] + requirements = list( + slot.ak.requirements.values_list("name", flat=True) + ) + + # proxy rooms + if not any(constr.startswith("proxy") for constr in requirements): + requirements.append("no-proxy") + + # fixed slot + if slot.fixed and slot.room is not None: + requirements.append(f"fixed-room-{slot.room.pk}") + + self.assertEqual( + set(ak["room_constraints"]), + set(requirements), + f"Room constraints for slot {slot.pk} not as expected", + ) + + def test_ak_time_constraints(self): + """Test if AK time constraints are exported as expected.""" + for event in Event.objects.all(): + with self.subTest(event=event): + self.set_up_event(event=event) + + for slot in self.ak_slots: + time_constraints = set() + + # add time constraints for AK category + if slot.ak.category: + category_constraints = AKCategory.create_category_constraints( + [slot.ak.category] + ) + time_constraints |= category_constraints + + if slot.fixed and slot.start is not None: + # fixed slot + time_constraints.add(f"fixed-akslot-{slot.pk}") + elif not Availability.is_event_covered( + slot.event, slot.ak.availabilities.all() + ): + # restricted AK availability + time_constraints.add(f"availability-ak-{slot.ak.pk}") + + for owner in slot.ak.owners.all(): + # restricted owner availability + if not owner.availabilities.all(): + # no availability for owner -> assume full event is covered + continue + + if not Availability.is_event_covered( + slot.event, owner.availabilities.all() + ): + time_constraints.add(f"availability-person-{owner.pk}") + + ak = self.export_objects["aks"][slot.pk] + self.assertEqual( + set(ak["time_constraints"]), + time_constraints, + f"Time constraints for slot {slot.pk} not as expected", + ) + + def test_all_rooms_exported(self): + """Test if exported Rooms match the rooms of Event.""" + for event in Event.objects.all(): + with self.subTest(event=event): + self.set_up_event(event=event) + + self.assertEqual( + {room.pk for room in self.rooms}, + self.export_objects["rooms"].keys(), + "Exported Rooms do not match the Rooms of the event", + ) + + def test_room_capacity(self): + """Test if room capacity is exported correctly.""" + for event in Event.objects.all(): + with self.subTest(event=event): + self.set_up_event(event=event) + + for room in self.rooms: + export_room = self.export_objects["rooms"][room.pk] + self.assertEqual(room.capacity, export_room["capacity"]) + + def test_room_info(self): + """Test if contents of Room info dict is correct.""" + for event in Event.objects.all(): + with self.subTest(event=event): + self.set_up_event(event=event) + + for room in self.rooms: + export_room = self.export_objects["rooms"][room.pk] + self.assertEqual(room.name, export_room["info"]["name"]) + + def test_room_timeconstraints(self): + """Test if Room time constraints are exported as expected.""" + for event in Event.objects.all(): + with self.subTest(event=event): + self.set_up_event(event=event) + + for room in self.rooms: + time_constraints = set() + + # test if time availability of room is restricted + if not Availability.is_event_covered( + event, room.availabilities.all() + ): + time_constraints.add(f"availability-room-{room.pk}") + + export_room = self.export_objects["rooms"][room.pk] + self.assertEqual( + time_constraints, set(export_room["time_constraints"]) + ) + + def test_room_fulfilledroomconstraints(self): + """Test if room constraints fulfilled by Room are correct.""" + for event in Event.objects.all(): + with self.subTest(event=event): + self.set_up_event(event=event) + + for room in self.rooms: + # room properties + fulfilled_room_constraints = set( + room.properties.values_list("name", flat=True) + ) + + # proxy rooms + if not any( + constr.startswith("proxy") + for constr in fulfilled_room_constraints + ): + fulfilled_room_constraints.add("no-proxy") + + fulfilled_room_constraints.add(f"fixed-room-{room.pk}") + + export_room = self.export_objects["rooms"][room.pk] + self.assertEqual( + fulfilled_room_constraints, + set(export_room["fulfilled_room_constraints"]), + ) + + def _get_timeslot_start_end(self, timeslot): + start = datetime.strptime(timeslot["info"]["start"], "%Y-%m-%d %H:%M").replace( + tzinfo=self.event.timezone + ) + end = datetime.strptime(timeslot["info"]["end"], "%Y-%m-%d %H:%M").replace( + tzinfo=self.event.timezone + ) + return start, end + + def _get_cat_availability_in_export(self): + export_slot_cat_avails = defaultdict(list) + for timeslot in chain.from_iterable(self.export_dict["timeslots"]["blocks"]): + for constr in timeslot["fulfilled_time_constraints"]: + if constr.startswith("availability-cat-"): + cat_name = constr[len("availability-cat-") :] + start, end = self._get_timeslot_start_end(timeslot) + export_slot_cat_avails[cat_name].append( + Availability(event=self.event, start=start, end=end) + ) + return { + cat_name: Availability.union(avail_lst) + for cat_name, avail_lst in export_slot_cat_avails.items() + } + + def _get_cat_availability(self): + if DefaultSlot.objects.filter(event=self.event).exists(): + # Event has default slots -> use them for category availability + default_slots_avails = defaultdict(list) + for def_slot in DefaultSlot.objects.filter(event=self.event).all(): + avail = Availability( + event=self.event, + start=def_slot.start.astimezone(self.event.timezone), + end=def_slot.end.astimezone(self.event.timezone), + ) + for cat in def_slot.primary_categories.all(): + default_slots_avails[cat.name].append(avail) + + return { + cat_name: Availability.union(avail_lst) + for cat_name, avail_lst in default_slots_avails.items() + } + + # Event has no default slots -> all categories available through whole event + start = self.event.start.astimezone(self.event.timezone) + end = self.event.end.astimezone(self.event.timezone) + delta = (end - start).total_seconds() + + # tweak event end + # 1. shorten event to match discrete slot grid + slot_seconds = 3600 / self.slots_in_an_hour + remainder_seconds = delta % slot_seconds + remainder_seconds += 1 # add a second to compensate rounding errs + end -= timedelta(seconds=remainder_seconds) + + # set seconds and microseconds to 0 as they are not exported to the json + start -= timedelta(seconds=start.second, microseconds=start.microsecond) + end -= timedelta(seconds=end.second, microseconds=end.microsecond) + event_avail = Availability(event=self.event, start=start, end=end) + + category_names = AKCategory.objects.filter(event=self.event).values_list( + "name", flat=True + ) + return {cat_name: [event_avail] for cat_name in category_names} + + def test_timeslots_consecutive(self): + """Test if consecutive timeslots in JSON are in fact consecutive.""" + for event in Event.objects.all(): + with self.subTest(event=event): + self.set_up_event(event=event) + + prev_end = None + for timeslot in chain.from_iterable( + self.export_dict["timeslots"]["blocks"] + ): + start, end = self._get_timeslot_start_end(timeslot) + self.assertLess(start, end) + + delta = end - start + self.assertAlmostEqual( + delta.total_seconds() / (3600), 1 / self.slots_in_an_hour + ) + + if prev_end is not None: + self.assertLessEqual(prev_end, start) + prev_end = end + + def test_block_cover_categories(self): + """Test if blocks covers all default slot resp. whole event per category.""" + for event in Event.objects.all(): + with self.subTest(event=event): + self.set_up_event(event=event) + category_names = AKCategory.objects.filter(event=event).values_list( + "name", flat=True + ) + + export_cat_avails = self._get_cat_availability_in_export() + cat_avails = self._get_cat_availability() + + for cat_name in category_names: + for avail in cat_avails[cat_name]: + # check that all category availabilities are covered + self.assertTrue( + avail.is_covered(export_cat_avails[cat_name]), + f"AKCategory {cat_name}: avail ({avail.start} – {avail.end}) " + f"not covered by {[f'({a.start} – {a.end})' for a in export_cat_avails[cat_name]]}", + ) + + def _is_restricted_and_contained_slot( + self, slot: Availability, availabilities: list[Availability] + ) -> bool: + """Test if object is not available for whole event and may happen during slot.""" + return slot.is_covered(availabilities) and not Availability.is_event_covered( + self.event, availabilities + ) + + def _is_ak_fixed_in_slot( + self, + ak_slot: AKSlot, + timeslot_avail: Availability, + ) -> bool: + if not ak_slot.fixed or ak_slot.start is None: + return False + ak_slot_avail = Availability( + event=self.event, + start=ak_slot.start.astimezone(self.event.timezone), + end=ak_slot.end.astimezone(self.event.timezone), + ) + return timeslot_avail.overlaps(ak_slot_avail, strict=True) + + def test_timeslot_fulfilledconstraints(self): + """Test if fulfilled time constraints by timeslot are as expected.""" + for event in Event.objects.all(): + with self.subTest(event=event): + self.set_up_event(event=event) + + cat_avails = self._get_cat_availability() + num_blocks = len(self.export_dict["timeslots"]["blocks"]) + for block_idx, block in enumerate( + self.export_dict["timeslots"]["blocks"] + ): + for timeslot in block: + start, end = self._get_timeslot_start_end(timeslot) + timeslot_avail = Availability( + event=self.event, start=start, end=end + ) + + fulfilled_time_constraints = set() + + # reso deadline + if self.event.reso_deadline is not None: + # timeslot ends before deadline + if end < self.event.reso_deadline.astimezone( + self.event.timezone + ): + fulfilled_time_constraints.add("resolution") + + # add category constraints + fulfilled_time_constraints |= ( + AKCategory.create_category_constraints( + [ + cat + for cat in AKCategory.objects.filter( + event=self.event + ).all() + if timeslot_avail.is_covered(cat_avails[cat.name]) + ] + ) + ) + + # add owner constraints + fulfilled_time_constraints |= { + f"availability-person-{owner.id}" + for owner in AKOwner.objects.filter(event=self.event).all() + if self._is_restricted_and_contained_slot( + timeslot_avail, + Availability.union(owner.availabilities.all()), + ) + } + + # add room constraints + fulfilled_time_constraints |= { + f"availability-room-{room.id}" + for room in self.rooms + if self._is_restricted_and_contained_slot( + timeslot_avail, + Availability.union(room.availabilities.all()), + ) + } + + # add ak constraints + fulfilled_time_constraints |= { + f"availability-ak-{ak.id}" + for ak in AK.objects.filter(event=event) + if self._is_restricted_and_contained_slot( + timeslot_avail, + Availability.union(ak.availabilities.all()), + ) + } + fulfilled_time_constraints |= { + f"fixed-akslot-{slot.id}" + for slot in self.ak_slots + if self._is_ak_fixed_in_slot(slot, timeslot_avail) + } + + fulfilled_time_constraints |= { + f"notblock{idx}" + for idx in range(num_blocks) + if idx != block_idx + } + + self.assertEqual( + fulfilled_time_constraints, + set(timeslot["fulfilled_time_constraints"]), + ) + + def test_timeslots_info(self): + """Test timeslots info dict""" + for event in Event.objects.all(): + with self.subTest(event=event): + self.set_up_event(event=event) + + self.assertAlmostEqual( + self.export_dict["timeslots"]["info"]["duration"], + float(self.event.export_slot), + ) + + block_names = [] + for block in self.export_dict["timeslots"]["blocks"]: + if not block: + continue + + block_start, _ = self._get_timeslot_start_end(block[0]) + _, block_end = self._get_timeslot_start_end(block[-1]) + + start_day = block_start.strftime("%A, %d. %b") + if block_start.date() == block_end.date(): + # same day + time_str = ( + block_start.strftime("%H:%M") + + " – " + + block_end.strftime("%H:%M") + ) + else: + # different days + time_str = ( + block_start.strftime("%a %H:%M") + + " – " + + block_end.strftime("%a %H:%M") + ) + block_names.append([start_day, time_str]) + self.assertEqual( + block_names, self.export_dict["timeslots"]["info"]["blocknames"] + ) diff --git a/AKModel/tests.py b/AKModel/tests/test_views.py similarity index 60% rename from AKModel/tests.py rename to AKModel/tests/test_views.py index fb24dc08a7891425c24d6017be0a51e25566e52d..639847458a866c62304164bc64b08570b8d151d5 100644 --- a/AKModel/tests.py +++ b/AKModel/tests/test_views.py @@ -7,8 +7,19 @@ from django.contrib.messages.storage.base import Message from django.test import TestCase from django.urls import reverse_lazy, reverse -from AKModel.models import Event, AKOwner, AKCategory, AKTrack, AKRequirement, AK, Room, AKSlot, AKOrgaMessage, \ - ConstraintViolation, DefaultSlot +from AKModel.models import ( + Event, + AKOwner, + AKCategory, + AKTrack, + AKRequirement, + AK, + Room, + AKSlot, + AKOrgaMessage, + ConstraintViolation, + DefaultSlot, +) class BasicViewTests: @@ -29,9 +40,10 @@ class BasicViewTests: since the test framework does not understand the concept of abstract test definitions and would handle this class as real test case otherwise, distorting the test results. """ + # pylint: disable=no-member VIEWS = [] - APP_NAME = '' + APP_NAME = "" VIEWS_STAFF_ONLY = [] EDIT_TESTCASES = [] @@ -41,16 +53,26 @@ class BasicViewTests: """ user_model = get_user_model() self.staff_user = user_model.objects.create( - username='Test Staff User', email='teststaff@example.com', password='staffpw', - is_staff=True, is_active=True + username="Test Staff User", + email="teststaff@example.com", + password="staffpw", + is_staff=True, + is_active=True, ) self.admin_user = user_model.objects.create( - username='Test Admin User', email='testadmin@example.com', password='adminpw', - is_staff=True, is_superuser=True, is_active=True + username="Test Admin User", + email="testadmin@example.com", + password="adminpw", + is_staff=True, + is_superuser=True, + is_active=True, ) self.deactivated_user = user_model.objects.create( - username='Test Deactivated User', email='testdeactivated@example.com', password='deactivatedpw', - is_staff=True, is_active=False + username="Test Deactivated User", + email="testdeactivated@example.com", + password="deactivatedpw", + is_staff=True, + is_active=False, ) def _name_and_url(self, view_name): @@ -62,7 +84,9 @@ class BasicViewTests: :return: full view name with prefix if applicable, url of the view :rtype: str, str """ - view_name_with_prefix = f"{self.APP_NAME}:{view_name[0]}" if self.APP_NAME != "" else view_name[0] + view_name_with_prefix = ( + f"{self.APP_NAME}:{view_name[0]}" if self.APP_NAME != "" else view_name[0] + ) url = reverse(view_name_with_prefix, kwargs=view_name[1]) return view_name_with_prefix, url @@ -74,7 +98,7 @@ class BasicViewTests: :param expected_message: message that should be shown :param msg_prefix: prefix for the error message when test fails """ - messages:List[Message] = list(get_messages(response.wsgi_request)) + messages: List[Message] = list(get_messages(response.wsgi_request)) msg_count = "No message shown to user" msg_content = "Wrong message, expected '{expected_message}'" @@ -95,10 +119,16 @@ class BasicViewTests: view_name_with_prefix, url = self._name_and_url(view_name) try: response = self.client.get(url) - self.assertEqual(response.status_code, 200, msg=f"{view_name_with_prefix} ({url}) broken") - except Exception: # pylint: disable=broad-exception-caught - self.fail(f"An error occurred during rendering of {view_name_with_prefix} ({url}):" - f"\n\n{traceback.format_exc()}") + self.assertEqual( + response.status_code, + 200, + msg=f"{view_name_with_prefix} ({url}) broken", + ) + except Exception: # pylint: disable=broad-exception-caught + self.fail( + f"An error occurred during rendering of {view_name_with_prefix} ({url}):" + f"\n\n{traceback.format_exc()}" + ) def test_access_control_staff_only(self): """ @@ -107,11 +137,16 @@ class BasicViewTests: # Not logged in? Views should not be visible self.client.logout() for view_name_info in self.VIEWS_STAFF_ONLY: - expected_response_code = 302 if len(view_name_info) == 2 else view_name_info[2] + expected_response_code = ( + 302 if len(view_name_info) == 2 else view_name_info[2] + ) view_name_with_prefix, url = self._name_and_url(view_name_info) response = self.client.get(url) - self.assertEqual(response.status_code, expected_response_code, - msg=f"{view_name_with_prefix} ({url}) accessible by non-staff") + self.assertEqual( + response.status_code, + expected_response_code, + msg=f"{view_name_with_prefix} ({url}) accessible by non-staff", + ) # Logged in? Views should be visible self.client.force_login(self.staff_user) @@ -119,20 +154,30 @@ class BasicViewTests: view_name_with_prefix, url = self._name_and_url(view_name_info) try: response = self.client.get(url) - self.assertEqual(response.status_code, 200, - msg=f"{view_name_with_prefix} ({url}) should be accessible for staff (but isn't)") + self.assertEqual( + response.status_code, + 200, + msg=f"{view_name_with_prefix} ({url}) should be accessible for staff (but isn't)", + ) except Exception: # pylint: disable=broad-exception-caught - self.fail(f"An error occurred during rendering of {view_name_with_prefix} ({url}):" - f"\n\n{traceback.format_exc()}") + self.fail( + f"An error occurred during rendering of {view_name_with_prefix} ({url}):" + f"\n\n{traceback.format_exc()}" + ) # Disabled user? Views should not be visible self.client.force_login(self.deactivated_user) for view_name_info in self.VIEWS_STAFF_ONLY: - expected_response_code = 302 if len(view_name_info) == 2 else view_name_info[2] + expected_response_code = ( + 302 if len(view_name_info) == 2 else view_name_info[2] + ) view_name_with_prefix, url = self._name_and_url(view_name_info) response = self.client.get(url) - self.assertEqual(response.status_code, expected_response_code, - msg=f"{view_name_with_prefix} ({url}) still accessible for deactivated user") + self.assertEqual( + response.status_code, + expected_response_code, + msg=f"{view_name_with_prefix} ({url}) still accessible for deactivated user", + ) def _to_sendable_value(self, val): """ @@ -182,16 +227,26 @@ class BasicViewTests: self.client.logout() response = self.client.get(url) - self.assertEqual(response.status_code, 200, msg=f"{name}: Could not load edit form via GET ({url})") + self.assertEqual( + response.status_code, + 200, + msg=f"{name}: Could not load edit form via GET ({url})", + ) form = response.context[form_name] - data = {k:self._to_sendable_value(v) for k,v in form.initial.items()} + data = {k: self._to_sendable_value(v) for k, v in form.initial.items()} response = self.client.post(url, data=data) if expected_code == 200: - self.assertEqual(response.status_code, 200, msg=f"{name}: Did not return 200 ({url}") + self.assertEqual( + response.status_code, 200, msg=f"{name}: Did not return 200 ({url}" + ) elif expected_code == 302: - self.assertRedirects(response, target_url, msg_prefix=f"{name}: Did not redirect ({url} -> {target_url}") + self.assertRedirects( + response, + target_url, + msg_prefix=f"{name}: Did not redirect ({url} -> {target_url}", + ) if expected_message != "": self._assert_message(response, expected_message, msg_prefix=f"{name}") @@ -200,30 +255,44 @@ class ModelViewTests(BasicViewTests, TestCase): """ Basic view test cases for views from AKModel plus some custom tests """ - fixtures = ['model.json'] + + fixtures = ["model.json"] ADMIN_MODELS = [ - (Event, 'event'), (AKOwner, 'akowner'), (AKCategory, 'akcategory'), (AKTrack, 'aktrack'), - (AKRequirement, 'akrequirement'), (AK, 'ak'), (Room, 'room'), (AKSlot, 'akslot'), - (AKOrgaMessage, 'akorgamessage'), (ConstraintViolation, 'constraintviolation'), - (DefaultSlot, 'defaultslot') + (Event, "event"), + (AKOwner, "akowner"), + (AKCategory, "akcategory"), + (AKTrack, "aktrack"), + (AKRequirement, "akrequirement"), + (AK, "ak"), + (Room, "room"), + (AKSlot, "akslot"), + (AKOrgaMessage, "akorgamessage"), + (ConstraintViolation, "constraintviolation"), + (DefaultSlot, "defaultslot"), ] VIEWS_STAFF_ONLY = [ - ('admin:index', {}), - ('admin:event_status', {'event_slug': 'kif42'}), - ('admin:event_requirement_overview', {'event_slug': 'kif42'}), - ('admin:ak_csv_export', {'event_slug': 'kif42'}), - ('admin:ak_wiki_export', {'slug': 'kif42'}), - ('admin:ak_delete_orga_messages', {'event_slug': 'kif42'}), - ('admin:ak_slide_export', {'event_slug': 'kif42'}), - ('admin:default-slots-editor', {'event_slug': 'kif42'}), - ('admin:room-import', {'event_slug': 'kif42'}), - ('admin:new_event_wizard_start', {}), + ("admin:index", {}), + ("admin:event_status", {"event_slug": "kif42"}), + ("admin:event_requirement_overview", {"event_slug": "kif42"}), + ("admin:ak_csv_export", {"event_slug": "kif42"}), + ("admin:ak_json_export", {"event_slug": "kif42"}), + ("admin:ak_wiki_export", {"slug": "kif42"}), + ("admin:ak_schedule_json_import", {"event_slug": "kif42"}), + ("admin:ak_delete_orga_messages", {"event_slug": "kif42"}), + ("admin:ak_slide_export", {"event_slug": "kif42"}), + ("admin:default-slots-editor", {"event_slug": "kif42"}), + ("admin:room-import", {"event_slug": "kif42"}), + ("admin:new_event_wizard_start", {}), ] EDIT_TESTCASES = [ - {'view': 'admin:default-slots-editor', 'kwargs': {'event_slug': 'kif42'}, "admin": True}, + { + "view": "admin:default-slots-editor", + "kwargs": {"event_slug": "kif42"}, + "admin": True, + }, ] def test_admin(self): @@ -234,24 +303,32 @@ class ModelViewTests(BasicViewTests, TestCase): for model in self.ADMIN_MODELS: # Special treatment for a subset of views (where we exchanged default functionality, e.g., create views) if model[1] == "event": - _, url = self._name_and_url(('admin:new_event_wizard_start', {})) + _, url = self._name_and_url(("admin:new_event_wizard_start", {})) elif model[1] == "room": - _, url = self._name_and_url(('admin:room-new', {})) + _, url = self._name_and_url(("admin:room-new", {})) # Otherwise, just call the creation form view else: - _, url = self._name_and_url((f'admin:AKModel_{model[1]}_add', {})) + _, url = self._name_and_url((f"admin:AKModel_{model[1]}_add", {})) response = self.client.get(url) - self.assertEqual(response.status_code, 200, msg=f"Add form for model {model[1]} ({url}) broken") + self.assertEqual( + response.status_code, + 200, + msg=f"Add form for model {model[1]} ({url}) broken", + ) for model in self.ADMIN_MODELS: # Test the update view using the first existing instance of each model m = model[0].objects.first() if m is not None: _, url = self._name_and_url( - (f'admin:AKModel_{model[1]}_change', {'object_id': m.pk}) + (f"admin:AKModel_{model[1]}_change", {"object_id": m.pk}) ) response = self.client.get(url) - self.assertEqual(response.status_code, 200, msg=f"Edit form for model {model[1]} ({url}) broken") + self.assertEqual( + response.status_code, + 200, + msg=f"Edit form for model {model[1]} ({url}) broken", + ) def test_wiki_export(self): """ @@ -260,17 +337,27 @@ class ModelViewTests(BasicViewTests, TestCase): """ self.client.force_login(self.admin_user) - export_url = reverse_lazy("admin:ak_wiki_export", kwargs={'slug': 'kif42'}) + export_url = reverse_lazy("admin:ak_wiki_export", kwargs={"slug": "kif42"}) response = self.client.get(export_url) self.assertEqual(response.status_code, 200, "Export not working at all") export_count = 0 for _, aks in response.context["categories_with_aks"]: for ak in aks: - self.assertEqual(ak.include_in_export, True, - f"AK with export flag set to False (pk={ak.pk}) included in export") - self.assertNotEqual(ak.pk, 1, "AK known to be excluded from export (PK 1) included in export") + self.assertEqual( + ak.include_in_export, + True, + f"AK with export flag set to False (pk={ak.pk}) included in export", + ) + self.assertNotEqual( + ak.pk, + 1, + "AK known to be excluded from export (PK 1) included in export", + ) export_count += 1 - self.assertEqual(export_count, AK.objects.filter(event_id=2, include_in_export=True).count(), - "Wiki export contained the wrong number of AKs") + self.assertEqual( + export_count, + AK.objects.filter(event_id=2, include_in_export=True).count(), + "Wiki export contained the wrong number of AKs", + ) diff --git a/AKModel/urls.py b/AKModel/urls.py index 3abf646058afdf71c7938032236942e7bb0ed995..9c10340546b787c92cbd02cb2c3dbbeb6fe1ff94 100644 --- a/AKModel/urls.py +++ b/AKModel/urls.py @@ -5,8 +5,9 @@ from rest_framework.routers import DefaultRouter import AKModel.views.api from AKModel.views.manage import ExportSlidesView, PlanPublishView, PlanUnpublishView, DefaultSlotEditorView, \ - AKsByUserView -from AKModel.views.ak import AKRequirementOverview, AKCSVExportView, AKWikiExportView, AKMessageDeleteView + AKsByUserView, AKScheduleJSONImportView +from AKModel.views.ak import AKRequirementOverview, AKCSVExportView, AKJSONExportView, AKWikiExportView, \ + AKMessageDeleteView from AKModel.views.event_wizard import NewEventWizardStartView, NewEventWizardPrepareImportView, \ NewEventWizardImportView, NewEventWizardActivateView, NewEventWizardFinishView, NewEventWizardSettingsView from AKModel.views.room import RoomBatchCreationView @@ -96,6 +97,10 @@ def get_admin_urls_event(admin_site): name="aks_by_owner"), path('<slug:event_slug>/ak-csv-export/', admin_site.admin_view(AKCSVExportView.as_view()), name="ak_csv_export"), + path('<slug:event_slug>/ak-json-export/', admin_site.admin_view(AKJSONExportView.as_view()), + name="ak_json_export"), + path('<slug:event_slug>/ak-schedule-json-import/', admin_site.admin_view(AKScheduleJSONImportView.as_view()), + name="ak_schedule_json_import"), path('<slug:slug>/ak-wiki-export/', admin_site.admin_view(AKWikiExportView.as_view()), name="ak_wiki_export"), path('<slug:event_slug>/delete-orga-messages/', admin_site.admin_view(AKMessageDeleteView.as_view()), diff --git a/AKModel/views/ak.py b/AKModel/views/ak.py index 3afec5ac2de03a62ad3d103d17062927c3b97026..461edd3a92402d706e3190899f76317dc13dae1c 100644 --- a/AKModel/views/ak.py +++ b/AKModel/views/ak.py @@ -1,3 +1,5 @@ +import json + from django.contrib import messages from django.urls import reverse_lazy from django.utils.translation import gettext_lazy as _ @@ -37,6 +39,25 @@ class AKCSVExportView(AdminViewMixin, FilterByEventSlugMixin, ListView): return super().get_queryset().order_by("ak__track") +class AKJSONExportView(AdminViewMixin, DetailView): + """ + View: Export all AK slots of this event in JSON format ordered by tracks + """ + template_name = "admin/AKModel/ak_json_export.html" + model = Event + context_object_name = "event" + title = _("AK JSON Export") + slug_url_kwarg = "event_slug" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + data = context["event"].as_json_dict() + context["json_data_oneline"] = json.dumps(data) + context["json_data"] = json.dumps(data, indent=2) + + return context + class AKWikiExportView(AdminViewMixin, DetailView): """ View: Export AKs of this event in wiki syntax diff --git a/AKModel/views/manage.py b/AKModel/views/manage.py index 64443cb8f7de27832f518df75378f1fc2ea59571..3acb05fd29a6e91cd17f45e9ed43d889a67da22c 100644 --- a/AKModel/views/manage.py +++ b/AKModel/views/manage.py @@ -4,15 +4,17 @@ import os import tempfile from itertools import zip_longest + from django.contrib import messages from django.db.models.functions import Now +from django.shortcuts import redirect from django.utils.dateparse import parse_datetime from django.utils.translation import gettext_lazy as _ from django.views.generic import TemplateView, DetailView from django_tex.core import render_template_with_context, run_tex_in_directory from django_tex.response import PDFResponse -from AKModel.forms import SlideExportForm, DefaultSlotEditorForm +from AKModel.forms import SlideExportForm, DefaultSlotEditorForm, JSONScheduleImportForm from AKModel.metaviews.admin import EventSlugMixin, IntermediateAdminView, IntermediateAdminActionView, AdminViewMixin from AKModel.models import ConstraintViolation, Event, DefaultSlot, AKOwner @@ -58,7 +60,7 @@ class ExportSlidesView(EventSlugMixin, IntermediateAdminView): Create a list of tuples cosisting of an AK and a list of upcoming AKs (list length depending on setting) """ next_aks_list = zip_longest(*[ak_list[i + 1:] for i in range(NEXT_AK_LIST_LENGTH)], fillvalue=None) - return [(ak, next_aks) for ak, next_aks in zip_longest(ak_list, next_aks_list, fillvalue=[])] + return list(zip_longest(ak_list, next_aks_list, fillvalue=[])) # Get all relevant AKs (wishes separately, and either all AKs or only those who should directly or indirectly # be presented when restriction setting was chosen) @@ -245,3 +247,28 @@ class AKsByUserView(AdminViewMixin, EventSlugMixin, DetailView): model = AKOwner context_object_name = 'owner' template_name = "admin/AKModel/aks_by_user.html" + + +class AKScheduleJSONImportView(EventSlugMixin, IntermediateAdminView): + """ + View: Import an AK schedule from a json file that can be pasted into this view. + """ + form_class = JSONScheduleImportForm + title = _("AK Schedule JSON Import") + + def form_valid(self, form): + try: + number_of_slots_changed = self.event.schedule_from_json(form.data["json_data"]) + messages.add_message( + self.request, + messages.SUCCESS, + _("Successfully imported {n} slot(s)").format(n=number_of_slots_changed) + ) + except ValueError as ex: + messages.add_message( + self.request, + messages.ERROR, + _("Importing an AK schedule failed! Reason: ") + str(ex), + ) + + return redirect("admin:event_status", self.event.slug) diff --git a/AKModel/views/status.py b/AKModel/views/status.py index 233d4cc9be571db5b4a1ee31a607d1fa04b9305e..0cb26c3cc80fdae32ea3870222f54bd6640a0fad 100644 --- a/AKModel/views/status.py +++ b/AKModel/views/status.py @@ -138,10 +138,18 @@ class EventAKsWidget(TemplateStatusWidget): "text": _("Manage ak tracks"), "url": reverse_lazy("admin:tracks_manage", kwargs={"event_slug": context["event"].slug}), }, + { + "text": _("Import AK schedule from JSON"), + "url": reverse_lazy("admin:ak_schedule_json_import", kwargs={"event_slug": context["event"].slug}), + }, { "text": _("Export AKs as CSV"), "url": reverse_lazy("admin:ak_csv_export", kwargs={"event_slug": context["event"].slug}), }, + { + "text": _("Export AKs as JSON"), + "url": reverse_lazy("admin:ak_json_export", kwargs={"event_slug": context["event"].slug}), + }, { "text": _("Export AKs for Wiki"), "url": reverse_lazy("admin:ak_wiki_export", kwargs={"slug": context["event"].slug}), diff --git a/AKPlan/locale/de_DE/LC_MESSAGES/django.po b/AKPlan/locale/de_DE/LC_MESSAGES/django.po index 90ee78c19d2da3dc385e336b58a4b37d6b76c000..1bbfe72524190a8feb747dfe0e0c2e6ae88d09a6 100644 --- a/AKPlan/locale/de_DE/LC_MESSAGES/django.po +++ b/AKPlan/locale/de_DE/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-05-15 20:03+0200\n" +"POT-Creation-Date: 2024-05-27 01:57+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" @@ -38,7 +38,7 @@ msgstr "Veranstaltung" #: AKPlan/templates/AKPlan/plan_index.html:59 #: AKPlan/templates/AKPlan/plan_room.html:13 #: AKPlan/templates/AKPlan/plan_room.html:59 -#: AKPlan/templates/AKPlan/plan_wall.html:65 +#: AKPlan/templates/AKPlan/plan_wall.html:67 msgid "Room" msgstr "Raum" @@ -63,12 +63,12 @@ msgid "AK Wall" msgstr "AK-Wall" #: AKPlan/templates/AKPlan/plan_index.html:130 -#: AKPlan/templates/AKPlan/plan_wall.html:130 +#: AKPlan/templates/AKPlan/plan_wall.html:132 msgid "Current AKs" msgstr "Aktuelle AKs" #: AKPlan/templates/AKPlan/plan_index.html:137 -#: AKPlan/templates/AKPlan/plan_wall.html:135 +#: AKPlan/templates/AKPlan/plan_wall.html:137 msgid "Next AKs" msgstr "Nächste AKs" @@ -99,7 +99,7 @@ msgstr "Eigenschaften" msgid "Track" msgstr "Track" -#: AKPlan/templates/AKPlan/plan_wall.html:145 +#: AKPlan/templates/AKPlan/plan_wall.html:147 msgid "Reload page automatically?" msgstr "Seite automatisch neu laden?" diff --git a/AKPlan/tests.py b/AKPlan/tests.py index 69365c2ba783311708a7babde1fda534c978b61c..3f00061af127307d8c5b64bed7b6c1ffa4d4eb82 100644 --- a/AKPlan/tests.py +++ b/AKPlan/tests.py @@ -1,6 +1,6 @@ from django.test import TestCase -from AKModel.tests import BasicViewTests +from AKModel.tests.test_views import BasicViewTests class PlanViewTests(BasicViewTests, TestCase): diff --git a/AKPlanning/locale/de_DE/LC_MESSAGES/django.po b/AKPlanning/locale/de_DE/LC_MESSAGES/django.po index 3f47223fa6145f6d4c55f87c6f4c1c660f2bd311..68529ec4e06f679135124da17e2aa86e6d2757a7 100644 --- a/AKPlanning/locale/de_DE/LC_MESSAGES/django.po +++ b/AKPlanning/locale/de_DE/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-08-16 16:30+0200\n" +"POT-Creation-Date: 2024-05-27 01:57+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" @@ -17,10 +17,10 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: AKPlanning/settings.py:148 +#: AKPlanning/settings.py:147 msgid "German" msgstr "Deutsch" -#: AKPlanning/settings.py:149 +#: AKPlanning/settings.py:148 msgid "English" msgstr "Englisch" diff --git a/AKScheduling/api.py b/AKScheduling/api.py index e78fda781df734d93edc001ae7933152a536e92e..cfd476ec7e112f5350b9814e29f3d037da47d9c9 100644 --- a/AKScheduling/api.py +++ b/AKScheduling/api.py @@ -55,7 +55,9 @@ class EventsView(LoginRequiredMixin, EventSlugMixin, ListView): model = AKSlot def get_queryset(self): - return super().get_queryset().select_related('ak').filter(event=self.event, room__isnull=False) + return super().get_queryset().select_related('ak').filter( + event=self.event, room__isnull=False, start__isnull=False + ) def render_to_response(self, context, **response_kwargs): return JsonResponse( diff --git a/AKScheduling/locale/de_DE/LC_MESSAGES/django.po b/AKScheduling/locale/de_DE/LC_MESSAGES/django.po index e66d8d55bcda42f3271b2400d6b98cf84f64ee1d..3944e8722fe004177ef48f6119fa1bd5dd6e38d3 100644 --- a/AKScheduling/locale/de_DE/LC_MESSAGES/django.po +++ b/AKScheduling/locale/de_DE/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-04-25 00:24+0200\n" +"POT-Creation-Date: 2025-03-04 14:49+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" @@ -27,14 +27,14 @@ msgstr "Ende" #: AKScheduling/forms.py:26 msgid "Duration" -msgstr "" +msgstr "Dauer" -#: AKScheduling/forms.py:27 -#: AKScheduling/templates/admin/AKScheduling/scheduling.html:171 +#: AKScheduling/forms.py:27 AKScheduling/forms.py:28 +#: AKScheduling/templates/admin/AKScheduling/scheduling.html:172 msgid "Room" msgstr "Raum" -#: AKScheduling/forms.py:31 +#: AKScheduling/forms.py:32 msgid "AK" msgstr "AK" @@ -62,13 +62,13 @@ msgstr "" #: AKScheduling/templates/admin/AKScheduling/constraint_violations.html:44 #: AKScheduling/templates/admin/AKScheduling/constraint_violations.html:105 -#: AKScheduling/templates/admin/AKScheduling/scheduling.html:240 -#: AKScheduling/templates/admin/AKScheduling/scheduling.html:375 +#: AKScheduling/templates/admin/AKScheduling/scheduling.html:241 +#: AKScheduling/templates/admin/AKScheduling/scheduling.html:378 msgid "No violations" msgstr "Keine Verletzungen" #: AKScheduling/templates/admin/AKScheduling/constraint_violations.html:82 -#: AKScheduling/templates/admin/AKScheduling/scheduling.html:346 +#: AKScheduling/templates/admin/AKScheduling/scheduling.html:347 msgid "Violation(s)" msgstr "Verletzung(en)" @@ -81,12 +81,12 @@ msgid "Reload now" msgstr "Jetzt neu laden" #: AKScheduling/templates/admin/AKScheduling/constraint_violations.html:95 -#: AKScheduling/templates/admin/AKScheduling/scheduling.html:228 +#: AKScheduling/templates/admin/AKScheduling/scheduling.html:229 msgid "Violation" msgstr "Verletzung" #: AKScheduling/templates/admin/AKScheduling/constraint_violations.html:96 -#: AKScheduling/templates/admin/AKScheduling/scheduling.html:369 +#: AKScheduling/templates/admin/AKScheduling/scheduling.html:372 msgid "Problem" msgstr "Problem" @@ -100,8 +100,8 @@ msgstr "Seit" #: AKScheduling/templates/admin/AKScheduling/constraint_violations.html:111 #: AKScheduling/templates/admin/AKScheduling/manage_tracks.html:256 -#: AKScheduling/templates/admin/AKScheduling/scheduling.html:332 -#: AKScheduling/templates/admin/AKScheduling/special_attention.html:48 +#: AKScheduling/templates/admin/AKScheduling/scheduling.html:333 +#: AKScheduling/templates/admin/AKScheduling/special_attention.html:58 #: AKScheduling/templates/admin/AKScheduling/unscheduled.html:34 msgid "Event Status" msgstr "Event-Status" @@ -116,7 +116,7 @@ msgstr "Abschicken" #: AKScheduling/templates/admin/AKScheduling/manage_tracks.html:11 #: AKScheduling/templates/admin/AKScheduling/scheduling.html:21 -#: AKScheduling/templates/admin/AKScheduling/scheduling.html:329 +#: AKScheduling/templates/admin/AKScheduling/scheduling.html:330 msgid "Scheduling for" msgstr "Scheduling für" @@ -168,31 +168,31 @@ msgstr "Event (horizontal)" msgid "Event (Vertical)" msgstr "Event (vertikal)" -#: AKScheduling/templates/admin/AKScheduling/scheduling.html:271 +#: AKScheduling/templates/admin/AKScheduling/scheduling.html:272 msgid "Please choose AK" msgstr "Bitte AK auswählen" -#: AKScheduling/templates/admin/AKScheduling/scheduling.html:291 +#: AKScheduling/templates/admin/AKScheduling/scheduling.html:292 msgid "Could not create slot" msgstr "Konnte Slot nicht anlegen" -#: AKScheduling/templates/admin/AKScheduling/scheduling.html:307 +#: AKScheduling/templates/admin/AKScheduling/scheduling.html:308 msgid "Add slot" msgstr "Slot hinzufügen" -#: AKScheduling/templates/admin/AKScheduling/scheduling.html:315 +#: AKScheduling/templates/admin/AKScheduling/scheduling.html:316 msgid "Add" msgstr "Hinzufügen" -#: AKScheduling/templates/admin/AKScheduling/scheduling.html:316 +#: AKScheduling/templates/admin/AKScheduling/scheduling.html:317 msgid "Cancel" msgstr "Abbrechen" -#: AKScheduling/templates/admin/AKScheduling/scheduling.html:343 +#: AKScheduling/templates/admin/AKScheduling/scheduling.html:344 msgid "Unscheduled" msgstr "Nicht gescheduled" -#: AKScheduling/templates/admin/AKScheduling/scheduling.html:368 +#: AKScheduling/templates/admin/AKScheduling/scheduling.html:371 msgid "Level" msgstr "Level" @@ -200,23 +200,23 @@ msgstr "Level" msgid "AKs with public notes" msgstr "AKs mit öffentlichen Kommentaren" -#: AKScheduling/templates/admin/AKScheduling/special_attention.html:21 +#: AKScheduling/templates/admin/AKScheduling/special_attention.html:24 msgid "AKs without availabilities" msgstr "AKs ohne Verfügbarkeiten" -#: AKScheduling/templates/admin/AKScheduling/special_attention.html:28 +#: AKScheduling/templates/admin/AKScheduling/special_attention.html:33 msgid "Create default availabilities" msgstr "Standardverfügbarkeiten anlegen" -#: AKScheduling/templates/admin/AKScheduling/special_attention.html:31 +#: AKScheduling/templates/admin/AKScheduling/special_attention.html:36 msgid "AK wishes with slots" msgstr "AK-Wünsche mit Slots" -#: AKScheduling/templates/admin/AKScheduling/special_attention.html:38 +#: AKScheduling/templates/admin/AKScheduling/special_attention.html:46 msgid "Delete slots for wishes" msgstr "" -#: AKScheduling/templates/admin/AKScheduling/special_attention.html:40 +#: AKScheduling/templates/admin/AKScheduling/special_attention.html:48 msgid "AKs without slots" msgstr "AKs ohne Slots" @@ -246,19 +246,19 @@ msgstr "Noch nicht geschedulte AK-Slots" msgid "Count" msgstr "Anzahl" -#: AKScheduling/views.py:150 +#: AKScheduling/views.py:152 msgid "Interest updated" msgstr "Interesse aktualisiert" -#: AKScheduling/views.py:201 +#: AKScheduling/views.py:210 msgid "Wishes" msgstr "Wünsche" -#: AKScheduling/views.py:219 +#: AKScheduling/views.py:228 msgid "Cleanup: Delete unscheduled slots for wishes" msgstr "Aufräumen: Noch nicht geplante Slots für Wünsche löschen" -#: AKScheduling/views.py:226 +#: AKScheduling/views.py:235 #, python-brace-format msgid "" "The following {count} unscheduled slots of wishes will be deleted:\n" @@ -270,15 +270,15 @@ msgstr "" "\n" " {slots}" -#: AKScheduling/views.py:233 +#: AKScheduling/views.py:242 msgid "Unscheduled slots for wishes successfully deleted" msgstr "Noch nicht geplante Slots für Wünsche erfolgreich gelöscht" -#: AKScheduling/views.py:247 +#: AKScheduling/views.py:256 msgid "Create default availabilities for AKs" msgstr "Standardverfügbarkeiten für AKs anlegen" -#: AKScheduling/views.py:254 +#: AKScheduling/views.py:263 #, python-brace-format msgid "" "The following {count} AKs don't have any availability information. Create " @@ -291,20 +291,29 @@ msgstr "" "\n" " {aks}" -#: AKScheduling/views.py:274 +#: AKScheduling/views.py:283 #, python-brace-format msgid "Could not create default availabilities for AK: {ak}" msgstr "Konnte keine Verfügbarkeit anlegen für AK: {ak}" -#: AKScheduling/views.py:279 +#: AKScheduling/views.py:288 #, python-brace-format msgid "Created default availabilities for {count} AKs" msgstr "Standardverfügbarkeiten für {count} AKs angelegt" -#: AKScheduling/views.py:290 +#: AKScheduling/views.py:299 msgid "Constraint Violations" msgstr "Constraintverletzungen" +#~ msgid "Constraint violations for" +#~ msgstr "Constraintverletzungen für" + +#~ msgid "AKs requiring special attention for" +#~ msgstr "AKs die besondere Aufmerksamkeit erfordern für" + +#~ msgid "Enter interest" +#~ msgstr "Interesse eingeben" + #~ msgid "Bitte AK auswählen" #~ msgstr "Please sel" diff --git a/AKScheduling/models.py b/AKScheduling/models.py index 1495f311935583a945aef0d737ca44e21a0a2663..8f34c151d4616c5a709057f5b529c9555d08c8b0 100644 --- a/AKScheduling/models.py +++ b/AKScheduling/models.py @@ -288,6 +288,8 @@ def ak_requirements_changed_handler(sender, instance: AK, action: str, **kwargs) for slot in slots_of_this_ak: room = slot.room + if room is None: + continue room_requirements = room.properties.all() for requirement in instance.requirements.all(): @@ -363,8 +365,8 @@ def akslot_changed_handler(sender, instance: AKSlot, **kwargs): new_violations = [] # For all slots in this room... - if instance.room: - for other_slot in instance.room.akslot_set.all(): + if instance.room and instance.start: + for other_slot in instance.room.akslot_set.filter(start__isnull=False): if other_slot != instance: # ... find overlapping slots... if instance.overlaps(other_slot): diff --git a/AKScheduling/tests.py b/AKScheduling/tests.py index 0996eedd905259f0f463589a89f68bde055bc01a..44f25719233cab9eaf4316c6268cf7145989e3cf 100644 --- a/AKScheduling/tests.py +++ b/AKScheduling/tests.py @@ -4,7 +4,7 @@ from datetime import timedelta from django.test import TestCase from django.utils import timezone -from AKModel.tests import BasicViewTests +from AKModel.tests.test_views import BasicViewTests from AKModel.models import AKSlot, Event, Room class ModelViewTests(BasicViewTests, TestCase): diff --git a/AKSubmission/locale/de_DE/LC_MESSAGES/django.po b/AKSubmission/locale/de_DE/LC_MESSAGES/django.po index f3a20fd7f478292d140ee405f78490d60fac7df7..1ccfaeb613e93c43db06c116b542e49efca85658 100644 --- a/AKSubmission/locale/de_DE/LC_MESSAGES/django.po +++ b/AKSubmission/locale/de_DE/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-25 22:33+0100\n" +"POT-Creation-Date: 2025-03-04 14:49+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" @@ -109,21 +109,25 @@ msgstr "AK-Wunsch" #: AKSubmission/templates/AKSubmission/ak_detail.html:186 #, python-format msgid "" -"This AK currently takes place for another <span v-html=\"timeUntilEnd\">" -"%(featured_slot_remaining)s</span> minute(s) in %(room)s. " +"This AK currently takes place for another <span v-" +"html=\"timeUntilEnd\">%(featured_slot_remaining)s</span> minute(s) in " +"%(room)s. " msgstr "" -"Dieser AK findet noch <span v-html=\"timeUntilEnd\">" -"%(featured_slot_remaining)s</span> Minute(n) in %(room)s statt. \n" +"Dieser AK findet noch <span v-" +"html=\"timeUntilEnd\">%(featured_slot_remaining)s</span> Minute(n) in " +"%(room)s statt. \n" " " #: AKSubmission/templates/AKSubmission/ak_detail.html:189 #, python-format msgid "" -"This AK starts in <span v-html=\"timeUntilStart\">" -"%(featured_slot_remaining)s</span> minute(s) in %(room)s. " +"This AK starts in <span v-" +"html=\"timeUntilStart\">%(featured_slot_remaining)s</span> minute(s) in " +"%(room)s. " msgstr "" -"Dieser AK beginnt in <span v-html=\"timeUntilStart\">" -"%(featured_slot_remaining)s</span> Minute(n) in %(room)s. \n" +"Dieser AK beginnt in <span v-" +"html=\"timeUntilStart\">%(featured_slot_remaining)s</span> Minute(n) in " +"%(room)s. \n" " " #: AKSubmission/templates/AKSubmission/ak_detail.html:194 @@ -274,7 +278,7 @@ msgstr "Die Ergebnisse dieses AKs vorstellen" msgid "Intends to submit a resolution" msgstr "Beabsichtigt eine Resolution einzureichen" -#: AKSubmission/templates/AKSubmission/ak_list.html:6 AKSubmission/views.py:84 +#: AKSubmission/templates/AKSubmission/ak_list.html:6 AKSubmission/views.py:82 msgid "All AKs" msgstr "Alle AKs" @@ -400,77 +404,80 @@ msgstr "" msgid "Submit" msgstr "Eintragen" -#: AKSubmission/views.py:127 +#: AKSubmission/views.py:125 msgid "Wishes" msgstr "Wünsche" -#: AKSubmission/views.py:127 +#: AKSubmission/views.py:125 msgid "AKs one would like to have" msgstr "" "AKs die sich gewünscht wurden, aber bei denen noch nicht klar ist, wer sie " "macht. Falls du dir das vorstellen kannst, trag dich einfach ein" -#: AKSubmission/views.py:169 +#: AKSubmission/views.py:167 msgid "Currently planned AKs" msgstr "Aktuell geplante AKs" -#: AKSubmission/views.py:302 +#: AKSubmission/views.py:305 msgid "Event inactive. Cannot create or update." msgstr "Event inaktiv. Hinzufügen/Bearbeiten nicht möglich." -#: AKSubmission/views.py:327 +#: AKSubmission/views.py:330 msgid "AK successfully created" msgstr "AK erfolgreich angelegt" -#: AKSubmission/views.py:400 +#: AKSubmission/views.py:404 msgid "AK successfully updated" msgstr "AK erfolgreich aktualisiert" -#: AKSubmission/views.py:451 +#: AKSubmission/views.py:455 #, python-brace-format msgid "Added '{owner}' as new owner of '{ak.name}'" msgstr "'{owner}' als neue Leitung von '{ak.name}' hinzugefügt" -#: AKSubmission/views.py:555 +#: AKSubmission/views.py:558 msgid "No user selected" msgstr "Keine Person ausgewählt" -#: AKSubmission/views.py:571 +#: AKSubmission/views.py:574 msgid "Person Info successfully updated" msgstr "Personen-Info erfolgreich aktualisiert" -#: AKSubmission/views.py:607 +#: AKSubmission/views.py:610 msgid "AK Slot successfully added" msgstr "AK-Slot erfolgreich angelegt" -#: AKSubmission/views.py:626 +#: AKSubmission/views.py:629 msgid "You cannot edit a slot that has already been scheduled" msgstr "Bereits geplante AK-Slots können nicht mehr bearbeitet werden" -#: AKSubmission/views.py:636 +#: AKSubmission/views.py:639 msgid "AK Slot successfully updated" msgstr "AK-Slot erfolgreich aktualisiert" -#: AKSubmission/views.py:654 +#: AKSubmission/views.py:657 msgid "You cannot delete a slot that has already been scheduled" msgstr "Bereits geplante AK-Slots können nicht mehr gelöscht werden" -#: AKSubmission/views.py:664 +#: AKSubmission/views.py:667 msgid "AK Slot successfully deleted" msgstr "AK-Slot erfolgreich angelegt" -#: AKSubmission/views.py:676 +#: AKSubmission/views.py:679 msgid "Messages" msgstr "Nachrichten" -#: AKSubmission/views.py:686 +#: AKSubmission/views.py:689 msgid "Delete all messages" msgstr "Alle Nachrichten löschen" -#: AKSubmission/views.py:713 +#: AKSubmission/views.py:716 msgid "Message to organizers successfully saved" msgstr "Nachricht an die Organisator*innen erfolgreich gespeichert" +#~ msgid "AKs with Track" +#~ msgstr "AK mit Track" + #~ msgid "" #~ "Due to technical reasons, the link you entered was truncated to a length " #~ "of 200 characters" diff --git a/AKSubmission/tests.py b/AKSubmission/tests.py index b3da0e6c431f964d1de0884034c2b19d387b04a0..fd31454391bbc15ed42ba5e77a1e6da5b04f1461 100644 --- a/AKSubmission/tests.py +++ b/AKSubmission/tests.py @@ -4,7 +4,7 @@ from django.test import TestCase from django.urls import reverse_lazy from AKModel.models import AK, AKSlot, Event -from AKModel.tests import BasicViewTests +from AKModel.tests.test_views import BasicViewTests from AKSubmission.forms import AKSubmissionForm diff --git a/INSTALL.md b/INSTALL.md index cbf8ea28d4a6954f7d359b7dd678df2709d7864a..4c98ce7b4bdbfdbc21ac3429199ff4a5786e1294 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -10,7 +10,7 @@ setup. ### System Requirements -* Python3.11+ incl. development tools +* Python 3.11+ incl. development tools * Virtualenv * pdflatex & beamer class (`texlive-latex-base texlive-latex-recommended texlive-latex-extra texlive-fonts-extra texlive-luatex`) @@ -37,7 +37,7 @@ Python requirements are listed in ``requirements.txt``. They can be installed wi ### Manual Setup -1. setup a virtual environment using the proper python version ``virtualenv venv -p python3.7`` +1. setup a virtual environment using the proper python version ``virtualenv venv -p python3.11`` 1. activate virtualenv ``source venv/bin/activate`` 1. install python requirements ``pip install -r requirements.txt`` 1. setup necessary database tables etc. ``python manage.py migrate`` @@ -68,7 +68,7 @@ is not stored in any repository or similar, and disable DEBUG mode (``settings.p 1. create a folder, e.g. ``mkdir /srv/AKPlanning/`` 1. change to the new directory ``cd /srv/AKPlanning/`` 1. clone this repository ``git clone URL .`` -1. setup a virtual environment using the proper python version ``virtualenv venv -p python3.7`` +1. setup a virtual environment using the proper python version ``virtualenv venv -p python3.11`` 1. activate virtualenv ``source venv/bin/activate`` 1. update tools ``pip install --upgrade setuptools pip wheel`` 1. install python requirements ``pip install -r requirements.txt`` diff --git a/Utils/check.sh b/Utils/check.sh index a8a9d4b7a9d6e78e7d93cabecb6bd4f36af4f89f..1cdbdf7d85687b820c0a16f98217d367ac8b3ccf 100755 --- a/Utils/check.sh +++ b/Utils/check.sh @@ -19,7 +19,6 @@ for arg in "$@"; do if [[ "$arg" == "--prod" ]]; then export DJANGO_SETTINGS_MODULE=AKPlanning.settings_production ./manage.py check --deploy - ./manage.py makemigrations --dry-run --check fi done diff --git a/Utils/update.sh b/Utils/update.sh index a711b73b912fcd07e8543b6b49d4eb8950ff0d79..d81aae52947404ac629050ba2c2d69c19e4b37e5 100755 --- a/Utils/update.sh +++ b/Utils/update.sh @@ -19,7 +19,7 @@ fi mkdir -p backups/ python manage.py dumpdata --indent=2 > "backups/$(date +"%Y%m%d%H%M")_datadump.json" --traceback -git pull +# git pull pip install --upgrade setuptools pip wheel pip install --upgrade -r requirements.txt diff --git a/locale/de_DE/LC_MESSAGES/django.po b/locale/de_DE/LC_MESSAGES/django.po index c1bbd2db4e5c22f703d2acc6a7388eb37116792b..67103fbfa9c4d81665a77037fd3cb432b1a117f2 100644 --- a/locale/de_DE/LC_MESSAGES/django.po +++ b/locale/de_DE/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-08-16 16:30+0200\n" +"POT-Creation-Date: 2025-02-27 15:13+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" @@ -18,24 +18,24 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" -#: templates/base.html:43 +#: templates/base.html:44 msgid "" "Are you sure you want to change the language now? This will clear the form!" msgstr "Wirklich jetzt die Sprache ändern? Das wird das Formular zurücksetzen!" -#: templates/base.html:108 +#: templates/base.html:109 msgid "Go to backend" msgstr "Zum Backend" -#: templates/base.html:109 +#: templates/base.html:110 msgid "Docs" msgstr "Doku" -#: templates/base.html:115 +#: templates/base.html:116 msgid "Impress" msgstr "Impressum" -#: templates/base.html:118 +#: templates/base.html:119 msgid "This software is open source" msgstr "Diese Software ist Open Source" diff --git a/requirements.txt b/requirements.txt index 9b0938d4a76aff97222757ebd6ac30ac3efef24a..be46f86ad6caa490c27ac703e3de38154a542b6b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,9 +15,13 @@ django_csp==3.8 djangorestframework==3.15.2 fontawesomefree==6.6.0 # Makes static files (css, fonts) available locally -mysqlclient==2.2.7 # for production deployment +# mysqlclient==2.2.7 # for production deployment tzdata==2025.1 +# Tests +beautifulsoup4==4.13.3 +lxml==5.3.1 + # Documentation Sphinx==8.2.3 sphinx-rtd-theme==3.0.2