Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • konstantin/akplanning
  • matedealer/akplanning
  • kif/akplanning
  • mirco/akplanning
  • lordofthevoid/akplanning
  • voidptr/akplanning
  • xayomer/akplanning-fork
  • mollux/akplanning
  • neumantm/akplanning
  • mmarx/akplanning
  • nerf/akplanning
  • felix_bonn/akplanning
  • sebastian.uschmann/akplanning
13 results
Show changes
Commits on Source (123)
Showing
with 2304 additions and 329 deletions
image: python:3.9
image: python:3.10
services:
- mysql
......@@ -38,7 +38,7 @@ test:
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
- pip install pytest-cov unittest-xml-reporting beautifulsoup4
- coverage run --source='.' manage.py test --settings AKPlanning.settings_ci
after_script:
- source venv/bin/activate
......@@ -56,6 +56,8 @@ lint:
extends: .before_script_template
stage: test
script:
- source venv/bin/activate
- pip install beautifulsoup4
- pylint --load-plugins pylint_django --django-settings-module=AKPlanning.settings_ci --rcfile pylintrc --exit-zero --output-format=text AK* | tee /tmp/pylint.txt
- sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' /tmp/pylint.txt > public/badges/$CI_JOB_NAME.score
- pylint --load-plugins pylint_django --django-settings-module=AKPlanning.settings_ci --rcfile pylintrc --exit-zero --output-format=pylint_gitlab.GitlabCodeClimateReporter AK* > codeclimate.json
......
......@@ -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."
......@@ -6,7 +6,7 @@ from django.utils.timezone import now
from AKDashboard.models import DashboardButton
from AKModel.models import Event, AK, AKCategory
from AKModel.tests import BasicViewTests
from AKModel.tests.test_views import BasicViewTests
class DashboardTests(TestCase):
......
......@@ -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
......
......@@ -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')
......
......@@ -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]
}
}
]
......@@ -4,8 +4,10 @@ Central and admin forms
import csv
import io
import json
from django import forms
from django.core.exceptions import ValidationError
from django.forms.utils import ErrorList
from django.utils.translation import gettext_lazy as _
......@@ -281,3 +283,61 @@ 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=False,
widget=forms.Textarea,
label=_("JSON data"),
help_text=_("JSON data from the scheduling solver"),
)
json_file = forms.FileField(
required=False,
label=_("File with JSON data"),
help_text=_("File with JSON data from the scheduling solver"),
)
def _check_json_data(self, data: str):
try:
schedule = json.loads(data)
except json.JSONDecodeError as ex:
raise ValidationError(_("Cannot decode as JSON"), "invalid") from ex
for field in ["input", "scheduled_aks"]:
if not field in schedule:
raise ValidationError(
_("Invalid JSON format: field '%(field)s' is missing"),
"invalid",
params={"field": field}
)
# TODO: Add further checks on json input
return schedule
def clean(self):
cleaned_data = super().clean()
if cleaned_data.get("json_file") and cleaned_data.get("json_data"):
err = ValidationError(
_("Please enter data as a file OR via text, not both."), "invalid"
)
self.add_error("json_data", err)
self.add_error("json_file", err)
elif not (cleaned_data.get("json_file") or cleaned_data.get("json_data")):
err = ValidationError(
_("No data entered. Please enter data as a file or via text."), "invalid"
)
self.add_error("json_data", err)
self.add_error("json_file", err)
else:
source_field = "json_data"
data = cleaned_data.get(source_field)
if not data:
source_field = "json_file"
with cleaned_data.get(source_field).open() as ff:
data = ff.read()
try:
cleaned_data["data"] = self._check_json_data(data)
except ValidationError as ex:
self.add_error(source_field, ex)
return cleaned_data
......@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-26 16:35+0100\n"
"POT-Creation-Date: 2025-03-03 00:44+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:381 AKModel/models.py:707
#: AKModel/templates/admin/AKModel/aks_by_user.html:12
#: AKModel/admin.py:170 AKModel/models.py:878 AKModel/models.py:1332
#: AKModel/models.py:1368 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:123
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:138
msgid "Reset AKs' interest counters"
msgstr "Interessenszähler der AKs zurücksetzen"
......@@ -72,35 +72,35 @@ 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"
#: AKModel/admin.py:580
#: AKModel/admin.py:585
msgid "Activate selected users"
msgstr "Ausgewählte Benutzer*innen aktivieren"
#: AKModel/admin.py:583
#: AKModel/admin.py:594
msgid "The selected users have been activated."
msgstr "Benutzer*innen aktiviert"
#: AKModel/admin.py:585
#: AKModel/admin.py:596
msgid "Deactivate selected users"
msgstr "Ausgewählte Benutzer*innen deaktivieren"
#: AKModel/admin.py:588
#: AKModel/admin.py:605
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:60 AKModel/models.py:174
#: AKModel/models.py:251 AKModel/models.py:270 AKModel/models.py:296
#: AKModel/models.py:315 AKModel/models.py:371 AKModel/models.py:517
#: AKModel/models.py:556 AKModel/models.py:646 AKModel/models.py:703
#: AKModel/models.py:894
#: AKModel/availability/models.py:43 AKModel/models.py:166
#: AKModel/models.py:657 AKModel/models.py:734 AKModel/models.py:767
#: AKModel/models.py:793 AKModel/models.py:812 AKModel/models.py:868
#: AKModel/models.py:1026 AKModel/models.py:1103 AKModel/models.py:1271
#: AKModel/models.py:1328 AKModel/models.py:1519
msgid "Event"
msgstr "Event"
#: AKModel/availability/models.py:44 AKModel/models.py:175
#: AKModel/models.py:252 AKModel/models.py:271 AKModel/models.py:297
#: AKModel/models.py:316 AKModel/models.py:372 AKModel/models.py:518
#: AKModel/models.py:557 AKModel/models.py:647 AKModel/models.py:704
#: AKModel/models.py:895
#: AKModel/availability/models.py:44 AKModel/models.py:658
#: AKModel/models.py:735 AKModel/models.py:768 AKModel/models.py:794
#: AKModel/models.py:813 AKModel/models.py:869 AKModel/models.py:1027
#: AKModel/models.py:1104 AKModel/models.py:1272 AKModel/models.py:1329
#: AKModel/models.py:1520
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:521
#: AKModel/models.py:546 AKModel/models.py:713
#: AKModel/availability/models.py:61 AKModel/models.py:1030
#: AKModel/models.py:1093 AKModel/models.py:1338
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:380
#: AKModel/models.py:545 AKModel/models.py:641
#: AKModel/availability/models.py:70 AKModel/models.py:877
#: AKModel/models.py:1092 AKModel/models.py:1266
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:255
#: AKModel/models.py:719
#: AKModel/availability/models.py:79 AKModel/models.py:738
#: AKModel/models.py:1344
msgid "AK Category"
msgstr "AK-Kategorie"
......@@ -176,64 +176,64 @@ 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 AKModel/models.py:926
msgid "Availabilities"
msgstr "Verfügbarkeiten"
#: AKModel/forms.py:78
#: AKModel/forms.py:80
msgid "Copy ak requirements and ak categories of existing event"
msgstr "AK-Anforderungen und AK-Kategorien eines existierenden Events kopieren"
#: AKModel/forms.py:79
#: AKModel/forms.py:81
msgid "You can choose what to copy in the next step"
msgstr ""
"Im nächsten Schritt kann ausgewählt werden, was genau kopiert werden soll"
#: AKModel/forms.py:93
#: AKModel/forms.py:95
msgid "Copy ak categories"
msgstr "AK-Kategorien kopieren"
#: AKModel/forms.py:100
#: AKModel/forms.py:102
msgid "Copy ak requirements"
msgstr "AK-Anforderungen kopieren"
#: AKModel/forms.py:107
#: AKModel/forms.py:109
msgid "Copy types"
msgstr "Typen kopieren"
#: AKModel/forms.py:133
#: AKModel/forms.py:135
msgid "Copy dashboard buttons"
msgstr "Dashboard-Buttons kopieren"
#: AKModel/forms.py:174
#: AKModel/forms.py:176
msgid "# next AKs"
msgstr "# nächste AKs"
#: AKModel/forms.py:175
#: AKModel/forms.py:177
msgid "How many next AKs should be shown on a slide?"
msgstr "Wie viele nächste AKs sollen auf einer Folie angezeigt werden?"
#: AKModel/forms.py:178
#: AKModel/forms.py:180
msgid "Presentation only?"
msgstr "Nur Vorstellung?"
#: AKModel/forms.py:180 AKModel/forms.py:187
#: AKModel/forms.py:182 AKModel/forms.py:189
msgid "Yes"
msgstr "Ja"
#: AKModel/forms.py:180 AKModel/forms.py:187
#: AKModel/forms.py:182 AKModel/forms.py:189
msgid "No"
msgstr "Nein"
#: AKModel/forms.py:182
#: AKModel/forms.py:184
msgid "Restrict AKs to those that asked for chance to be presented?"
msgstr "AKs auf solche, die um eine Vorstellung gebeten haben, einschränken?"
#: AKModel/forms.py:185
#: AKModel/forms.py:187
msgid "Space for notes in wishes?"
msgstr "Platz für Notizen bei den Wünschen?"
#: AKModel/forms.py:189
#: AKModel/forms.py:191
msgid ""
"Create symbols indicating space to note down owners and timeslots for "
"wishes, e.g., to be filled out on a touch screen while presenting?"
......@@ -242,11 +242,11 @@ 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:888
#: AKModel/forms.py:200 AKModel/models.py:1513
msgid "Default Slots"
msgstr "Standardslots"
#: AKModel/forms.py:200
#: AKModel/forms.py:202
msgid ""
"Click and drag to add default slots, double-click to delete. Or use the "
"start and end inputs to add entries to the calendar view."
......@@ -255,11 +255,11 @@ msgstr ""
"Einträge zu löschen. Oder Start- und End-Eingabe verwenden, um der "
"Kalenderansicht neue Einträge hinzuzufügen."
#: AKModel/forms.py:216
#: AKModel/forms.py:218
msgid "New rooms"
msgstr "Neue Räume"
#: AKModel/forms.py:217
#: AKModel/forms.py:219
msgid ""
"Enter room details in CSV format. Required colum is \"name\", optional "
"colums are \"location\", \"capacity\", and \"url\" for online/hybrid rooms. "
......@@ -269,19 +269,52 @@ msgstr ""
"Spalten sind \"location\", \"capacity\", und \"url\" for Online-/"
"HybridräumeTrennzeichen: Semikolon"
#: AKModel/forms.py:223
#: AKModel/forms.py:225
msgid "Default availabilities?"
msgstr "Standardverfügbarkeiten?"
#: AKModel/forms.py:224
#: AKModel/forms.py:226
msgid "Create default availabilities for all rooms?"
msgstr "Standardverfügbarkeiten für alle Räume anlegen?"
#: AKModel/forms.py:240
#: AKModel/forms.py:242
msgid "CSV must contain a name column"
msgstr "CSV muss eine name-Spalte enthalten"
#: AKModel/metaviews/admin.py:156 AKModel/models.py:29
#: AKModel/forms.py:293
msgid "JSON data"
msgstr "JSON-Daten"
#: AKModel/forms.py:294
msgid "JSON data from the scheduling solver"
msgstr "JSON-Daten, die der scheduling-solver produziert hat"
#: AKModel/forms.py:299
msgid "File with JSON data"
msgstr "Datei mit JSON-Daten"
#: AKModel/forms.py:300
msgid "File with JSON data from the scheduling solver"
msgstr "Datei mit JSON-Daten, die der scheduling-solver produziert hat"
#: AKModel/forms.py:307
msgid "Cannot decode as JSON"
msgstr "Dekodierung als JSON fehlgeschlagen"
#: AKModel/forms.py:311
#, python-format
msgid "Invalid JSON format: field '%(field)s' is missing"
msgstr "Ungültige JSON-Eingabe: das Feld '%(field)s' fehlt"
#: AKModel/forms.py:321
msgid "Please enter data as a file OR via text, not both."
msgstr "Gib die Daten bitte als Datei oder als Text ein, nicht beides."
#: AKModel/forms.py:327
msgid "No data entered. Please enter data as a file or via text."
msgstr "Keine Daten eingegeben. Gib die Daten bitte als Datei oder als Text ein."
#: AKModel/metaviews/admin.py:156 AKModel/models.py:129
msgid "Start"
msgstr "Start"
......@@ -306,67 +339,67 @@ msgstr "Aktivieren?"
msgid "Finish"
msgstr "Abschluss"
#: AKModel/models.py:20 AKModel/models.py:243 AKModel/models.py:267
#: AKModel/models.py:294 AKModel/models.py:313 AKModel/models.py:331
#: AKModel/models.py:507
#: AKModel/models.py:120 AKModel/models.py:726 AKModel/models.py:764
#: AKModel/models.py:791 AKModel/models.py:810 AKModel/models.py:828
#: AKModel/models.py:1018
msgid "Name"
msgstr "Name"
#: AKModel/models.py:21
#: AKModel/models.py:121
msgid "Name or iteration of the event"
msgstr "Name oder Iteration des Events"
#: AKModel/models.py:22
#: AKModel/models.py:122
msgid "Short Form"
msgstr "Kurzer Name"
#: AKModel/models.py:23
#: AKModel/models.py:123
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:25
#: AKModel/models.py:125
msgid "Place"
msgstr "Ort"
#: AKModel/models.py:26
#: AKModel/models.py:126
msgid "City etc. the event takes place in"
msgstr "Stadt o.ä. in der das Event stattfindet"
#: AKModel/models.py:28
#: AKModel/models.py:128
msgid "Time Zone"
msgstr "Zeitzone"
#: AKModel/models.py:28
#: AKModel/models.py:128
msgid "Time Zone where this event takes place in"
msgstr "Zeitzone in der das Event stattfindet"
#: AKModel/models.py:29
#: AKModel/models.py:129
msgid "Time the event begins"
msgstr "Zeit zu der das Event beginnt"
#: AKModel/models.py:30
#: AKModel/models.py:130
msgid "End"
msgstr "Ende"
#: AKModel/models.py:30
#: AKModel/models.py:130
msgid "Time the event ends"
msgstr "Zeit zu der das Event endet"
#: AKModel/models.py:31
#: AKModel/models.py:131
msgid "Resolution Deadline"
msgstr "Resolutionsdeadline"
#: AKModel/models.py:32
#: AKModel/models.py:132
msgid "When should AKs with intention to submit a resolution be done?"
msgstr "Wann sollen AKs mit Resolutionsabsicht stattgefunden haben?"
#: AKModel/models.py:34
#: AKModel/models.py:134
msgid "Interest Window Start"
msgstr "Beginn Interessensbekundung"
#: AKModel/models.py:36
#: AKModel/models.py:136
msgid ""
"Opening time for expression of interest. When left blank, no interest "
"indication will be possible."
......@@ -374,71 +407,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:38
#: AKModel/models.py:138
msgid "Interest Window End"
msgstr "Ende Interessensbekundung"
#: AKModel/models.py:39
#: AKModel/models.py:139
msgid "Closing time for expression of interest."
msgstr "Öffnungszeitpunkt für die Angabe von Interesse an AKs."
#: AKModel/models.py:41
#: AKModel/models.py:141
msgid "Public event"
msgstr "Öffentliches Event"
#: AKModel/models.py:42
#: AKModel/models.py:142
msgid "Show this event on overview page."
msgstr "Zeige dieses Event auf der Übersichtseite an"
#: AKModel/models.py:44
#: AKModel/models.py:144
msgid "Active State"
msgstr "Aktiver Status"
#: AKModel/models.py:44
#: AKModel/models.py:144
msgid "Marks currently active events"
msgstr "Markiert aktuell aktive Events"
#: AKModel/models.py:45
#: AKModel/models.py:145
msgid "Plan Hidden"
msgstr "Plan verborgen"
#: AKModel/models.py:45
#: AKModel/models.py:145
msgid "Hides plan for non-staff users"
msgstr "Verbirgt den Plan für Nutzer*innen ohne erweiterte Rechte"
#: AKModel/models.py:47
#: AKModel/models.py:147
msgid "Plan published at"
msgstr "Plan veröffentlicht am/um"
#: AKModel/models.py:48
#: AKModel/models.py:148
msgid "Timestamp at which the plan was published"
msgstr "Zeitpunkt, zu dem der Plan veröffentlicht wurde"
#: AKModel/models.py:50
#: AKModel/models.py:150
msgid "Base URL"
msgstr "URL-Prefix"
#: AKModel/models.py:50
#: AKModel/models.py:150
msgid "Prefix for wiki link construction"
msgstr "Prefix für die automatische Generierung von Wiki-Links"
#: AKModel/models.py:51
#: AKModel/models.py:151
msgid "Wiki Export Template Name"
msgstr "Wiki-Export Templatename"
#: AKModel/models.py:52
#: AKModel/models.py:152
msgid "Default Slot Length"
msgstr "Standardslotlänge"
#: AKModel/models.py:53
#: AKModel/models.py:153
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:55
#: AKModel/models.py:154
msgid "Export Slot Length"
msgstr "Export-Slotlänge"
#: AKModel/models.py:156
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:161
msgid "Contact email address"
msgstr "E-Mail Kontaktadresse"
#: AKModel/models.py:56
#: AKModel/models.py:162
msgid ""
"An email address that is displayed on every page and can be used for all "
"kinds of questions"
......@@ -446,75 +491,116 @@ msgstr ""
"Eine Mailadresse die auf jeder Seite angezeigt wird und für alle Arten von "
"Fragen genutzt werden kann"
#: AKModel/models.py:61
#: AKModel/models.py:167
msgid "Events"
msgstr "Events"
#: AKModel/models.py:169
#: AKModel/models.py:427
msgid "Cannot parse malformed JSON input."
msgstr "Kann fehlerhafte JSON-Eingabe nicht verarbeiten"
#: AKModel/models.py:430
msgid "Data has changed since the export. Reexport and run the solver again."
msgstr "Seit dem Export wurden die Daten verändert. Wiederhole den Export und führe den Solver erneut aus."
#: AKModel/models.py:447
#, 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:457
#, 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:471
#, 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:482
#, 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:652
msgid "Nickname"
msgstr "Spitzname"
#: AKModel/models.py:169
#: AKModel/models.py:652
msgid "Name to identify an AK owner by"
msgstr "Name, durch den eine AK-Leitung identifiziert wird"
#: AKModel/models.py:170
#: AKModel/models.py:653
msgid "Slug"
msgstr "Slug"
#: AKModel/models.py:170
#: AKModel/models.py:653
msgid "Slug for URL generation"
msgstr "Slug für URL-Generierung"
#: AKModel/models.py:171
#: AKModel/models.py:654
msgid "Institution"
msgstr "Instutution"
#: AKModel/models.py:171
#: AKModel/models.py:654
msgid "Uni etc."
msgstr "Universität o.ä."
#: AKModel/models.py:172 AKModel/models.py:340
#: AKModel/models.py:655 AKModel/models.py:837
msgid "Web Link"
msgstr "Internet Link"
#: AKModel/models.py:172
#: AKModel/models.py:655
msgid "Link to Homepage"
msgstr "Link zu Homepage oder Webseite"
#: AKModel/models.py:178 AKModel/models.py:712
#: AKModel/models.py:661 AKModel/models.py:1337
msgid "AK Owner"
msgstr "AK-Leitung"
#: AKModel/models.py:179
#: AKModel/models.py:662
msgid "AK Owners"
msgstr "AK-Leitungen"
#: AKModel/models.py:243
#: AKModel/models.py:726
msgid "Name of the AK Category"
msgstr "Name der AK-Kategorie"
#: AKModel/models.py:244 AKModel/models.py:268
#: AKModel/models.py:727 AKModel/models.py:765
msgid "Color"
msgstr "Farbe"
#: AKModel/models.py:244 AKModel/models.py:268
#: AKModel/models.py:727 AKModel/models.py:765
msgid "Color for displaying"
msgstr "Farbe für die Anzeige"
#: AKModel/models.py:245 AKModel/models.py:334
#: AKModel/models.py:728 AKModel/models.py:831
msgid "Description"
msgstr "Beschreibung"
#: AKModel/models.py:246
#: AKModel/models.py:729
msgid "Short description of this AK Category"
msgstr "Beschreibung der AK-Kategorie"
#: AKModel/models.py:247
#: AKModel/models.py:730
msgid "Present by default"
msgstr "Defaultmäßig präsentieren"
#: AKModel/models.py:248
#: AKModel/models.py:731
msgid ""
"Present AKs of this category by default if AK owner did not specify whether "
"this AK should be presented?"
......@@ -522,152 +608,152 @@ msgstr ""
"AKs dieser Kategorie standardmäßig vorstellen, wenn die Leitungen das für "
"ihren AK nicht explizit spezifiziert haben?"
#: AKModel/models.py:256
#: AKModel/models.py:739
msgid "AK Categories"
msgstr "AK-Kategorien"
#: AKModel/models.py:267
#: AKModel/models.py:764
msgid "Name of the AK Track"
msgstr "Name des AK-Tracks"
#: AKModel/models.py:274
#: AKModel/models.py:771
msgid "AK Track"
msgstr "AK-Track"
#: AKModel/models.py:275
#: AKModel/models.py:772
msgid "AK Tracks"
msgstr "AK-Tracks"
#: AKModel/models.py:294
#: AKModel/models.py:791
msgid "Name of the Requirement"
msgstr "Name der Anforderung"
#: AKModel/models.py:300 AKModel/models.py:716
#: AKModel/models.py:797 AKModel/models.py:1341
msgid "AK Requirement"
msgstr "AK-Anforderung"
#: AKModel/models.py:301
#: AKModel/models.py:798
msgid "AK Requirements"
msgstr "AK-Anforderungen"
#: AKModel/models.py:313
#: AKModel/models.py:810
msgid "Name describing the type"
msgstr "Name, der den Typ beschreibt"
#: AKModel/models.py:319
#: AKModel/models.py:816
msgid "AK Type"
msgstr "AK Typ"
#: AKModel/models.py:320
#: AKModel/models.py:817
msgid "AK Types"
msgstr "AK-Typen"
#: AKModel/models.py:331
#: AKModel/models.py:828
msgid "Name of the AK"
msgstr "Name des AKs"
#: AKModel/models.py:332
#: AKModel/models.py:829
msgid "Short Name"
msgstr "Kurzer Name"
#: AKModel/models.py:333
#: AKModel/models.py:830
msgid "Name displayed in the schedule"
msgstr "Name zur Anzeige im AK-Plan"
#: AKModel/models.py:334
#: AKModel/models.py:831
msgid "Description of the AK"
msgstr "Beschreibung des AKs"
#: AKModel/models.py:336
#: AKModel/models.py:833
msgid "Owners"
msgstr "Leitungen"
#: AKModel/models.py:337
#: AKModel/models.py:834
msgid "Those organizing the AK"
msgstr "Menschen, die den AK organisieren und halten"
#: AKModel/models.py:340
#: AKModel/models.py:837
msgid "Link to wiki page"
msgstr "Link zur Wiki Seite"
#: AKModel/models.py:341
#: AKModel/models.py:838
msgid "Protocol Link"
msgstr "Protokolllink"
#: AKModel/models.py:341
#: AKModel/models.py:838
msgid "Link to protocol"
msgstr "Link zum Protokoll"
#: AKModel/models.py:343
#: AKModel/models.py:840
msgid "Category"
msgstr "Kategorie"
#: AKModel/models.py:344
#: AKModel/models.py:841
msgid "Category of the AK"
msgstr "Kategorie des AKs"
#: AKModel/models.py:345
#: AKModel/models.py:842 AKModel/models.py:907
msgid "Types"
msgstr "Typen"
#: AKModel/models.py:346
#: AKModel/models.py:843
msgid "This AK is"
msgstr "Dieser AK ist"
#: AKModel/models.py:347
#: AKModel/models.py:844
msgid "Track"
msgstr "Track"
#: AKModel/models.py:348
#: AKModel/models.py:845
msgid "Track the AK belongs to"
msgstr "Track zu dem der AK gehört"
#: AKModel/models.py:350
#: AKModel/models.py:847
msgid "Resolution Intention"
msgstr "Resolutionsabsicht"
#: AKModel/models.py:351
#: AKModel/models.py:848
msgid "Intends to submit a resolution"
msgstr "Beabsichtigt eine Resolution einzureichen"
#: AKModel/models.py:352
#: AKModel/models.py:849
msgid "Present this AK"
msgstr "AK präsentieren"
#: AKModel/models.py:353
#: AKModel/models.py:850
msgid "Present results of this AK"
msgstr "Die Ergebnisse dieses AKs vorstellen"
#: AKModel/models.py:355 AKModel/views/status.py:167
#: AKModel/models.py:852 AKModel/models.py:905 AKModel/views/status.py:175
msgid "Requirements"
msgstr "Anforderungen"
#: AKModel/models.py:356
#: AKModel/models.py:853
msgid "AK's Requirements"
msgstr "Anforderungen des AKs"
#: AKModel/models.py:358
#: AKModel/models.py:855
msgid "Conflicting AKs"
msgstr "AK-Konflikte"
#: AKModel/models.py:359
#: AKModel/models.py:856
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:360
#: AKModel/models.py:857
msgid "Prerequisite AKs"
msgstr "Vorausgesetzte AKs"
#: AKModel/models.py:361
#: AKModel/models.py:858
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:363
#: AKModel/models.py:860
msgid "Organizational Notes"
msgstr "Notizen zur Organisation"
#: AKModel/models.py:364
#: AKModel/models.py:861
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/"
......@@ -677,295 +763,303 @@ msgstr ""
"Anmerkungen bitte den Button für Direktnachrichten verwenden (nach dem "
"Anlegen/Bearbeiten)."
#: AKModel/models.py:367
#: AKModel/models.py:864 AKModel/models.py:903
msgid "Interest"
msgstr "Interesse"
#: AKModel/models.py:367
#: AKModel/models.py:864
msgid "Expected number of people"
msgstr "Erwartete Personenzahl"
#: AKModel/models.py:368
#: AKModel/models.py:865
msgid "Interest Counter"
msgstr "Interessenszähler"
#: AKModel/models.py:369
#: AKModel/models.py:866
msgid "People who have indicated interest online"
msgstr "Anzahl Personen, die online Interesse bekundet haben"
#: AKModel/models.py:374
#: AKModel/models.py:871
msgid "Export?"
msgstr "Export?"
#: AKModel/models.py:375
#: AKModel/models.py:872
msgid "Include AK in wiki export?"
msgstr "AK bei Wiki-Export berücksichtigen?"
#: AKModel/models.py:507
#: AKModel/models.py:922
msgid "Conflicts"
msgstr "Konflikte"
#: AKModel/models.py:925
msgid "Prerequisites"
msgstr "Voraussetzungen"
#: AKModel/models.py:1018
msgid "Name or number of the room"
msgstr "Name oder Nummer des Raums"
#: AKModel/models.py:508
#: AKModel/models.py:1019
msgid "Location"
msgstr "Ort"
#: AKModel/models.py:509
#: AKModel/models.py:1020
msgid "Name or number of the location"
msgstr "Name oder Nummer des Ortes"
#: AKModel/models.py:510
#: AKModel/models.py:1021
msgid "Capacity"
msgstr "Kapazität"
#: AKModel/models.py:511
#: AKModel/models.py:1022
msgid "Maximum number of people (-1 for unlimited)."
msgstr "Maximale Personenzahl (-1 wenn unbeschränkt)."
#: AKModel/models.py:512
#: AKModel/models.py:1023
msgid "Properties"
msgstr "Eigenschaften"
#: AKModel/models.py:513
#: AKModel/models.py:1024
msgid "AK requirements fulfilled by the room"
msgstr "AK-Anforderungen, die dieser Raum erfüllt"
#: AKModel/models.py:522 AKModel/views/status.py:59
#: AKModel/models.py:1031 AKModel/views/status.py:59
msgid "Rooms"
msgstr "Räume"
#: AKModel/models.py:545
#: AKModel/models.py:1092
msgid "AK being mapped"
msgstr "AK, der zugeordnet wird"
#: AKModel/models.py:547
#: AKModel/models.py:1094
msgid "Room the AK will take place in"
msgstr "Raum in dem der AK stattfindet"
#: AKModel/models.py:548 AKModel/models.py:891
#: AKModel/models.py:1095 AKModel/models.py:1516
msgid "Slot Begin"
msgstr "Beginn des Slots"
#: AKModel/models.py:548 AKModel/models.py:891
#: AKModel/models.py:1095 AKModel/models.py:1516
msgid "Time and date the slot begins"
msgstr "Zeit und Datum zu der der AK beginnt"
#: AKModel/models.py:550
#: AKModel/models.py:1097
msgid "Duration"
msgstr "Dauer"
#: AKModel/models.py:551
#: AKModel/models.py:1098
msgid "Length in hours"
msgstr "Länge in Stunden"
#: AKModel/models.py:553
#: AKModel/models.py:1100
msgid "Scheduling fixed"
msgstr "Planung fix"
#: AKModel/models.py:554
#: AKModel/models.py:1101
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:559
#: AKModel/models.py:1106
msgid "Last update"
msgstr "Letzte Aktualisierung"
#: AKModel/models.py:562
#: AKModel/models.py:1109
msgid "AK Slot"
msgstr "AK-Slot"
#: AKModel/models.py:563 AKModel/models.py:709
#: AKModel/models.py:1110 AKModel/models.py:1334 AKModel/models.py:1369
msgid "AK Slots"
msgstr "AK-Slot"
#: AKModel/models.py:585 AKModel/models.py:594
#: AKModel/models.py:1132 AKModel/models.py:1141
msgid "Not scheduled yet"
msgstr "Noch nicht geplant"
#: AKModel/models.py:642
#: AKModel/models.py:1267
msgid "AK this message belongs to"
msgstr "AK zu dem die Nachricht gehört"
#: AKModel/models.py:643
#: AKModel/models.py:1268
msgid "Message text"
msgstr "Nachrichtentext"
#: AKModel/models.py:644
#: AKModel/models.py:1269
msgid "Message to the organizers. This is not publicly visible."
msgstr ""
"Nachricht an die Organisator*innen. Diese ist nicht öffentlich sichtbar."
#: AKModel/models.py:648
#: AKModel/models.py:1273
msgid "Resolved"
msgstr "Erledigt"
#: AKModel/models.py:649
#: AKModel/models.py:1274
msgid "This message has been resolved (no further action needed)"
msgstr ""
"Diese Nachricht wurde vollständig bearbeitet (keine weiteren Aktionen "
"notwendig)"
#: AKModel/models.py:652
#: AKModel/models.py:1277
msgid "AK Orga Message"
msgstr "AK-Organachricht"
#: AKModel/models.py:653
#: AKModel/models.py:1278
msgid "AK Orga Messages"
msgstr "AK-Organachrichten"
#: AKModel/models.py:670
#: AKModel/models.py:1295
msgid "Constraint Violation"
msgstr "Constraintverletzung"
#: AKModel/models.py:671
#: AKModel/models.py:1296
msgid "Constraint Violations"
msgstr "Constraintverletzungen"
#: AKModel/models.py:678
#: AKModel/models.py:1303
msgid "Owner has two parallel slots"
msgstr "Leitung hat zwei Slots parallel"
#: AKModel/models.py:679
#: AKModel/models.py:1304
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:680
#: AKModel/models.py:1305
msgid "Room has two AK slots scheduled at the same time"
msgstr "Raum hat zwei AK Slots gleichzeitig"
#: AKModel/models.py:681
#: AKModel/models.py:1306
msgid "Room does not satisfy the requirement of the scheduled AK"
msgstr "Room erfüllt die Anforderungen des platzierten AKs nicht"
#: AKModel/models.py:682
#: AKModel/models.py:1307
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:683
#: AKModel/models.py:1308
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:685
#: AKModel/models.py:1310
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:686
#: AKModel/models.py:1311
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:687
#: AKModel/models.py:1312
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:688
#: AKModel/models.py:1313
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:689
#: AKModel/models.py:1314
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:695
#: AKModel/models.py:1320
msgid "Warning"
msgstr "Warnung"
#: AKModel/models.py:696
#: AKModel/models.py:1321
msgid "Violation"
msgstr "Verletzung"
#: AKModel/models.py:698
#: AKModel/models.py:1323
msgid "Type"
msgstr "Art"
#: AKModel/models.py:699
#: AKModel/models.py:1324
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:700
#: AKModel/models.py:1325
msgid "Level"
msgstr "Level"
#: AKModel/models.py:701
#: AKModel/models.py:1326
msgid "Severity level of the violation"
msgstr "Schweregrad der Verletzung"
#: AKModel/models.py:708
#: AKModel/models.py:1333
msgid "AK(s) belonging to this constraint"
msgstr "AK(s), die zu diesem Constraint gehören"
#: AKModel/models.py:710
#: AKModel/models.py:1335
msgid "AK Slot(s) belonging to this constraint"
msgstr "AK Slot(s), die zu diesem Constraint gehören"
#: AKModel/models.py:712
#: AKModel/models.py:1337
msgid "AK Owner belonging to this constraint"
msgstr "AK Leitung(en), die zu diesem Constraint gehören"
#: AKModel/models.py:714
#: AKModel/models.py:1339
msgid "Room belonging to this constraint"
msgstr "Raum, der zu diesem Constraint gehört"
#: AKModel/models.py:717
#: AKModel/models.py:1342
msgid "AK Requirement belonging to this constraint"
msgstr "AK Anforderung, die zu diesem Constraint gehört"
#: AKModel/models.py:719
#: AKModel/models.py:1344
msgid "AK Category belonging to this constraint"
msgstr "AK Kategorie, di zu diesem Constraint gehört"
#: AKModel/models.py:721
#: AKModel/models.py:1346
msgid "Comment"
msgstr "Kommentar"
#: AKModel/models.py:721
#: AKModel/models.py:1346
msgid "Comment or further details for this violation"
msgstr "Kommentar oder weitere Details zu dieser Vereletzung"
#: AKModel/models.py:724
#: AKModel/models.py:1349
msgid "Timestamp"
msgstr "Timestamp"
#: AKModel/models.py:724
#: AKModel/models.py:1349
msgid "Time of creation"
msgstr "Zeitpunkt der ERstellung"
#: AKModel/models.py:725
#: AKModel/models.py:1350
msgid "Manually Resolved"
msgstr "Manuell behoben"
#: AKModel/models.py:726
#: AKModel/models.py:1351
msgid "Mark this violation manually as resolved"
msgstr "Markiere diese Verletzung manuell als behoben"
#: AKModel/models.py:753 AKModel/templates/admin/AKModel/aks_by_user.html:22
#: AKModel/models.py:1378 AKModel/templates/admin/AKModel/aks_by_user.html:22
#: AKModel/templates/admin/AKModel/requirements_overview.html:27
msgid "Details"
msgstr "Details"
#: AKModel/models.py:887
#: AKModel/models.py:1512
msgid "Default Slot"
msgstr "Standardslot"
#: AKModel/models.py:892
#: AKModel/models.py:1517
msgid "Slot End"
msgstr "Ende des Slots"
#: AKModel/models.py:892
#: AKModel/models.py:1517
msgid "Time and date the slot ends"
msgstr "Zeit und Datum zu der der Slot endet"
#: AKModel/models.py:897
#: AKModel/models.py:1522
msgid "Primary categories"
msgstr "Primäre Kategorien"
#: AKModel/models.py:898
#: AKModel/models.py:1523
msgid "Categories that should be assigned to this slot primarily"
msgstr "Kategorieren, die diesem Slot primär zugewiesen werden sollen"
#: AKModel/site.py:14
#: AKModel/site.py:13 AKModel/site.py:14
msgid "Administration"
msgstr "Verwaltung"
......@@ -987,6 +1081,7 @@ msgid "Logout"
msgstr "Ausloggen"
#: AKModel/templates/admin/AKModel/action_intermediate.html:23
#: AKModel/templates/admin/AKModel/import_json.html:23
msgid "Confirm"
msgstr "Bestätigen"
......@@ -994,6 +1089,7 @@ msgstr "Bestätigen"
#: AKModel/templates/admin/AKModel/event_wizard/import.html:27
#: AKModel/templates/admin/AKModel/event_wizard/settings.html:32
#: AKModel/templates/admin/AKModel/event_wizard/start.html:28
#: AKModel/templates/admin/AKModel/import_json.html:27
#: AKModel/templates/admin/AKModel/room_create.html:30
msgid "Cancel"
msgstr "Abbrechen"
......@@ -1082,7 +1178,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"
......@@ -1168,43 +1264,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:72
msgid "AK Wiki Export"
msgstr "AK-Wiki-Export"
#: AKModel/views/ak.py:59 AKModel/views/manage.py:53
#: AKModel/views/ak.py:83 AKModel/views/manage.py:55
msgid "Wishes"
msgstr "Wünsche"
#: AKModel/views/ak.py:71
#: AKModel/views/ak.py:95
msgid "Delete AK Orga Messages"
msgstr "AK-Organachrichten löschen"
#: AKModel/views/ak.py:89
#: AKModel/views/ak.py:113
msgid "AK Orga Messages successfully deleted"
msgstr "AK-Organachrichten erfolgreich gelöscht"
#: AKModel/views/ak.py:101
#: AKModel/views/ak.py:125
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:126
msgid "Reset of interest in AKs successful."
msgstr "Interesse an AKs erfolgreich zurückgesetzt."
#: AKModel/views/ak.py:116
#: AKModel/views/ak.py:140
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:141
msgid "AKs' interest counters set back to 0."
msgstr "Interessenszähler der AKs zurückgesetzt"
......@@ -1218,90 +1318,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:258
msgid "AK Schedule JSON Import"
msgstr "AK-Plan JSON-Import"
#: AKModel/views/manage.py:274
#, python-brace-format
msgid "Successfully imported {n} slot(s)"
msgstr "Erfolgreich {n} Slot(s) importiert"
#: AKModel/views/manage.py:280
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'"
......@@ -1354,18 +1467,26 @@ 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"
......
# 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",
),
),
]
# 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 = []
import decimal
import itertools
from datetime import timedelta
import json
import math
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any, Iterable, Generator
from django.db import models
from django.db import models, transaction
from django.apps import apps
from django.db.models import Count
from django.urls import reverse_lazy
from django.utils import timezone
from django.utils.datetime_safe import datetime
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from simple_history.models import HistoricalRecords
from timezone_field import TimeZoneField
@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.
......@@ -51,6 +151,12 @@ 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 "
......@@ -162,6 +268,386 @@ 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 | dict[str, Any], *, 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
"""
if isinstance(schedule, str):
schedule = json.loads(schedule)
export_dict = self.as_json_dict()
if "input" not in schedule or "scheduled_aks" not in schedule:
raise ValueError(_("Cannot parse malformed JSON input."))
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.
......@@ -260,6 +746,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.
......@@ -400,7 +900,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}"""
......@@ -435,7 +935,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())
......@@ -445,7 +945,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())
......@@ -550,6 +1050,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.
......@@ -645,6 +1183,84 @@ class AKSlot(models.Model):
self.duration = min(self.duration, event_duration_hours)
super().save(force_insert, force_update, using, 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):
"""
......
{% 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 %}
{% extends "admin/base_site.html" %}
{% load tags_AKModel %}
{% load i18n %}
{% load django_bootstrap5 %}
{% load fontawesome_6 %}
{% block title %}{{event}}: {{ title }}{% endblock %}
{% block content %}
{% block action_preview %}
<p>
{{ preview|linebreaksbr }}
</p>
{% endblock %}
<form enctype="multipart/form-data" method="post">{% csrf_token %}
{% bootstrap_form form %}
<div class="float-end">
<button type="submit" class="save btn btn-success" value="Submit">
{% fa6_icon "check" 'fas' %} {% trans "Confirm" %}
</button>
</div>
<a href="javascript:history.back()" class="btn btn-info">
{% fa6_icon "times" 'fas' %} {% trans "Cancel" %}
</a>
</form>
{% endblock %}
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 jsonschema.exceptions import best_match
from AKModel.availability.models import Availability
from AKModel.models import AK, AKCategory, AKOwner, AKSlot, DefaultSlot, Event, Room
from AKModel.utils import construct_schema_validator
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,
)
cls.json_export_validator = construct_schema_validator(
"solver-input.schema.json"
)
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_conformity_to_schema(self):
"""Test if JSON structure and types conform to schema."""
for event in Event.objects.all():
with self.subTest(event=event):
self.set_up_event(event=event)
error = best_match(
self.json_export_validator.iter_errors(self.export_dict)
)
msg = "" if error is None else error.message
self.assertTrue(error is None, msg)
def test_id_uniqueness(self):
"""Test if objects are only exported once."""
for event in Event.objects.all():
with self.subTest(event=event):
self.set_up_event(event=event)
self._check_uniqueness(self.export_dict["aks"], "AKs")
self._check_uniqueness(self.export_dict["rooms"], "Rooms")
self._check_uniqueness(self.export_dict["participants"], "Participants")
self._check_uniqueness(
chain.from_iterable(self.export_dict["timeslots"]["blocks"]),
"Timeslots",
)
def test_timeslot_ids_consecutive(self):
"""Test if Timeslots ids are chronologically consecutive."""
for event in Event.objects.all():
with self.subTest(event=event):
self.set_up_event(event=event)
prev_id = None
for timeslot in chain.from_iterable(
self.export_dict["timeslots"]["blocks"]
):
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]
)
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"]
)
......@@ -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",
)
......@@ -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()),
......
import json
from pathlib import Path
from jsonschema import Draft202012Validator
from jsonschema.protocols import Validator
from referencing import Registry, Resource
from AKPlanning import settings
def construct_schema_validator(schema: str | dict) -> Validator:
"""Construct a validator for a JSON schema.
In particular, all schemas from the 'schemas' directory
are loaded into the registry.
"""
schema_base_path = Path(settings.BASE_DIR) / "schemas"
resources = []
for schema_path in schema_base_path.glob("**/*.schema.json"):
with schema_path.open("r") as ff:
res = Resource.from_contents(json.load(ff))
resources.append((res.id(), res))
registry = Registry().with_resources(resources)
if isinstance(schema, str):
with (schema_base_path / schema).open("r") as ff:
schema = json.load(ff)
return Draft202012Validator(schema=schema, registry=registry)
import json
from django.contrib import messages
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
......@@ -37,6 +39,28 @@ class AKCSVExportView(AdminViewMixin, FilterByEventSlugMixin, ListView):
return super().get_queryset().order_by("ak__track")
class AKJSONExportView(AdminViewMixin, FilterByEventSlugMixin, ListView):
"""
View: Export all AK slots of this event in JSON format ordered by tracks
"""
template_name = "admin/AKModel/ak_json_export.html"
model = AKSlot
context_object_name = "slots"
title = _("AK JSON Export")
def get_queryset(self):
return super().get_queryset().order_by("ak__track")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
data = self.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
......
......@@ -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,29 @@ 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.
"""
template_name = "admin/AKModel/import_json.html"
form_class = JSONScheduleImportForm
title = _("AK Schedule JSON Import")
def form_valid(self, form):
try:
number_of_slots_changed = self.event.schedule_from_json(form.cleaned_data["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)