From 417e8151154d2848112fda9374fb2bb25b9c1857 Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Thu, 23 Jan 2025 16:55:06 +0100
Subject: [PATCH 01/39] Add other slots of same AK to conflict list

---
 AKModel/models.py | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/AKModel/models.py b/AKModel/models.py
index 267f0bb0..7cca95c1 100644
--- a/AKModel/models.py
+++ b/AKModel/models.py
@@ -955,6 +955,7 @@ class AKSlot(models.Model):
 
         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)
 
@@ -963,7 +964,9 @@ class AKSlot(models.Model):
             "id": str(self.pk),
             "duration": math.ceil(self.duration * self.slots_in_an_hour - ceil_offet_eps),
             "properties": {
-                "conflicts": [str(conflict.pk) for conflict in conflict_slots.all()],
+                "conflicts":
+                    [str(conflict.pk) for conflict in conflict_slots.all()]
+                  + [str(second_slot.pk) for second_slot in other_ak_slots.all()],
                 "dependencies": [str(dep.pk) for dep in dependency_slots.all()],
             },
             "room_constraints": [constraint.name
-- 
GitLab


From b0f0b9ae7e7082c0c057e99d3fe2f2f4120b3a71 Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Thu, 23 Jan 2025 16:55:26 +0100
Subject: [PATCH 02/39] Add django AK id to info dict of slot

---
 AKModel/models.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/AKModel/models.py b/AKModel/models.py
index 7cca95c1..1d12a76d 100644
--- a/AKModel/models.py
+++ b/AKModel/models.py
@@ -979,6 +979,7 @@ class AKSlot(models.Model):
                 "description": self.ak.description,
                 "reso": self.ak.reso,
                 "duration_in_hours": float(self.duration),
+                "django_ak_id": str(self.ak.pk),
                 },
             }
 
-- 
GitLab


From d1b162da0ced02d71f818bea02cee3dca5f4c611 Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Sat, 25 Jan 2025 03:06:58 +0100
Subject: [PATCH 03/39] Start adding json export unit tests

---
 AKModel/tests.py | 478 +++++++++++++++++++++++++++++++++++++++++------
 1 file changed, 419 insertions(+), 59 deletions(-)

diff --git a/AKModel/tests.py b/AKModel/tests.py
index 6730315d..110b49fb 100644
--- a/AKModel/tests.py
+++ b/AKModel/tests.py
@@ -1,4 +1,7 @@
+import json
+import math
 import traceback
+from itertools import chain
 from typing import List
 
 from django.contrib.auth import get_user_model
@@ -7,8 +10,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 +43,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 +56,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 +87,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 +101,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 +122,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 +140,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 +157,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 +230,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,32 +258,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_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', {}),
+        ("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):
@@ -236,24 +306,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):
         """
@@ -262,17 +340,299 @@ 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",
+        )
+
+
+class JSONExportTest(TestCase):
+    fixtures = ["model.json"]
+    event_slug = "kif42"
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.admin_user = get_user_model().objects.create(
+            username="Test Admin User",
+            email="testadmin@example.com",
+            password="adminpw",
+            is_staff=True,
+            is_superuser=True,
+            is_active=True,
+        )
+
+    def setUp(self):
+        self.client.force_login(self.admin_user)
+        export_url = reverse(
+            "admin:ak_json_export", kwargs={"event_slug": self.event_slug}
+        )
+        self.response = self.client.get(export_url)
+
+        self.assertEqual(self.response.status_code, 200, "Export not working at all")
+        try:
+
+            from bs4 import BeautifulSoup
+
+            soup = BeautifulSoup(self.response.content, features="lxml")
+            self.export_dict = json.loads(soup.find("pre").string)
+        except ImportError:
+            # without beautiful soup: just reconstruct the template
+            print("Import failed!")
+            ak_slots = (
+                "["
+                + ", ".join([slot.as_json() for slot in self.response.context["slots"]])
+                + "]"
+            )
+            rooms = (
+                "["
+                + ", ".join([room.as_json() for room in self.response.context["rooms"]])
+                + "]"
+            )
+
+            export_str = f"""{{
+            "aks": {ak_slots},
+            "rooms": {rooms},
+            "participants": {self.response.context["participants"]},
+            "timeslots": {self.response.context["timeslots"]},
+            "info": {"participants": {self.response.context["info_dict"]}}
+            }}
+            """
+            self.export_dict = json.loads(export_str)
+
+        self.export_aks = {ak["id"]: ak for ak in self.export_dict["aks"]}
+        self.export_rooms = {room["id"]: room for room in self.export_dict["rooms"]}
+        self.export_participants = {
+            participant["id"]: participant
+            for participant in self.export_dict["participants"]
+        }
+
+        self.ak_slots = AKSlot.objects.filter(event__slug=self.event_slug).all()
+        self.rooms = Room.objects.filter(event__slug=self.event_slug).all()
+        self.aks = AK.objects.filter(event__slug=self.event_slug).all()
+
+        self.slots_in_an_hour = 1 / self.export_dict["timeslots"]["info"]["duration"]
+        self.event = Event.objects.filter(slug=self.event_slug).get()
+
+    def test_all_aks_exported(self):
+        self.assertEqual(
+            {str(slot.pk) for slot in self.ak_slots},
+            self.export_aks.keys(),
+            "Exported AKs does not match the AKSlots of the event",
+        )
+
+    def test_conformity_to_spec(self):
+        def _check_uniqueness(lst, name: str):
+            id_lst = [entry["id"] for entry in lst]
+            self.assertEqual(len(id_lst), len(set(id_lst)), f"{name} IDs not unique!")
+
+        def _check_type(attr, cls, name: str) -> None:
+            self.assertTrue(isinstance(attr, cls), f"{item} {name} not a {cls}")
+
+        def _check_lst(lst: list[str], name: 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}",
+            )
+
+        for ak in self.export_dict["aks"]:
+            item = f"AK {ak['id']}"
+            self.assertEqual(
+                ak.keys(),
+                {
+                    "id",
+                    "duration",
+                    "properties",
+                    "room_constraints",
+                    "time_constraints",
+                    "info",
+                },
+                f"{item} keys not as expected",
+            )
+            self.assertEqual(
+                ak["info"].keys(),
+                {"name", "head", "description", "reso", "duration_in_hours"},
+                f"{item} info keys not as expected",
+            )
+            self.assertEqual(
+                ak["properties"].keys(),
+                {"conflicts", "dependencies"},
+                f"{item} properties keys not as expected",
+            )
+
+            _check_type(ak["id"], str, "id")
+            _check_type(ak["duration"], int, "duration")
+            _check_type(ak["info"]["name"], str, "info/name")
+            _check_type(ak["info"]["head"], str, "info/head")
+            _check_type(ak["info"]["description"], str, "info/description")
+            _check_type(ak["info"]["reso"], bool, "info/reso")
+            _check_type(
+                ak["info"]["duration_in_hours"], float, "info/duration_in_hours"
+            )
+
+            _check_lst(ak["properties"]["conflicts"], "conflicts")
+            _check_lst(ak["properties"]["dependencies"], "dependencies")
+            _check_lst(ak["time_constraints"], "time_constraints")
+            _check_lst(ak["room_constraints"], "room_constraints")
+
+        for room in self.export_dict["rooms"]:
+            item = f"Room {room['id']}"
+            self.assertEqual(
+                room.keys(),
+                {
+                    "id",
+                    "info",
+                    "capacity",
+                    "fulfilled_room_constraints",
+                    "time_constraints",
+                },
+                f"{item} keys not as expected",
+            )
+            self.assertEqual(
+                room["info"].keys(), {"name"}, f"{item} info keys not as expected"
+            )
+
+            _check_type(room["id"], str, "id")
+            _check_type(room["capacity"], int, "capacity")
+            _check_type(room["info"]["name"], str, "info/name")
+
+            self.assertTrue(
+                room["capacity"] > 0 or room["capacity"] == -1, "invalid room capacity"
+            )
+
+            _check_lst(room["time_constraints"], "time_constraints")
+            _check_lst(room["fulfilled_room_constraints"], "fulfilled_room_constraints")
+
+        item = "timeslots"
+        self.assertEqual(
+            self.export_dict["timeslots"].keys(),
+            {"info", "blocks"},
+            "timeslot keys not as expected",
+        )
+        self.assertEqual(
+            self.export_dict["timeslots"]["info"].keys(),
+            {"duration"},
+            "timeslot info keys not as expected",
+        )
+        _check_type(
+            self.export_dict["timeslots"]["info"]["duration"], float, "info/duration"
+        )
+        _check_lst(
+            self.export_dict["timeslots"]["blocks"], "blocks", contained_type=list
+        )
+
+        # TODO: Check if blocks are sorted
+
+        prev_id = None
+        for timeslot in chain.from_iterable(self.export_dict["timeslots"]["blocks"]):
+            item = f"timeslot {timeslot['id']}"
+            self.assertEqual(
+                timeslot.keys(),
+                {"id", "info", "fulfilled_time_constraints"},
+                f"{item} keys not as expected",
+            )
+            self.assertEqual(
+                timeslot["info"].keys(),
+                {"start"},
+                f"{item} info keys not as expected",
+            )
+            _check_type(timeslot["id"], str, "id")
+            _check_type(timeslot["info"]["start"], str, "info/start")
+            _check_lst(
+                timeslot["fulfilled_time_constraints"], "fulfilled_time_constraints"
+            )
+
+            if prev_id is not None:
+                self.assertLess(
+                    prev_id, int(timeslot["id"]), "timeslot ids must be increasing"
+                )
+            prev_id = int(timeslot["id"])
+
+        self.assertEqual(
+            self.export_dict["participants"], [], "Empty participant list expected"
+        )
+        self.assertEqual(
+            self.export_dict["info"].keys(),
+            {"title", "slug", "place", "contact_email"},
+            "info keys not as expected",
+        )
+        self.assertEqual(self.event.name, self.export_dict["info"]["title"])
+        self.assertEqual(self.event.slug, self.export_dict["info"]["slug"])
+        self.assertEqual(self.event.place, self.export_dict["info"]["place"])
+        self.assertEqual(
+            self.event.contact_email, self.export_dict["info"]["contact_email"]
+        )
+
+        _check_uniqueness(self.export_dict["aks"], "AK")
+        _check_uniqueness(self.export_dict["rooms"], "Room")
+        _check_uniqueness(self.export_dict["participants"], "Participants")
+        _check_uniqueness(
+            chain.from_iterable(self.export_dict["timeslots"]["blocks"]), "Timeslots"
+        )
+
+    def test_ak_durations(self):
+        for slot in self.ak_slots:
+            ak = self.export_aks[str(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",
+            )
+
+    def test_ak_conflicts(self):
+        for slot in self.ak_slots:
+            ak = self.export_aks[str(slot.pk)]
+            conflict_slots = self.ak_slots.filter(
+                ak__in=slot.ak.conflicts.all()
+            ).values_list("pk", flat=True)
+            conflict_pks = {str(conflict_pk) for conflict_pk in conflict_slots}
+
+            ## Uncomment if multi-slot AKs are implemented
+            # other_ak_slots = self.ak_slots.filter(ak=slot.ak).exclude(pk=slot.pk).values_list('pk', flat=True)
+            # conflict_pks.update(str(other_slot_pk) for other_slot_pk in other_ak_slots)
+
+            self.assertEqual(
+                conflict_pks,
+                set(ak["properties"]["conflicts"]),
+                f"Conflicts for slot {slot.pk} not as expected",
+            )
+
+    def test_ak_depenedencies(self):
+        for slot in self.ak_slots:
+            ak = self.export_aks[str(slot.pk)]
+            dependency_slots = self.ak_slots.filter(
+                ak__in=slot.ak.prerequisites.all()
+            ).values_list("pk", flat=True)
+
+            self.assertEqual(
+                {str(dep_pk) for dep_pk in dependency_slots},
+                set(ak["properties"]["dependencies"]),
+                f"Dependencies for slot {slot.pk} not as expected",
+            )
-- 
GitLab


From 0c882fe14c2e5addd0ebfdc7670646ea182f2b95 Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Sat, 25 Jan 2025 03:11:00 +0100
Subject: [PATCH 04/39] check constraint lists for uniqueness

---
 AKModel/tests.py | 9 ++++++---
 1 file changed, 6 insertions(+), 3 deletions(-)

diff --git a/AKModel/tests.py b/AKModel/tests.py
index 110b49fb..6f0a3306 100644
--- a/AKModel/tests.py
+++ b/AKModel/tests.py
@@ -441,9 +441,10 @@ class JSONExportTest(TestCase):
         )
 
     def test_conformity_to_spec(self):
-        def _check_uniqueness(lst, name: str):
-            id_lst = [entry["id"] for entry in lst]
-            self.assertEqual(len(id_lst), len(set(id_lst)), f"{name} IDs not unique!")
+        def _check_uniqueness(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(attr, cls, name: str) -> None:
             self.assertTrue(isinstance(attr, cls), f"{item} {name} not a {cls}")
@@ -454,6 +455,8 @@ class JSONExportTest(TestCase):
                 all(isinstance(c, contained_type) for c in lst),
                 f"{item} has non-{contained_type} {name}",
             )
+            if contained_type in {str, int}:
+                _check_uniqueness(lst, name, key=None)
 
         for ak in self.export_dict["aks"]:
             item = f"AK {ak['id']}"
-- 
GitLab


From 5fb5b3acc9f8314d7005839e60078ed86148e0de Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Sat, 25 Jan 2025 04:26:16 +0100
Subject: [PATCH 05/39] Add more tests

---
 AKModel/tests.py | 13 +++++++++++++
 1 file changed, 13 insertions(+)

diff --git a/AKModel/tests.py b/AKModel/tests.py
index 6f0a3306..347edd69 100644
--- a/AKModel/tests.py
+++ b/AKModel/tests.py
@@ -639,3 +639,16 @@ class JSONExportTest(TestCase):
                 set(ak["properties"]["dependencies"]),
                 f"Dependencies for slot {slot.pk} not as expected",
             )
+
+    def test_ak_reso(self):
+        for slot in self.ak_slots:
+            ak = self.export_aks[str(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):
+        for slot in self.ak_slots:
+            ak = self.export_aks[str(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)
-- 
GitLab


From 6c912268f44927d8de95aa9476027ce49c3557f7 Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Sat, 25 Jan 2025 04:33:00 +0100
Subject: [PATCH 06/39] Add room constr test

---
 AKModel/tests.py | 27 ++++++++++++++++++++++++++-
 1 file changed, 26 insertions(+), 1 deletion(-)

diff --git a/AKModel/tests.py b/AKModel/tests.py
index 347edd69..121271f1 100644
--- a/AKModel/tests.py
+++ b/AKModel/tests.py
@@ -650,5 +650,30 @@ class JSONExportTest(TestCase):
         for slot in self.ak_slots:
             ak = self.export_aks[str(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"]["head"], ", ".join(map(str, slot.ak.owners.all()))
+            )
             self.assertEqual(ak["info"]["description"], slot.ak.description)
+
+    def test_ak_room_constraints(self):
+        for slot in self.ak_slots:
+            ak = self.export_aks[str(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"availability-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):
+        # TODO
+        ...
-- 
GitLab


From 983d1f8e89ff2b803fce65aac199d3860b333161 Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Sat, 25 Jan 2025 04:47:46 +0100
Subject: [PATCH 07/39] Fix import

---
 .gitlab-ci.yml   | 2 +-
 AKModel/tests.py | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 55ef5901..41f4e0c1 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -18,7 +18,7 @@ cache:
   before_script:
     - python -V  # Print out python version for debugging
     - apt-get -qq update
-    - apt-get -qq install -y python3-virtualenv python3 python3-dev python3-pip gettext default-mysql-client default-libmysqlclient-dev
+    - apt-get -qq install -y python3-virtualenv python3 python3-dev python3-pip gettext default-mysql-client default-libmysqlclient-dev python3-bs4
     - ./Utils/setup.sh --ci
     - mkdir -p public/badges public/lint
     - echo undefined > public/badges/$CI_JOB_NAME.score
diff --git a/AKModel/tests.py b/AKModel/tests.py
index 121271f1..c1297b28 100644
--- a/AKModel/tests.py
+++ b/AKModel/tests.py
@@ -395,7 +395,7 @@ class JSONExportTest(TestCase):
 
             soup = BeautifulSoup(self.response.content, features="lxml")
             self.export_dict = json.loads(soup.find("pre").string)
-        except ImportError:
+        except ModuleNotFoundError:
             # without beautiful soup: just reconstruct the template
             print("Import failed!")
             ak_slots = (
-- 
GitLab


From 6420daf82d963e6f0a343d37e8e9e307687598d9 Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Sat, 25 Jan 2025 04:55:00 +0100
Subject: [PATCH 08/39] Tweak runner

---
 .gitlab-ci.yml | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 41f4e0c1..94903153 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -18,7 +18,7 @@ cache:
   before_script:
     - python -V  # Print out python version for debugging
     - apt-get -qq update
-    - apt-get -qq install -y python3-virtualenv python3 python3-dev python3-pip gettext default-mysql-client default-libmysqlclient-dev python3-bs4
+    - apt-get -qq install -y python3-virtualenv python3 python3-dev python3-pip gettext default-mysql-client default-libmysqlclient-dev
     - ./Utils/setup.sh --ci
     - mkdir -p public/badges public/lint
     - echo undefined > public/badges/$CI_JOB_NAME.score
@@ -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
-- 
GitLab


From 0f662a81805040f88bc3deba9bdf332547ec7e1d Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Sat, 25 Jan 2025 05:01:06 +0100
Subject: [PATCH 09/39] make bs4 mandatory

---
 AKModel/tests.py | 31 +++----------------------------
 1 file changed, 3 insertions(+), 28 deletions(-)

diff --git a/AKModel/tests.py b/AKModel/tests.py
index c1297b28..22cc94bd 100644
--- a/AKModel/tests.py
+++ b/AKModel/tests.py
@@ -4,6 +4,7 @@ import traceback
 from itertools import chain
 from typing import List
 
+from bs4 import BeautifulSoup
 from django.contrib.auth import get_user_model
 from django.contrib.messages import get_messages
 from django.contrib.messages.storage.base import Message
@@ -389,35 +390,9 @@ class JSONExportTest(TestCase):
         self.response = self.client.get(export_url)
 
         self.assertEqual(self.response.status_code, 200, "Export not working at all")
-        try:
-
-            from bs4 import BeautifulSoup
-
-            soup = BeautifulSoup(self.response.content, features="lxml")
-            self.export_dict = json.loads(soup.find("pre").string)
-        except ModuleNotFoundError:
-            # without beautiful soup: just reconstruct the template
-            print("Import failed!")
-            ak_slots = (
-                "["
-                + ", ".join([slot.as_json() for slot in self.response.context["slots"]])
-                + "]"
-            )
-            rooms = (
-                "["
-                + ", ".join([room.as_json() for room in self.response.context["rooms"]])
-                + "]"
-            )
 
-            export_str = f"""{{
-            "aks": {ak_slots},
-            "rooms": {rooms},
-            "participants": {self.response.context["participants"]},
-            "timeslots": {self.response.context["timeslots"]},
-            "info": {"participants": {self.response.context["info_dict"]}}
-            }}
-            """
-            self.export_dict = json.loads(export_str)
+        soup = BeautifulSoup(self.response.content, features="lxml")
+        self.export_dict = json.loads(soup.find("pre").string)
 
         self.export_aks = {ak["id"]: ak for ak in self.export_dict["aks"]}
         self.export_rooms = {room["id"]: room for room in self.export_dict["rooms"]}
-- 
GitLab


From ce1d9eac61887d145c65ea7094134104b7f317f5 Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Sat, 25 Jan 2025 18:26:05 +0100
Subject: [PATCH 10/39] Add time constraint test

---
 AKModel/tests.py | 36 ++++++++++++++++++++++++++++++++++--
 1 file changed, 34 insertions(+), 2 deletions(-)

diff --git a/AKModel/tests.py b/AKModel/tests.py
index 22cc94bd..f7482f48 100644
--- a/AKModel/tests.py
+++ b/AKModel/tests.py
@@ -11,6 +11,7 @@ from django.contrib.messages.storage.base import Message
 from django.test import TestCase
 from django.urls import reverse_lazy, reverse
 
+from AKModel.availability.models import Availability
 from AKModel.models import (
     Event,
     AKOwner,
@@ -650,5 +651,36 @@ class JSONExportTest(TestCase):
             )
 
     def test_ak_time_constraints(self):
-        # TODO
-        ...
+        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
+
+            # fixed slot
+            if slot.fixed and slot.start is not None:
+                time_constraints.add(f"availability-ak-{slot.ak.pk}")
+
+            # restricted AK availability
+            if not Availability.is_event_covered(
+                slot.event, slot.ak.availabilities.all()
+            ):
+                time_constraints.add(f"availability-ak-{slot.ak.pk}")
+
+            for owner in slot.ak.owners.all():
+                # restricted owner availability
+                if not Availability.is_event_covered(
+                    slot.event, owner.availabilities.all()
+                ):
+                    time_constraints.add(f"availability-person-{owner.pk}")
+
+            ak = self.export_aks[str(slot.pk)]
+            self.assertEqual(
+                set(ak["time_constraints"]),
+                time_constraints,
+                f"Time constraints for slot {slot.pk} not as expected",
+            )
-- 
GitLab


From ac891b035b0cba01260456fc1244db8f4f4b089d Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Sat, 25 Jan 2025 18:29:24 +0100
Subject: [PATCH 11/39] Test duration_in_hours

---
 AKModel/tests.py | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/AKModel/tests.py b/AKModel/tests.py
index f7482f48..248f32e6 100644
--- a/AKModel/tests.py
+++ b/AKModel/tests.py
@@ -585,6 +585,12 @@ class JSONExportTest(TestCase):
                 "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):
         for slot in self.ak_slots:
             ak = self.export_aks[str(slot.pk)]
-- 
GitLab


From e3b979c1ef1bb19da004f34c31bda88dcaa4a0d7 Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Sat, 25 Jan 2025 18:54:04 +0100
Subject: [PATCH 12/39] Add more tests

---
 AKModel/tests.py | 28 ++++++++++++++++++++++++++++
 1 file changed, 28 insertions(+)

diff --git a/AKModel/tests.py b/AKModel/tests.py
index 248f32e6..8fb5de1b 100644
--- a/AKModel/tests.py
+++ b/AKModel/tests.py
@@ -690,3 +690,31 @@ class JSONExportTest(TestCase):
                 time_constraints,
                 f"Time constraints for slot {slot.pk} not as expected",
             )
+
+    def test_all_rooms_exported(self):
+        self.assertEqual(
+            {str(room.pk) for room in self.rooms},
+            self.export_rooms.keys(),
+            "Exported Rooms do not match the Rooms of the event",
+        )
+
+    def test_room_capacity(self):
+        for room in self.rooms:
+            export_room = self.export_rooms[str(room.pk)]
+            self.assertEqual(room.capacity, export_room["capacity"])
+
+    def test_room_info(self):
+        for room in self.rooms:
+            export_room = self.export_rooms[str(room.pk)]
+            self.assertEqual(room.name, export_room["info"]["name"])
+
+    def test_room_timeconstraints(self):
+        for room in self.rooms:
+            time_constraints = set()
+
+            # test if time availability of room is restricted
+            if not Availability.is_event_covered(room.event, room.availabilities.all()):
+                time_constraints.add(f"availability-room-{room.pk}")
+
+            export_room = self.export_rooms[str(room.pk)]
+            self.assertEqual(time_constraints, set(export_room["time_constraints"]))
-- 
GitLab


From bbf8a41439b360a699f8c0702161fa2edf90d7da Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Sun, 26 Jan 2025 00:32:17 +0100
Subject: [PATCH 13/39] Test fulfilled room constr

---
 AKModel/tests.py | 21 +++++++++++++++++++++
 1 file changed, 21 insertions(+)

diff --git a/AKModel/tests.py b/AKModel/tests.py
index 8fb5de1b..31a07cbe 100644
--- a/AKModel/tests.py
+++ b/AKModel/tests.py
@@ -718,3 +718,24 @@ class JSONExportTest(TestCase):
 
             export_room = self.export_rooms[str(room.pk)]
             self.assertEqual(time_constraints, set(export_room["time_constraints"]))
+
+    def test_room_fulfilledroomconstraints(self):
+        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"availability-room-{room.pk}")
+
+            export_room = self.export_rooms[str(room.pk)]
+            self.assertEqual(
+                fulfilled_room_constraints,
+                set(export_room["fulfilled_room_constraints"]),
+            )
-- 
GitLab


From eaaebd5413686ad3417fdf7bbf8cf8c645e42e19 Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Sun, 26 Jan 2025 14:11:45 +0100
Subject: [PATCH 14/39] Store start and end in timeslot info dict

---
 AKModel/tests.py    | 2 +-
 AKModel/views/ak.py | 3 ++-
 2 files changed, 3 insertions(+), 2 deletions(-)

diff --git a/AKModel/tests.py b/AKModel/tests.py
index 31a07cbe..818864f5 100644
--- a/AKModel/tests.py
+++ b/AKModel/tests.py
@@ -532,7 +532,7 @@ class JSONExportTest(TestCase):
             )
             self.assertEqual(
                 timeslot["info"].keys(),
-                {"start"},
+                {"start", "end"},
                 f"{item} info keys not as expected",
             )
             _check_type(timeslot["id"], str, "id")
diff --git a/AKModel/views/ak.py b/AKModel/views/ak.py
index 1d60717b..7d51a12a 100644
--- a/AKModel/views/ak.py
+++ b/AKModel/views/ak.py
@@ -152,7 +152,8 @@ class AKJSONExportView(AdminViewMixin, FilterByEventSlugMixin, ListView):
                 current_block.append({
                     "id": str(timeslot.idx),
                     "info": {
-                        "start": timeslot.avail.simplified,
+                        "start": timeslot.avail.start.astimezone(self.event.timezone).strftime("%Y-%m-%d %H:%M"),
+                        "end": timeslot.avail.end.astimezone(self.event.timezone).strftime("%Y-%m-%d %H:%M"),
                     },
                     "fulfilled_time_constraints": time_constraints,
                     })
-- 
GitLab


From ba45c39d54ecf0eccb21b0aed68670567f0520f1 Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Sun, 26 Jan 2025 15:16:34 +0100
Subject: [PATCH 15/39] Test timeslot consecutivity

---
 AKModel/tests.py | 28 ++++++++++++++++++++++++++++
 1 file changed, 28 insertions(+)

diff --git a/AKModel/tests.py b/AKModel/tests.py
index 818864f5..e10367dd 100644
--- a/AKModel/tests.py
+++ b/AKModel/tests.py
@@ -1,8 +1,11 @@
 import json
 import math
 import traceback
+from collections import defaultdict
+from datetime import datetime
 from itertools import chain
 from typing import List
+from zoneinfo import ZoneInfo
 
 from bs4 import BeautifulSoup
 from django.contrib.auth import get_user_model
@@ -739,3 +742,28 @@ class JSONExportTest(TestCase):
                 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 test_timeslots_consecutive(self):
+        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
+
-- 
GitLab


From 19a52f2f889acca7708eb1030de83476856614e7 Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Sun, 26 Jan 2025 15:17:12 +0100
Subject: [PATCH 16/39] Test that default slots are covered

---
 AKModel/tests.py | 45 +++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 45 insertions(+)

diff --git a/AKModel/tests.py b/AKModel/tests.py
index e10367dd..ffe3e521 100644
--- a/AKModel/tests.py
+++ b/AKModel/tests.py
@@ -767,3 +767,48 @@ class JSONExportTest(TestCase):
                 self.assertLessEqual(prev_end, start)
             prev_end = end
 
+    def test_block_cover_default_slots(self):
+        if not DefaultSlot.objects.filter(event=self.event).exists():
+            # nothing to test
+            print("No default slots")
+            return
+
+        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)
+
+        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)
+                    )
+
+        cat_avails = {
+            cat_name: Availability.union(avail_lst)
+            for cat_name, avail_lst in default_slots_avails.items()
+        }
+
+        export_cat_avails = {
+            cat_name: Availability.union(avail_lst)
+            for cat_name, avail_lst in export_slot_cat_avails.items()
+        }
+
+        for cat_name, cat_avails in cat_avails.items():
+            for avail in cat_avails:
+                # check that all default slot availabilities are covered
+                self.assertTrue(
+                    any(
+                        export_avail.contains(avail)
+                        for export_avail in export_cat_avails[cat_name]
+                    )
+                )
-- 
GitLab


From 3b4a67604d6a5b4201aad4372fe47a6981abf500 Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Sun, 26 Jan 2025 15:18:27 +0100
Subject: [PATCH 17/39] Add default slots to fixture

---
 AKModel/fixtures/model.json | 44 +++++++++++++++++++++++++++++++++++--
 1 file changed, 42 insertions(+), 2 deletions(-)

diff --git a/AKModel/fixtures/model.json b/AKModel/fixtures/model.json
index d848041d..ca72040b 100644
--- a/AKModel/fixtures/model.json
+++ b/AKModel/fixtures/model.json
@@ -93,7 +93,7 @@
     "model": "AKModel.akcategory",
     "pk": 1,
     "fields": {
-        "name": "Spa▀",
+        "name": "Spaß",
         "color": "275246",
         "description": "",
         "present_by_default": true,
@@ -115,7 +115,7 @@
     "model": "AKModel.akcategory",
     "pk": 3,
     "fields": {
-        "name": "Spa▀/Kultur",
+        "name": "Spaß/Kultur",
         "color": "333333",
         "description": "",
         "present_by_default": true,
@@ -668,5 +668,45 @@
         "start": "2020-11-07T18:30:00Z",
         "end": "2020-11-07T21:30:00Z"
     }
+},
+{
+    "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]
+    }
 }
 ]
-- 
GitLab


From c35b8f23a4e7b7248f22d2262499ead6654c8c3e Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Sun, 26 Jan 2025 15:18:37 +0100
Subject: [PATCH 18/39] Use classfunc

---
 AKModel/tests.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/AKModel/tests.py b/AKModel/tests.py
index ffe3e521..93637af3 100644
--- a/AKModel/tests.py
+++ b/AKModel/tests.py
@@ -410,7 +410,7 @@ class JSONExportTest(TestCase):
         self.aks = AK.objects.filter(event__slug=self.event_slug).all()
 
         self.slots_in_an_hour = 1 / self.export_dict["timeslots"]["info"]["duration"]
-        self.event = Event.objects.filter(slug=self.event_slug).get()
+        self.event = Event.get_by_slug(self.event_slug)
 
     def test_all_aks_exported(self):
         self.assertEqual(
-- 
GitLab


From 0d8cec12b4f7b10ce87736bd063d080b5b42bdec Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Sun, 26 Jan 2025 16:07:53 +0100
Subject: [PATCH 19/39] Add test if discretization without DefaultSlots works

---
 AKModel/tests.py | 69 +++++++++++++++++++++++++++++++++++++-----------
 1 file changed, 54 insertions(+), 15 deletions(-)

diff --git a/AKModel/tests.py b/AKModel/tests.py
index 93637af3..e9754f64 100644
--- a/AKModel/tests.py
+++ b/AKModel/tests.py
@@ -2,7 +2,7 @@ import json
 import math
 import traceback
 from collections import defaultdict
-from datetime import datetime
+from datetime import datetime, timedelta
 from itertools import chain
 from typing import List
 from zoneinfo import ZoneInfo
@@ -752,6 +752,21 @@ class JSONExportTest(TestCase):
         )
         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 test_timeslots_consecutive(self):
         prev_end = None
         for timeslot in chain.from_iterable(self.export_dict["timeslots"]["blocks"]):
@@ -783,25 +798,12 @@ class JSONExportTest(TestCase):
             for cat in def_slot.primary_categories.all():
                 default_slots_avails[cat.name].append(avail)
 
-        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)
-                    )
-
         cat_avails = {
             cat_name: Availability.union(avail_lst)
             for cat_name, avail_lst in default_slots_avails.items()
         }
 
-        export_cat_avails = {
-            cat_name: Availability.union(avail_lst)
-            for cat_name, avail_lst in export_slot_cat_avails.items()
-        }
+        export_cat_avails = self._get_cat_availability_in_export()
 
         for cat_name, cat_avails in cat_avails.items():
             for avail in cat_avails:
@@ -812,3 +814,40 @@ class JSONExportTest(TestCase):
                         for export_avail in export_cat_avails[cat_name]
                     )
                 )
+
+    def test_block_cover_event(self):
+        if DefaultSlot.objects.filter(event=self.event).exists():
+            # nothing to test
+            return
+
+        start = self.event.start.astimezone(self.event.timezone)
+        end = self.event.end.astimezone(self.event.timezone)
+
+        delta = (end - start).total_seconds()
+        slot_seconds = 3600 / self.slots_in_an_hour
+
+        remainder_seconds = delta % slot_seconds
+        remainder_seconds += 1  # add a second to compensate rounding errs
+        seconds = timedelta(seconds=remainder_seconds)
+        end -= seconds
+
+        # set seconds and microseconds to 0 as they are not exported to the json
+        end -= timedelta(seconds=end.second, microseconds=end.microsecond)
+        event_avail = Availability(event=self.event, start=start, end=end)
+
+        cat_names = (
+            AKCategory.objects.filter(event=self.event)
+            .values_list("name", flat=True)
+            .all()
+        )
+        export_cat_avails = self._get_cat_availability_in_export()
+
+        for cat_name in cat_names:
+            # check that all default slot availabilities are covered
+            self.assertTrue(
+                any(
+                    avail.contains(event_avail) for avail in export_cat_avails[cat_name]
+                ),
+                f"AKCategory {cat_name}: Event ({event_avail.start} – {event_avail.end}) "
+                f"not covered by {[f'{avail.start} – {avail.end}' for avail in export_cat_avails[cat_name]]}",
+            )
-- 
GitLab


From 71f277f9d1ebc29775d9c90e7b3b5d9d73d56463 Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Sun, 26 Jan 2025 16:44:21 +0100
Subject: [PATCH 20/39] Add classmethod to check if avail is covered

---
 AKModel/availability/models.py | 13 +++++++++++--
 1 file changed, 11 insertions(+), 2 deletions(-)

diff --git a/AKModel/availability/models.py b/AKModel/availability/models.py
index 27a6c228..e2a64b22 100644
--- a/AKModel/availability/models.py
+++ b/AKModel/availability/models.py
@@ -280,6 +280,16 @@ 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.
@@ -292,8 +302,7 @@ class Availability(models.Model):
         # 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)
-        avail_union = Availability.union(availabilities)
-        return any(avail.contains(full_event) for avail in avail_union)
+        return full_event.is_covered(availabilities)
 
     class Meta:
         verbose_name = _('Availability')
-- 
GitLab


From c1aed9be43d57b5314b1b5eef6f175ed52691724 Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Sun, 26 Jan 2025 16:47:38 +0100
Subject: [PATCH 21/39] Refactor

---
 AKModel/tests.py | 112 ++++++++++++++++++++---------------------------
 1 file changed, 47 insertions(+), 65 deletions(-)

diff --git a/AKModel/tests.py b/AKModel/tests.py
index e9754f64..5beea468 100644
--- a/AKModel/tests.py
+++ b/AKModel/tests.py
@@ -408,6 +408,9 @@ class JSONExportTest(TestCase):
         self.ak_slots = AKSlot.objects.filter(event__slug=self.event_slug).all()
         self.rooms = Room.objects.filter(event__slug=self.event_slug).all()
         self.aks = AK.objects.filter(event__slug=self.event_slug).all()
+        self.category_names = AKCategory.objects.filter(
+            event__slug=self.event_slug
+        ).values_list("name", flat=True)
 
         self.slots_in_an_hour = 1 / self.export_dict["timeslots"]["info"]["duration"]
         self.event = Event.get_by_slug(self.event_slug)
@@ -767,6 +770,42 @@ class JSONExportTest(TestCase):
             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()
+            }
+        else:
+            # 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
+            seconds = timedelta(seconds=remainder_seconds)
+            end -= seconds
+
+            # set seconds and microseconds to 0 as they are not exported to the json
+            end -= timedelta(seconds=end.second, microseconds=end.microsecond)
+            event_avail = Availability(event=self.event, start=start, end=end)
+            return {cat_name: [event_avail] for cat_name in self.category_names}
+
     def test_timeslots_consecutive(self):
         prev_end = None
         for timeslot in chain.from_iterable(self.export_dict["timeslots"]["blocks"]):
@@ -782,72 +821,15 @@ class JSONExportTest(TestCase):
                 self.assertLessEqual(prev_end, start)
             prev_end = end
 
-    def test_block_cover_default_slots(self):
-        if not DefaultSlot.objects.filter(event=self.event).exists():
-            # nothing to test
-            print("No default slots")
-            return
-
-        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)
-
-        cat_avails = {
-            cat_name: Availability.union(avail_lst)
-            for cat_name, avail_lst in default_slots_avails.items()
-        }
-
+    def test_block_cover_categories(self):
         export_cat_avails = self._get_cat_availability_in_export()
+        cat_avails = self._get_cat_availability()
 
-        for cat_name, cat_avails in cat_avails.items():
-            for avail in cat_avails:
-                # check that all default slot availabilities are covered
+        for cat_name in self.category_names:
+            for avail in cat_avails[cat_name]:
+                # check that all category availabilities are covered
                 self.assertTrue(
-                    any(
-                        export_avail.contains(avail)
-                        for export_avail in export_cat_avails[cat_name]
-                    )
+                    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 test_block_cover_event(self):
-        if DefaultSlot.objects.filter(event=self.event).exists():
-            # nothing to test
-            return
-
-        start = self.event.start.astimezone(self.event.timezone)
-        end = self.event.end.astimezone(self.event.timezone)
-
-        delta = (end - start).total_seconds()
-        slot_seconds = 3600 / self.slots_in_an_hour
-
-        remainder_seconds = delta % slot_seconds
-        remainder_seconds += 1  # add a second to compensate rounding errs
-        seconds = timedelta(seconds=remainder_seconds)
-        end -= seconds
-
-        # set seconds and microseconds to 0 as they are not exported to the json
-        end -= timedelta(seconds=end.second, microseconds=end.microsecond)
-        event_avail = Availability(event=self.event, start=start, end=end)
-
-        cat_names = (
-            AKCategory.objects.filter(event=self.event)
-            .values_list("name", flat=True)
-            .all()
-        )
-        export_cat_avails = self._get_cat_availability_in_export()
-
-        for cat_name in cat_names:
-            # check that all default slot availabilities are covered
-            self.assertTrue(
-                any(
-                    avail.contains(event_avail) for avail in export_cat_avails[cat_name]
-                ),
-                f"AKCategory {cat_name}: Event ({event_avail.start} – {event_avail.end}) "
-                f"not covered by {[f'{avail.start} – {avail.end}' for avail in export_cat_avails[cat_name]]}",
-            )
-- 
GitLab


From 0a6c661845c95ea04f62adc021fdc835f50183e8 Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Sun, 26 Jan 2025 21:34:41 +0100
Subject: [PATCH 22/39] Test fulfilled_timeslot_constraints

---
 AKModel/tests.py | 78 ++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 78 insertions(+)

diff --git a/AKModel/tests.py b/AKModel/tests.py
index 5beea468..a06ccf00 100644
--- a/AKModel/tests.py
+++ b/AKModel/tests.py
@@ -833,3 +833,81 @@ class JSONExportTest(TestCase):
                     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 not Availability.is_event_covered(
+            self.event, availabilities
+        ) and slot.is_covered(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):
+        cat_avails = self._get_cat_availability()
+        for timeslot in chain.from_iterable(self.export_dict["timeslots"]["blocks"]):
+            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-{slot.ak.id}"
+                for slot in self.ak_slots
+                if self._is_restricted_and_contained_slot(
+                    timeslot_avail, Availability.union(slot.ak.availabilities.all())
+                )
+                or self._is_ak_fixed_in_slot(slot, timeslot_avail)
+            }
+
+            self.assertEqual(
+                fulfilled_time_constraints,
+                set(timeslot["fulfilled_time_constraints"]),
+            )
-- 
GitLab


From 7c0bd209e0ba15bd317481fe51c8c2a1393189ec Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Sun, 26 Jan 2025 21:35:03 +0100
Subject: [PATCH 23/39] Use is_covered function

---
 AKModel/views/ak.py | 8 +-------
 1 file changed, 1 insertion(+), 7 deletions(-)

diff --git a/AKModel/views/ak.py b/AKModel/views/ak.py
index 7d51a12a..954cb167 100644
--- a/AKModel/views/ak.py
+++ b/AKModel/views/ak.py
@@ -50,11 +50,6 @@ class AKJSONExportView(AdminViewMixin, FilterByEventSlugMixin, ListView):
     context_object_name = "slots"
     title = _("AK JSON Export")
 
-
-    def _test_slot_contained(self, slot: Availability, availabilities: List[Availability]) -> bool:
-        """Test if slot is contained in any member of availabilities."""
-        return any(availability.contains(slot) for availability in availabilities)
-
     def _test_event_not_covered(self, availabilities: List[Availability]) -> bool:
         """Test if event is not covered by availabilities."""
         return not Availability.is_event_covered(self.event, availabilities)
@@ -70,8 +65,7 @@ class AKJSONExportView(AdminViewMixin, FilterByEventSlugMixin, ListView):
     def _test_add_constraint(self, slot: Availability, availabilities: List[Availability]) -> bool:
         """Test if object is not available for whole event and may happen during slot."""
         return (
-            self._test_event_not_covered(availabilities)
-            and self._test_slot_contained(slot, availabilities)
+            self._test_event_not_covered(availabilities) and slot.is_covered(availabilities)
         )
 
     def get_queryset(self):
-- 
GitLab


From 5f5f183c46f71eeae7058e5f3aa636afcea25f31 Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Sun, 26 Jan 2025 21:41:55 +0100
Subject: [PATCH 24/39] Refactor info test

---
 AKModel/tests.py | 17 ++++++++---------
 1 file changed, 8 insertions(+), 9 deletions(-)

diff --git a/AKModel/tests.py b/AKModel/tests.py
index a06ccf00..00c28fe2 100644
--- a/AKModel/tests.py
+++ b/AKModel/tests.py
@@ -556,17 +556,16 @@ class JSONExportTest(TestCase):
         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(),
-            {"title", "slug", "place", "contact_email"},
-            "info keys not as expected",
-        )
-        self.assertEqual(self.event.name, self.export_dict["info"]["title"])
-        self.assertEqual(self.event.slug, self.export_dict["info"]["slug"])
-        self.assertEqual(self.event.place, self.export_dict["info"]["place"])
-        self.assertEqual(
-            self.event.contact_email, self.export_dict["info"]["contact_email"]
+            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])
 
         _check_uniqueness(self.export_dict["aks"], "AK")
         _check_uniqueness(self.export_dict["rooms"], "Room")
-- 
GitLab


From 30c66a415d4a7d1fc6015cdee724ac9dfe50413c Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Mon, 27 Jan 2025 00:42:43 +0100
Subject: [PATCH 25/39] Adapt tests to changed constraints

---
 AKModel/tests.py | 25 ++++++++++++++-----------
 1 file changed, 14 insertions(+), 11 deletions(-)

diff --git a/AKModel/tests.py b/AKModel/tests.py
index 00c28fe2..8aa16a6f 100644
--- a/AKModel/tests.py
+++ b/AKModel/tests.py
@@ -653,7 +653,7 @@ class JSONExportTest(TestCase):
 
             # fixed slot
             if slot.fixed and slot.room is not None:
-                requirements.append(f"availability-room-{slot.room.pk}")
+                requirements.append(f"fixed-room-{slot.room.pk}")
 
             self.assertEqual(
                 set(ak["room_constraints"]),
@@ -672,14 +672,13 @@ class JSONExportTest(TestCase):
                 )
                 time_constraints |= category_constraints
 
-            # fixed slot
             if slot.fixed and slot.start is not None:
-                time_constraints.add(f"availability-ak-{slot.ak.pk}")
-
-            # restricted AK availability
-            if not Availability.is_event_covered(
+                # 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():
@@ -737,7 +736,7 @@ class JSONExportTest(TestCase):
             ):
                 fulfilled_room_constraints.add("no-proxy")
 
-            fulfilled_room_constraints.add(f"availability-room-{room.pk}")
+            fulfilled_room_constraints.add(f"fixed-room-{room.pk}")
 
             export_room = self.export_rooms[str(room.pk)]
             self.assertEqual(
@@ -898,12 +897,16 @@ class JSONExportTest(TestCase):
 
             # add ak constraints
             fulfilled_time_constraints |= {
-                f"availability-ak-{slot.ak.id}"
-                for slot in self.ak_slots
+                f"availability-ak-{ak.id}"
+                for ak in self.aks
                 if self._is_restricted_and_contained_slot(
-                    timeslot_avail, Availability.union(slot.ak.availabilities.all())
+                    timeslot_avail, Availability.union(ak.availabilities.all())
                 )
-                or self._is_ak_fixed_in_slot(slot, timeslot_avail)
+            }
+            fulfilled_time_constraints |= {
+                f"fixed-akslot-{slot.id}"
+                for slot in self.ak_slots
+                if self._is_ak_fixed_in_slot(slot, timeslot_avail)
             }
 
             self.assertEqual(
-- 
GitLab


From 60b6f21391faa28cbada4403189f62c5f2202b53 Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Mon, 27 Jan 2025 00:43:25 +0100
Subject: [PATCH 26/39] Format

---
 AKModel/tests.py | 12 ++++++++----
 1 file changed, 8 insertions(+), 4 deletions(-)

diff --git a/AKModel/tests.py b/AKModel/tests.py
index 8aa16a6f..a705bc69 100644
--- a/AKModel/tests.py
+++ b/AKModel/tests.py
@@ -562,10 +562,14 @@ class JSONExportTest(TestCase):
             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"
+            self.export_dict["info"].keys(),
+            info_keys.keys(),
+            "info keys not as expected",
         )
         for attr, attr_field in info_keys.items():
-            self.assertEqual(getattr(self.event, attr_field), self.export_dict["info"][attr])
+            self.assertEqual(
+                getattr(self.event, attr_field), self.export_dict["info"][attr]
+            )
 
         _check_uniqueness(self.export_dict["aks"], "AK")
         _check_uniqueness(self.export_dict["rooms"], "Room")
@@ -836,9 +840,9 @@ class JSONExportTest(TestCase):
         self, slot: Availability, availabilities: list[Availability]
     ) -> bool:
         """Test if object is not available for whole event and may happen during slot."""
-        return not Availability.is_event_covered(
+        return slot.is_covered(availabilities) and not Availability.is_event_covered(
             self.event, availabilities
-        ) and slot.is_covered(availabilities)
+        )
 
     def _is_ak_fixed_in_slot(
         self,
-- 
GitLab


From fca792e6e26a56fc108733cd59163f1c7cd9bff5 Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Mon, 27 Jan 2025 00:54:23 +0100
Subject: [PATCH 27/39] Add fixed AKs to fixture

---
 AKModel/fixtures/model.json | 67 +++++++++++++++++++++++++++++++++++++
 1 file changed, 67 insertions(+)

diff --git a/AKModel/fixtures/model.json b/AKModel/fixtures/model.json
index ca72040b..78120ad3 100644
--- a/AKModel/fixtures/model.json
+++ b/AKModel/fixtures/model.json
@@ -436,6 +436,34 @@
         ]
     }
 },
+{
+    "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.room",
     "pk": 1,
@@ -525,6 +553,45 @@
         "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.constraintviolation",
     "pk": 1,
-- 
GitLab


From a359106b187de2a12fb9a0f4b7e28e925d729e9c Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Mon, 27 Jan 2025 00:54:41 +0100
Subject: [PATCH 28/39] Fix AKScheduling bug

---
 AKScheduling/models.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/AKScheduling/models.py b/AKScheduling/models.py
index aa6be3d0..8f34c151 100644
--- a/AKScheduling/models.py
+++ b/AKScheduling/models.py
@@ -365,8 +365,8 @@ def akslot_changed_handler(sender, instance: AKSlot, **kwargs):
     new_violations = []
 
     # For all slots in this room...
-    if instance.room:
-        for other_slot in instance.room.akslot_set.all():
+    if instance.room and instance.start:
+        for other_slot in instance.room.akslot_set.filter(start__isnull=False):
             if other_slot != instance:
                 # ... find overlapping slots...
                 if instance.overlaps(other_slot):
-- 
GitLab


From 3b3bdfe742c1cef16eb6e294c90b6d3b3563c714 Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Mon, 27 Jan 2025 01:56:32 +0100
Subject: [PATCH 29/39] Add docstrings

---
 AKModel/tests.py | 19 +++++++++++++++++++
 1 file changed, 19 insertions(+)

diff --git a/AKModel/tests.py b/AKModel/tests.py
index a705bc69..7ddb772b 100644
--- a/AKModel/tests.py
+++ b/AKModel/tests.py
@@ -377,6 +377,7 @@ class JSONExportTest(TestCase):
 
     @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",
@@ -387,6 +388,7 @@ class JSONExportTest(TestCase):
         )
 
     def setUp(self):
+        """Set up by retrieving json export and initializing data."""
         self.client.force_login(self.admin_user)
         export_url = reverse(
             "admin:ak_json_export", kwargs={"event_slug": self.event_slug}
@@ -416,6 +418,7 @@ class JSONExportTest(TestCase):
         self.event = Event.get_by_slug(self.event_slug)
 
     def test_all_aks_exported(self):
+        """Test if exported AKs match AKSlots of Event."""
         self.assertEqual(
             {str(slot.pk) for slot in self.ak_slots},
             self.export_aks.keys(),
@@ -423,6 +426,7 @@ class JSONExportTest(TestCase):
         )
 
     def test_conformity_to_spec(self):
+        """Test if JSON structure and types conform to standard."""
         def _check_uniqueness(lst, name: str, key: str | None = "id"):
             if key is not None:
                 lst = [entry[key] for entry in lst]
@@ -579,6 +583,7 @@ class JSONExportTest(TestCase):
         )
 
     def test_ak_durations(self):
+        """Test if all AK durations are correct."""
         for slot in self.ak_slots:
             ak = self.export_aks[str(slot.pk)]
 
@@ -601,6 +606,7 @@ class JSONExportTest(TestCase):
             )
 
     def test_ak_conflicts(self):
+        """Test if all AK conflicts are correct."""
         for slot in self.ak_slots:
             ak = self.export_aks[str(slot.pk)]
             conflict_slots = self.ak_slots.filter(
@@ -619,6 +625,7 @@ class JSONExportTest(TestCase):
             )
 
     def test_ak_depenedencies(self):
+        """Test if all AK dependencies are correct."""
         for slot in self.ak_slots:
             ak = self.export_aks[str(slot.pk)]
             dependency_slots = self.ak_slots.filter(
@@ -632,12 +639,14 @@ class JSONExportTest(TestCase):
             )
 
     def test_ak_reso(self):
+        """Test if resolution intent of AKs is correctly exported."""
         for slot in self.ak_slots:
             ak = self.export_aks[str(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 slot in self.ak_slots:
             ak = self.export_aks[str(slot.pk)]
             self.assertEqual(ak["info"]["name"], slot.ak.name)
@@ -647,6 +656,7 @@ class JSONExportTest(TestCase):
             self.assertEqual(ak["info"]["description"], slot.ak.description)
 
     def test_ak_room_constraints(self):
+        """Test if AK room constraints are exported as expected."""
         for slot in self.ak_slots:
             ak = self.export_aks[str(slot.pk)]
             requirements = list(slot.ak.requirements.values_list("name", flat=True))
@@ -666,6 +676,7 @@ class JSONExportTest(TestCase):
             )
 
     def test_ak_time_constraints(self):
+        """Test if AK time constraints are exported as expected."""
         for slot in self.ak_slots:
             time_constraints = set()
 
@@ -700,6 +711,7 @@ class JSONExportTest(TestCase):
             )
 
     def test_all_rooms_exported(self):
+        """Test if exported Rooms match the rooms of Event."""
         self.assertEqual(
             {str(room.pk) for room in self.rooms},
             self.export_rooms.keys(),
@@ -707,16 +719,19 @@ class JSONExportTest(TestCase):
         )
 
     def test_room_capacity(self):
+        """Test if room capacity is exported correctly."""
         for room in self.rooms:
             export_room = self.export_rooms[str(room.pk)]
             self.assertEqual(room.capacity, export_room["capacity"])
 
     def test_room_info(self):
+        """Test if contents of Room info dict is correct."""
         for room in self.rooms:
             export_room = self.export_rooms[str(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 room in self.rooms:
             time_constraints = set()
 
@@ -728,6 +743,7 @@ class JSONExportTest(TestCase):
             self.assertEqual(time_constraints, set(export_room["time_constraints"]))
 
     def test_room_fulfilledroomconstraints(self):
+        """Test if room constraints fulfilled by Room are correct."""
         for room in self.rooms:
             # room properties
             fulfilled_room_constraints = set(
@@ -809,6 +825,7 @@ class JSONExportTest(TestCase):
             return {cat_name: [event_avail] for cat_name in self.category_names}
 
     def test_timeslots_consecutive(self):
+        """Test if consecutive timeslots in JSON are in fact consecutive."""
         prev_end = None
         for timeslot in chain.from_iterable(self.export_dict["timeslots"]["blocks"]):
             start, end = self._get_timeslot_start_end(timeslot)
@@ -824,6 +841,7 @@ class JSONExportTest(TestCase):
             prev_end = end
 
     def test_block_cover_categories(self):
+        """Test if blocks covers all default slot resp. whole event per category."""
         export_cat_avails = self._get_cat_availability_in_export()
         cat_avails = self._get_cat_availability()
 
@@ -859,6 +877,7 @@ class JSONExportTest(TestCase):
         return timeslot_avail.overlaps(ak_slot_avail, strict=True)
 
     def test_timeslot_fulfilledconstraints(self):
+        """Test if fulfilled time constraints by timeslot are as expected."""
         cat_avails = self._get_cat_availability()
         for timeslot in chain.from_iterable(self.export_dict["timeslots"]["blocks"]):
             start, end = self._get_timeslot_start_end(timeslot)
-- 
GitLab


From 102f26ba2b210f672ecf9ec5918059ecf1042c72 Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Mon, 27 Jan 2025 02:01:41 +0100
Subject: [PATCH 30/39] Fix AKScheduling bug

---
 AKScheduling/api.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/AKScheduling/api.py b/AKScheduling/api.py
index e78fda78..2fe9bfd7 100644
--- a/AKScheduling/api.py
+++ b/AKScheduling/api.py
@@ -55,7 +55,7 @@ class EventsView(LoginRequiredMixin, EventSlugMixin, ListView):
     model = AKSlot
 
     def get_queryset(self):
-        return super().get_queryset().select_related('ak').filter(event=self.event, room__isnull=False)
+        return super().get_queryset().select_related('ak').filter(event=self.event, room__isnull=False, start__isnull=False)
 
     def render_to_response(self, context, **response_kwargs):
         return JsonResponse(
-- 
GitLab


From f60c1fa3c3982bafea500b123c6fa96648c7f404 Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Mon, 27 Jan 2025 02:24:42 +0100
Subject: [PATCH 31/39] Address code findings

---
 .gitlab-ci.yml      |   2 +
 AKModel/tests.py    | 169 ++++++++++++++++++++++++++------------------
 AKScheduling/api.py |   4 +-
 3 files changed, 104 insertions(+), 71 deletions(-)

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 94903153..5855c87c 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -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
diff --git a/AKModel/tests.py b/AKModel/tests.py
index 7ddb772b..ddd2db1f 100644
--- a/AKModel/tests.py
+++ b/AKModel/tests.py
@@ -5,7 +5,6 @@ from collections import defaultdict
 from datetime import datetime, timedelta
 from itertools import chain
 from typing import List
-from zoneinfo import ZoneInfo
 
 from bs4 import BeautifulSoup
 from django.contrib.auth import get_user_model
@@ -372,6 +371,12 @@ class ModelViewTests(BasicViewTests, TestCase):
 
 
 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"]
     event_slug = "kif42"
 
@@ -425,25 +430,28 @@ class JSONExportTest(TestCase):
             "Exported AKs does not match the AKSlots of the event",
         )
 
-    def test_conformity_to_spec(self):
-        """Test if JSON structure and types conform to standard."""
-        def _check_uniqueness(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(attr, cls, name: str) -> None:
-            self.assertTrue(isinstance(attr, cls), f"{item} {name} not a {cls}")
-
-        def _check_lst(lst: list[str], name: 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}:
-                _check_uniqueness(lst, name, key=None)
+    def _check_uniqueness(self, lst, name: str, key: str | None = "id"):
+        if key is not None:
+            lst = [entry[key] for entry in lst]
+        self.assertEqual(len(lst), len(set(lst)), f"{name} IDs not unique!")
+
+    def _check_type(self, attr, cls, name: str, item: str) -> None:
+        self.assertTrue(isinstance(attr, cls), f"{item} {name} not a {cls}")
+
+    def _check_lst(
+        self, lst: list[str], name: str, item: str, contained_type=str
+    ) -> None:
+        self.assertTrue(isinstance(lst, list), f"{item} {name} not a list")
+        self.assertTrue(
+            all(isinstance(c, contained_type) for c in lst),
+            f"{item} has non-{contained_type} {name}",
+        )
+        if contained_type in {str, int}:
+            self._check_uniqueness(lst, name, key=None)
 
+    def test_ak_conformity_to_spec(self):
+        """Test if AK JSON structure and types conform to standard."""
+        self._check_uniqueness(self.export_dict["aks"], "AK")
         for ak in self.export_dict["aks"]:
             item = f"AK {ak['id']}"
             self.assertEqual(
@@ -469,21 +477,29 @@ class JSONExportTest(TestCase):
                 f"{item} properties keys not as expected",
             )
 
-            _check_type(ak["id"], str, "id")
-            _check_type(ak["duration"], int, "duration")
-            _check_type(ak["info"]["name"], str, "info/name")
-            _check_type(ak["info"]["head"], str, "info/head")
-            _check_type(ak["info"]["description"], str, "info/description")
-            _check_type(ak["info"]["reso"], bool, "info/reso")
-            _check_type(
-                ak["info"]["duration_in_hours"], float, "info/duration_in_hours"
+            self._check_type(ak["id"], str, "id", item=item)
+            self._check_type(ak["duration"], int, "duration", item=item)
+            self._check_type(ak["info"]["name"], str, "info/name", item=item)
+            self._check_type(ak["info"]["head"], str, "info/head", item=item)
+            self._check_type(
+                ak["info"]["description"], str, "info/description", item=item
+            )
+            self._check_type(ak["info"]["reso"], bool, "info/reso", item=item)
+            self._check_type(
+                ak["info"]["duration_in_hours"],
+                float,
+                "info/duration_in_hours",
+                item=item,
             )
 
-            _check_lst(ak["properties"]["conflicts"], "conflicts")
-            _check_lst(ak["properties"]["dependencies"], "dependencies")
-            _check_lst(ak["time_constraints"], "time_constraints")
-            _check_lst(ak["room_constraints"], "room_constraints")
+            self._check_lst(ak["properties"]["conflicts"], "conflicts", item=item)
+            self._check_lst(ak["properties"]["dependencies"], "dependencies", item=item)
+            self._check_lst(ak["time_constraints"], "time_constraints", item=item)
+            self._check_lst(ak["room_constraints"], "room_constraints", item=item)
 
+    def test_room_conformity_to_spec(self):
+        """Test if Room JSON structure and types conform to standard."""
+        self._check_uniqueness(self.export_dict["rooms"], "Room")
         for room in self.export_dict["rooms"]:
             item = f"Room {room['id']}"
             self.assertEqual(
@@ -501,17 +517,26 @@ class JSONExportTest(TestCase):
                 room["info"].keys(), {"name"}, f"{item} info keys not as expected"
             )
 
-            _check_type(room["id"], str, "id")
-            _check_type(room["capacity"], int, "capacity")
-            _check_type(room["info"]["name"], str, "info/name")
+            self._check_type(room["id"], str, "id", item=item)
+            self._check_type(room["capacity"], int, "capacity", item=item)
+            self._check_type(room["info"]["name"], str, "info/name", item=item)
 
             self.assertTrue(
                 room["capacity"] > 0 or room["capacity"] == -1, "invalid room capacity"
             )
 
-            _check_lst(room["time_constraints"], "time_constraints")
-            _check_lst(room["fulfilled_room_constraints"], "fulfilled_room_constraints")
+            self._check_lst(room["time_constraints"], "time_constraints", item=item)
+            self._check_lst(
+                room["fulfilled_room_constraints"],
+                "fulfilled_room_constraints",
+                item=item,
+            )
 
+    def test_timeslots_conformity_to_spec(self):
+        """Test if Timeslots JSON structure and types conform to standard."""
+        self._check_uniqueness(
+            chain.from_iterable(self.export_dict["timeslots"]["blocks"]), "Timeslots"
+        )
         item = "timeslots"
         self.assertEqual(
             self.export_dict["timeslots"].keys(),
@@ -523,15 +548,19 @@ class JSONExportTest(TestCase):
             {"duration"},
             "timeslot info keys not as expected",
         )
-        _check_type(
-            self.export_dict["timeslots"]["info"]["duration"], float, "info/duration"
+        self._check_type(
+            self.export_dict["timeslots"]["info"]["duration"],
+            float,
+            "info/duration",
+            item=item,
         )
-        _check_lst(
-            self.export_dict["timeslots"]["blocks"], "blocks", contained_type=list
+        self._check_lst(
+            self.export_dict["timeslots"]["blocks"],
+            "blocks",
+            item=item,
+            contained_type=list,
         )
 
-        # TODO: Check if blocks are sorted
-
         prev_id = None
         for timeslot in chain.from_iterable(self.export_dict["timeslots"]["blocks"]):
             item = f"timeslot {timeslot['id']}"
@@ -545,10 +574,12 @@ class JSONExportTest(TestCase):
                 {"start", "end"},
                 f"{item} info keys not as expected",
             )
-            _check_type(timeslot["id"], str, "id")
-            _check_type(timeslot["info"]["start"], str, "info/start")
-            _check_lst(
-                timeslot["fulfilled_time_constraints"], "fulfilled_time_constraints"
+            self._check_type(timeslot["id"], str, "id", item=item)
+            self._check_type(timeslot["info"]["start"], str, "info/start", item=item)
+            self._check_lst(
+                timeslot["fulfilled_time_constraints"],
+                "fulfilled_time_constraints",
+                item=item,
             )
 
             if prev_id is not None:
@@ -557,6 +588,9 @@ class JSONExportTest(TestCase):
                 )
             prev_id = int(timeslot["id"])
 
+    def test_general_conformity_to_spec(self):
+        """Test if rest of JSON structure and types conform to standard."""
+
         self.assertEqual(
             self.export_dict["participants"], [], "Empty participant list expected"
         )
@@ -575,12 +609,7 @@ class JSONExportTest(TestCase):
                 getattr(self.event, attr_field), self.export_dict["info"][attr]
             )
 
-        _check_uniqueness(self.export_dict["aks"], "AK")
-        _check_uniqueness(self.export_dict["rooms"], "Room")
-        _check_uniqueness(self.export_dict["participants"], "Participants")
-        _check_uniqueness(
-            chain.from_iterable(self.export_dict["timeslots"]["blocks"]), "Timeslots"
-        )
+        self._check_uniqueness(self.export_dict["participants"], "Participants")
 
     def test_ak_durations(self):
         """Test if all AK durations are correct."""
@@ -805,24 +834,24 @@ class JSONExportTest(TestCase):
                 cat_name: Availability.union(avail_lst)
                 for cat_name, avail_lst in default_slots_avails.items()
             }
-        else:
-            # 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
-            seconds = timedelta(seconds=remainder_seconds)
-            end -= seconds
-
-            # set seconds and microseconds to 0 as they are not exported to the json
-            end -= timedelta(seconds=end.second, microseconds=end.microsecond)
-            event_avail = Availability(event=self.event, start=start, end=end)
-            return {cat_name: [event_avail] for cat_name in self.category_names}
+
+        # 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
+        seconds = timedelta(seconds=remainder_seconds)
+        end -= seconds
+
+        # set seconds and microseconds to 0 as they are not exported to the json
+        end -= timedelta(seconds=end.second, microseconds=end.microsecond)
+        event_avail = Availability(event=self.event, start=start, end=end)
+        return {cat_name: [event_avail] for cat_name in self.category_names}
 
     def test_timeslots_consecutive(self):
         """Test if consecutive timeslots in JSON are in fact consecutive."""
diff --git a/AKScheduling/api.py b/AKScheduling/api.py
index 2fe9bfd7..cfd476ec 100644
--- a/AKScheduling/api.py
+++ b/AKScheduling/api.py
@@ -55,7 +55,9 @@ class EventsView(LoginRequiredMixin, EventSlugMixin, ListView):
     model = AKSlot
 
     def get_queryset(self):
-        return super().get_queryset().select_related('ak').filter(event=self.event, room__isnull=False, start__isnull=False)
+        return super().get_queryset().select_related('ak').filter(
+            event=self.event, room__isnull=False, start__isnull=False
+        )
 
     def render_to_response(self, context, **response_kwargs):
         return JsonResponse(
-- 
GitLab


From 990f97aa4b772d3ebcec6bd870c3120b78383127 Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Mon, 27 Jan 2025 16:53:54 +0100
Subject: [PATCH 32/39] Run export tests on all events in fixture

---
 AKModel/tests.py | 824 ++++++++++++++++++++++++++---------------------
 1 file changed, 452 insertions(+), 372 deletions(-)

diff --git a/AKModel/tests.py b/AKModel/tests.py
index ddd2db1f..320e943c 100644
--- a/AKModel/tests.py
+++ b/AKModel/tests.py
@@ -378,7 +378,6 @@ class JSONExportTest(TestCase):
     """
 
     fixtures = ["model.json"]
-    event_slug = "kif42"
 
     @classmethod
     def setUpTestData(cls):
@@ -393,16 +392,19 @@ class JSONExportTest(TestCase):
         )
 
     def setUp(self):
-        """Set up by retrieving json export and initializing data."""
         self.client.force_login(self.admin_user)
+
+    def setUpPerEvent(self, event: Event) -> None:
+        """Set up by retrieving json export and initializing data."""
+
         export_url = reverse(
-            "admin:ak_json_export", kwargs={"event_slug": self.event_slug}
+            "admin:ak_json_export", kwargs={"event_slug": event.slug}
         )
-        self.response = self.client.get(export_url)
+        response = self.client.get(export_url)
 
-        self.assertEqual(self.response.status_code, 200, "Export not working at all")
+        self.assertEqual(response.status_code, 200, "Export not working at all")
 
-        soup = BeautifulSoup(self.response.content, features="lxml")
+        soup = BeautifulSoup(response.content, features="lxml")
         self.export_dict = json.loads(soup.find("pre").string)
 
         self.export_aks = {ak["id"]: ak for ak in self.export_dict["aks"]}
@@ -412,23 +414,26 @@ class JSONExportTest(TestCase):
             for participant in self.export_dict["participants"]
         }
 
-        self.ak_slots = AKSlot.objects.filter(event__slug=self.event_slug).all()
-        self.rooms = Room.objects.filter(event__slug=self.event_slug).all()
-        self.aks = AK.objects.filter(event__slug=self.event_slug).all()
+        self.ak_slots = AKSlot.objects.filter(event__slug=event.slug).all()
+        self.rooms = Room.objects.filter(event__slug=event.slug).all()
+        self.aks = AK.objects.filter(event__slug=event.slug).all()
         self.category_names = AKCategory.objects.filter(
-            event__slug=self.event_slug
+            event__slug=event.slug
         ).values_list("name", flat=True)
 
         self.slots_in_an_hour = 1 / self.export_dict["timeslots"]["info"]["duration"]
-        self.event = Event.get_by_slug(self.event_slug)
+        self.event = event
 
     def test_all_aks_exported(self):
         """Test if exported AKs match AKSlots of Event."""
-        self.assertEqual(
-            {str(slot.pk) for slot in self.ak_slots},
-            self.export_aks.keys(),
-            "Exported AKs does not match the AKSlots of the event",
-        )
+        for event in Event.objects.all():
+            with self.subTest(event=event):
+                self.setUpPerEvent(event=event)
+                self.assertEqual(
+                    {str(slot.pk) for slot in self.ak_slots},
+                    self.export_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:
@@ -451,347 +456,410 @@ class JSONExportTest(TestCase):
 
     def test_ak_conformity_to_spec(self):
         """Test if AK JSON structure and types conform to standard."""
-        self._check_uniqueness(self.export_dict["aks"], "AK")
-        for ak in self.export_dict["aks"]:
-            item = f"AK {ak['id']}"
-            self.assertEqual(
-                ak.keys(),
-                {
-                    "id",
-                    "duration",
-                    "properties",
-                    "room_constraints",
-                    "time_constraints",
-                    "info",
-                },
-                f"{item} keys not as expected",
-            )
-            self.assertEqual(
-                ak["info"].keys(),
-                {"name", "head", "description", "reso", "duration_in_hours"},
-                f"{item} info keys not as expected",
-            )
-            self.assertEqual(
-                ak["properties"].keys(),
-                {"conflicts", "dependencies"},
-                f"{item} properties keys not as expected",
-            )
+        for event in Event.objects.all():
+            with self.subTest(event=event):
+                self.setUpPerEvent(event=event)
+
+                self._check_uniqueness(self.export_dict["aks"], "AK")
+                for ak in self.export_dict["aks"]:
+                    item = f"AK {ak['id']}"
+                    self.assertEqual(
+                        ak.keys(),
+                        {
+                            "id",
+                            "duration",
+                            "properties",
+                            "room_constraints",
+                            "time_constraints",
+                            "info",
+                        },
+                        f"{item} keys not as expected",
+                    )
+                    self.assertEqual(
+                        ak["info"].keys(),
+                        {"name", "head", "description", "reso", "duration_in_hours"},
+                        f"{item} info keys not as expected",
+                    )
+                    self.assertEqual(
+                        ak["properties"].keys(),
+                        {"conflicts", "dependencies"},
+                        f"{item} properties keys not as expected",
+                    )
 
-            self._check_type(ak["id"], str, "id", item=item)
-            self._check_type(ak["duration"], int, "duration", item=item)
-            self._check_type(ak["info"]["name"], str, "info/name", item=item)
-            self._check_type(ak["info"]["head"], str, "info/head", item=item)
-            self._check_type(
-                ak["info"]["description"], str, "info/description", item=item
-            )
-            self._check_type(ak["info"]["reso"], bool, "info/reso", item=item)
-            self._check_type(
-                ak["info"]["duration_in_hours"],
-                float,
-                "info/duration_in_hours",
-                item=item,
-            )
+                    self._check_type(ak["id"], str, "id", item=item)
+                    self._check_type(ak["duration"], int, "duration", item=item)
+                    self._check_type(ak["info"]["name"], str, "info/name", item=item)
+                    self._check_type(ak["info"]["head"], str, "info/head", item=item)
+                    self._check_type(
+                        ak["info"]["description"], str, "info/description", item=item
+                    )
+                    self._check_type(ak["info"]["reso"], bool, "info/reso", item=item)
+                    self._check_type(
+                        ak["info"]["duration_in_hours"],
+                        float,
+                        "info/duration_in_hours",
+                        item=item,
+                    )
 
-            self._check_lst(ak["properties"]["conflicts"], "conflicts", item=item)
-            self._check_lst(ak["properties"]["dependencies"], "dependencies", item=item)
-            self._check_lst(ak["time_constraints"], "time_constraints", item=item)
-            self._check_lst(ak["room_constraints"], "room_constraints", item=item)
+                    self._check_lst(ak["properties"]["conflicts"], "conflicts", item=item)
+                    self._check_lst(ak["properties"]["dependencies"], "dependencies", item=item)
+                    self._check_lst(ak["time_constraints"], "time_constraints", item=item)
+                    self._check_lst(ak["room_constraints"], "room_constraints", item=item)
 
     def test_room_conformity_to_spec(self):
         """Test if Room JSON structure and types conform to standard."""
-        self._check_uniqueness(self.export_dict["rooms"], "Room")
-        for room in self.export_dict["rooms"]:
-            item = f"Room {room['id']}"
-            self.assertEqual(
-                room.keys(),
-                {
-                    "id",
-                    "info",
-                    "capacity",
-                    "fulfilled_room_constraints",
-                    "time_constraints",
-                },
-                f"{item} keys not as expected",
-            )
-            self.assertEqual(
-                room["info"].keys(), {"name"}, f"{item} info keys not as expected"
-            )
+        for event in Event.objects.all():
+            with self.subTest(event=event):
+                self.setUpPerEvent(event=event)
+
+                self._check_uniqueness(self.export_dict["rooms"], "Room")
+                for room in self.export_dict["rooms"]:
+                    item = f"Room {room['id']}"
+                    self.assertEqual(
+                        room.keys(),
+                        {
+                            "id",
+                            "info",
+                            "capacity",
+                            "fulfilled_room_constraints",
+                            "time_constraints",
+                        },
+                        f"{item} keys not as expected",
+                    )
+                    self.assertEqual(
+                        room["info"].keys(), {"name"}, f"{item} info keys not as expected"
+                    )
 
-            self._check_type(room["id"], str, "id", item=item)
-            self._check_type(room["capacity"], int, "capacity", item=item)
-            self._check_type(room["info"]["name"], str, "info/name", item=item)
+                    self._check_type(room["id"], str, "id", item=item)
+                    self._check_type(room["capacity"], int, "capacity", item=item)
+                    self._check_type(room["info"]["name"], str, "info/name", item=item)
 
-            self.assertTrue(
-                room["capacity"] > 0 or room["capacity"] == -1, "invalid room capacity"
-            )
+                    self.assertTrue(
+                        room["capacity"] > 0 or room["capacity"] == -1, "invalid room capacity"
+                    )
 
-            self._check_lst(room["time_constraints"], "time_constraints", item=item)
-            self._check_lst(
-                room["fulfilled_room_constraints"],
-                "fulfilled_room_constraints",
-                item=item,
-            )
+                    self._check_lst(room["time_constraints"], "time_constraints", item=item)
+                    self._check_lst(
+                        room["fulfilled_room_constraints"],
+                        "fulfilled_room_constraints",
+                        item=item,
+                    )
 
     def test_timeslots_conformity_to_spec(self):
         """Test if Timeslots JSON structure and types conform to standard."""
-        self._check_uniqueness(
-            chain.from_iterable(self.export_dict["timeslots"]["blocks"]), "Timeslots"
-        )
-        item = "timeslots"
-        self.assertEqual(
-            self.export_dict["timeslots"].keys(),
-            {"info", "blocks"},
-            "timeslot keys not as expected",
-        )
-        self.assertEqual(
-            self.export_dict["timeslots"]["info"].keys(),
-            {"duration"},
-            "timeslot info keys not as expected",
-        )
-        self._check_type(
-            self.export_dict["timeslots"]["info"]["duration"],
-            float,
-            "info/duration",
-            item=item,
-        )
-        self._check_lst(
-            self.export_dict["timeslots"]["blocks"],
-            "blocks",
-            item=item,
-            contained_type=list,
-        )
+        for event in Event.objects.all():
+            with self.subTest(event=event):
+                self.setUpPerEvent(event=event)
 
-        prev_id = None
-        for timeslot in chain.from_iterable(self.export_dict["timeslots"]["blocks"]):
-            item = f"timeslot {timeslot['id']}"
-            self.assertEqual(
-                timeslot.keys(),
-                {"id", "info", "fulfilled_time_constraints"},
-                f"{item} keys not as expected",
-            )
-            self.assertEqual(
-                timeslot["info"].keys(),
-                {"start", "end"},
-                f"{item} info keys not as expected",
-            )
-            self._check_type(timeslot["id"], str, "id", item=item)
-            self._check_type(timeslot["info"]["start"], str, "info/start", item=item)
-            self._check_lst(
-                timeslot["fulfilled_time_constraints"],
-                "fulfilled_time_constraints",
-                item=item,
-            )
-
-            if prev_id is not None:
-                self.assertLess(
-                    prev_id, int(timeslot["id"]), "timeslot ids must be increasing"
+                self._check_uniqueness(
+                    chain.from_iterable(self.export_dict["timeslots"]["blocks"]), "Timeslots"
+                )
+                item = "timeslots"
+                self.assertEqual(
+                    self.export_dict["timeslots"].keys(),
+                    {"info", "blocks"},
+                    "timeslot keys not as expected",
+                )
+                self.assertEqual(
+                    self.export_dict["timeslots"]["info"].keys(),
+                    {"duration"},
+                    "timeslot info keys not as expected",
+                )
+                self._check_type(
+                    self.export_dict["timeslots"]["info"]["duration"],
+                    float,
+                    "info/duration",
+                    item=item,
                 )
-            prev_id = int(timeslot["id"])
+                self._check_lst(
+                    self.export_dict["timeslots"]["blocks"],
+                    "blocks",
+                    item=item,
+                    contained_type=list,
+                )
+
+                prev_id = None
+                for timeslot in chain.from_iterable(self.export_dict["timeslots"]["blocks"]):
+                    item = f"timeslot {timeslot['id']}"
+                    self.assertEqual(
+                        timeslot.keys(),
+                        {"id", "info", "fulfilled_time_constraints"},
+                        f"{item} keys not as expected",
+                    )
+                    self.assertEqual(
+                        timeslot["info"].keys(),
+                        {"start", "end"},
+                        f"{item} info keys not as expected",
+                    )
+                    self._check_type(timeslot["id"], str, "id", item=item)
+                    self._check_type(timeslot["info"]["start"], str, "info/start", item=item)
+                    self._check_lst(
+                        timeslot["fulfilled_time_constraints"],
+                        "fulfilled_time_constraints",
+                        item=item,
+                    )
+
+                    if prev_id is not None:
+                        self.assertLess(
+                            prev_id, int(timeslot["id"]), "timeslot ids must be increasing"
+                        )
+                    prev_id = int(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.setUpPerEvent(event=event)
 
-        self.assertEqual(
-            self.export_dict["participants"], [], "Empty participant list expected"
-        )
+                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]
-            )
+                info_keys = {"title": "name", "slug": "slug"}
+                for attr in ["contact_email", "place"]:
+                    if hasattr(self.event, attr) and getattr(self.event, attr):
+                        info_keys[attr] = attr
+                self.assertEqual(
+                    self.export_dict["info"].keys(),
+                    info_keys.keys(),
+                    "info keys not as expected",
+                )
+                for attr, attr_field in info_keys.items():
+                    self.assertEqual(
+                        getattr(self.event, attr_field), self.export_dict["info"][attr]
+                    )
 
-        self._check_uniqueness(self.export_dict["participants"], "Participants")
+                self._check_uniqueness(self.export_dict["participants"], "Participants")
 
     def test_ak_durations(self):
         """Test if all AK durations are correct."""
-        for slot in self.ak_slots:
-            ak = self.export_aks[str(slot.pk)]
+        for event in Event.objects.all():
+            with self.subTest(event=event):
+                self.setUpPerEvent(event=event)
 
-            self.assertLessEqual(
-                float(slot.duration) * self.slots_in_an_hour - 1e-4,
-                ak["duration"],
-                "Slot duration is too short",
-            )
+                for slot in self.ak_slots:
+                    ak = self.export_aks[str(slot.pk)]
 
-            self.assertEqual(
-                math.ceil(float(slot.duration) * self.slots_in_an_hour - 1e-4),
-                ak["duration"],
-                "Slot duration is wrong",
-            )
+                    self.assertLessEqual(
+                        float(slot.duration) * self.slots_in_an_hour - 1e-4,
+                        ak["duration"],
+                        "Slot duration is too short",
+                    )
 
-            self.assertEqual(
-                float(slot.duration),
-                ak["info"]["duration_in_hours"],
-                "Slot duration_in_hours is wrong",
-            )
+                    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 slot in self.ak_slots:
-            ak = self.export_aks[str(slot.pk)]
-            conflict_slots = self.ak_slots.filter(
-                ak__in=slot.ak.conflicts.all()
-            ).values_list("pk", flat=True)
-            conflict_pks = {str(conflict_pk) for conflict_pk in conflict_slots}
-
-            ## Uncomment if multi-slot AKs are implemented
-            # other_ak_slots = self.ak_slots.filter(ak=slot.ak).exclude(pk=slot.pk).values_list('pk', flat=True)
-            # conflict_pks.update(str(other_slot_pk) for other_slot_pk in other_ak_slots)
-
-            self.assertEqual(
-                conflict_pks,
-                set(ak["properties"]["conflicts"]),
-                f"Conflicts for slot {slot.pk} not as expected",
-            )
+        for event in Event.objects.all():
+            with self.subTest(event=event):
+                self.setUpPerEvent(event=event)
+
+                for slot in self.ak_slots:
+                    ak = self.export_aks[str(slot.pk)]
+                    conflict_slots = self.ak_slots.filter(
+                        ak__in=slot.ak.conflicts.all()
+                    ).values_list("pk", flat=True)
+                    conflict_pks = {str(conflict_pk) for conflict_pk in conflict_slots}
+
+                    ## Uncomment if multi-slot AKs are implemented
+                    # other_ak_slots = self.ak_slots.filter(ak=slot.ak).exclude(pk=slot.pk).values_list('pk', flat=True)
+                    # conflict_pks.update(str(other_slot_pk) for other_slot_pk in other_ak_slots)
+
+                    self.assertEqual(
+                        conflict_pks,
+                        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 slot in self.ak_slots:
-            ak = self.export_aks[str(slot.pk)]
-            dependency_slots = self.ak_slots.filter(
-                ak__in=slot.ak.prerequisites.all()
-            ).values_list("pk", flat=True)
-
-            self.assertEqual(
-                {str(dep_pk) for dep_pk in dependency_slots},
-                set(ak["properties"]["dependencies"]),
-                f"Dependencies for slot {slot.pk} not as expected",
-            )
+        for event in Event.objects.all():
+            with self.subTest(event=event):
+                self.setUpPerEvent(event=event)
+
+                for slot in self.ak_slots:
+                    ak = self.export_aks[str(slot.pk)]
+                    dependency_slots = self.ak_slots.filter(
+                        ak__in=slot.ak.prerequisites.all()
+                    ).values_list("pk", flat=True)
+
+                    self.assertEqual(
+                        {str(dep_pk) for dep_pk in 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 slot in self.ak_slots:
-            ak = self.export_aks[str(slot.pk)]
-            self.assertEqual(slot.ak.reso, ak["info"]["reso"])
-            self.assertEqual(slot.ak.reso, "resolution" in ak["time_constraints"])
+        for event in Event.objects.all():
+            with self.subTest(event=event):
+                self.setUpPerEvent(event=event)
+
+                for slot in self.ak_slots:
+                    ak = self.export_aks[str(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 slot in self.ak_slots:
-            ak = self.export_aks[str(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)
+        for event in Event.objects.all():
+            with self.subTest(event=event):
+                self.setUpPerEvent(event=event)
+
+                for slot in self.ak_slots:
+                    ak = self.export_aks[str(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)
 
     def test_ak_room_constraints(self):
         """Test if AK room constraints are exported as expected."""
-        for slot in self.ak_slots:
-            ak = self.export_aks[str(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",
-            )
+        for event in Event.objects.all():
+            with self.subTest(event=event):
+                self.setUpPerEvent(event=event)
+
+                for slot in self.ak_slots:
+                    ak = self.export_aks[str(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 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 Availability.is_event_covered(
-                    slot.event, owner.availabilities.all()
-                ):
-                    time_constraints.add(f"availability-person-{owner.pk}")
-
-            ak = self.export_aks[str(slot.pk)]
-            self.assertEqual(
-                set(ak["time_constraints"]),
-                time_constraints,
-                f"Time constraints for slot {slot.pk} not as expected",
-            )
+        for event in Event.objects.all():
+            with self.subTest(event=event):
+                self.setUpPerEvent(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 Availability.is_event_covered(
+                            slot.event, owner.availabilities.all()
+                        ):
+                            time_constraints.add(f"availability-person-{owner.pk}")
+
+                    ak = self.export_aks[str(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."""
-        self.assertEqual(
-            {str(room.pk) for room in self.rooms},
-            self.export_rooms.keys(),
-            "Exported Rooms do not match the Rooms of the event",
-        )
+        for event in Event.objects.all():
+            with self.subTest(event=event):
+                self.setUpPerEvent(event=event)
+
+                self.assertEqual(
+                    {str(room.pk) for room in self.rooms},
+                    self.export_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 room in self.rooms:
-            export_room = self.export_rooms[str(room.pk)]
-            self.assertEqual(room.capacity, export_room["capacity"])
+        for event in Event.objects.all():
+            with self.subTest(event=event):
+                self.setUpPerEvent(event=event)
+
+                for room in self.rooms:
+                    export_room = self.export_rooms[str(room.pk)]
+                    self.assertEqual(room.capacity, export_room["capacity"])
 
     def test_room_info(self):
         """Test if contents of Room info dict is correct."""
-        for room in self.rooms:
-            export_room = self.export_rooms[str(room.pk)]
-            self.assertEqual(room.name, export_room["info"]["name"])
+        for event in Event.objects.all():
+            with self.subTest(event=event):
+                self.setUpPerEvent(event=event)
+
+                for room in self.rooms:
+                    export_room = self.export_rooms[str(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 room in self.rooms:
-            time_constraints = set()
+        for event in Event.objects.all():
+            with self.subTest(event=event):
+                self.setUpPerEvent(event=event)
+
+                for room in self.rooms:
+                    time_constraints = set()
 
-            # test if time availability of room is restricted
-            if not Availability.is_event_covered(room.event, room.availabilities.all()):
-                time_constraints.add(f"availability-room-{room.pk}")
+                    # test if time availability of room is restricted
+                    if not Availability.is_event_covered(room.event, room.availabilities.all()):
+                        time_constraints.add(f"availability-room-{room.pk}")
 
-            export_room = self.export_rooms[str(room.pk)]
-            self.assertEqual(time_constraints, set(export_room["time_constraints"]))
+                    export_room = self.export_rooms[str(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 room in self.rooms:
-            # room properties
-            fulfilled_room_constraints = set(
-                room.properties.values_list("name", flat=True)
-            )
+        for event in Event.objects.all():
+            with self.subTest(event=event):
+                self.setUpPerEvent(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")
+                    # 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}")
+                    fulfilled_room_constraints.add(f"fixed-room-{room.pk}")
 
-            export_room = self.export_rooms[str(room.pk)]
-            self.assertEqual(
-                fulfilled_room_constraints,
-                set(export_room["fulfilled_room_constraints"]),
-            )
+                    export_room = self.export_rooms[str(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(
@@ -855,33 +923,41 @@ class JSONExportTest(TestCase):
 
     def test_timeslots_consecutive(self):
         """Test if consecutive timeslots in JSON are in fact consecutive."""
-        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)
+        for event in Event.objects.all():
+            with self.subTest(event=event):
+                self.setUpPerEvent(event=event)
 
-            delta = end - start
-            self.assertAlmostEqual(
-                delta.total_seconds() / (3600), 1 / self.slots_in_an_hour
-            )
+                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
+                    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."""
-        export_cat_avails = self._get_cat_availability_in_export()
-        cat_avails = self._get_cat_availability()
-
-        for cat_name in self.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]]}",
-                )
+        for event in Event.objects.all():
+            with self.subTest(event=event):
+                self.setUpPerEvent(event=event)
+
+                export_cat_avails = self._get_cat_availability_in_export()
+                cat_avails = self._get_cat_availability()
+
+                for cat_name in self.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]
@@ -907,61 +983,65 @@ class JSONExportTest(TestCase):
 
     def test_timeslot_fulfilledconstraints(self):
         """Test if fulfilled time constraints by timeslot are as expected."""
-        cat_avails = self._get_cat_availability()
-        for timeslot in chain.from_iterable(self.export_dict["timeslots"]["blocks"]):
-            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())
-                )
-            }
+        for event in Event.objects.all():
+            with self.subTest(event=event):
+                self.setUpPerEvent(event=event)
 
-            # add ak constraints
-            fulfilled_time_constraints |= {
-                f"availability-ak-{ak.id}"
-                for ak in self.aks
-                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)
-            }
+                cat_avails = self._get_cat_availability()
+                for timeslot in chain.from_iterable(self.export_dict["timeslots"]["blocks"]):
+                    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])
+                        ]
+                    )
 
-            self.assertEqual(
-                fulfilled_time_constraints,
-                set(timeslot["fulfilled_time_constraints"]),
-            )
+                    # 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 self.aks
+                        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)
+                    }
+
+                    self.assertEqual(
+                        fulfilled_time_constraints,
+                        set(timeslot["fulfilled_time_constraints"]),
+                    )
-- 
GitLab


From 5d300d7fdbd0be1fbc8c434ce93fd65ebc91fdac Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Mon, 27 Jan 2025 17:05:59 +0100
Subject: [PATCH 33/39] Extend fixture event 1 with some AKs

---
 AKModel/fixtures/model.json | 80 +++++++++++++++++++++++++++++++++++++
 1 file changed, 80 insertions(+)

diff --git a/AKModel/fixtures/model.json b/AKModel/fixtures/model.json
index 78120ad3..42cf6c81 100644
--- a/AKModel/fixtures/model.json
+++ b/AKModel/fixtures/model.json
@@ -464,6 +464,34 @@
         "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,
@@ -488,6 +516,19 @@
         "properties": []
     }
 },
+{
+    "model": "AKModel.room",
+    "pk": 3,
+    "fields": {
+        "name": "BBB Session 1",
+        "location": "",
+        "capacity": -1,
+        "event": 1,
+        "properties": [
+            2
+        ]
+    }
+},
 {
     "model": "AKModel.akslot",
     "pk": 1,
@@ -592,6 +633,19 @@
         "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,
@@ -736,6 +790,32 @@
         "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,
-- 
GitLab


From 1e7223b9404c8fd8d054f495a948997ab51202c6 Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Mon, 27 Jan 2025 17:18:30 +0100
Subject: [PATCH 34/39] Also set start seconds to 0

---
 AKModel/tests.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/AKModel/tests.py b/AKModel/tests.py
index 320e943c..f0bb2be8 100644
--- a/AKModel/tests.py
+++ b/AKModel/tests.py
@@ -913,10 +913,10 @@ class JSONExportTest(TestCase):
         slot_seconds = 3600 / self.slots_in_an_hour
         remainder_seconds = delta % slot_seconds
         remainder_seconds += 1  # add a second to compensate rounding errs
-        seconds = timedelta(seconds=remainder_seconds)
-        end -= seconds
+        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)
         return {cat_name: [event_avail] for cat_name in self.category_names}
-- 
GitLab


From 06701cf2971977345f620e11eb4fbbccc62f0b95 Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Mon, 27 Jan 2025 17:19:38 +0100
Subject: [PATCH 35/39] Format

---
 AKModel/tests.py | 107 +++++++++++++++++++++++++++++++++--------------
 1 file changed, 75 insertions(+), 32 deletions(-)

diff --git a/AKModel/tests.py b/AKModel/tests.py
index f0bb2be8..30c00dca 100644
--- a/AKModel/tests.py
+++ b/AKModel/tests.py
@@ -397,9 +397,7 @@ class JSONExportTest(TestCase):
     def setUpPerEvent(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}
-        )
+        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")
@@ -501,10 +499,18 @@ class JSONExportTest(TestCase):
                         item=item,
                     )
 
-                    self._check_lst(ak["properties"]["conflicts"], "conflicts", item=item)
-                    self._check_lst(ak["properties"]["dependencies"], "dependencies", item=item)
-                    self._check_lst(ak["time_constraints"], "time_constraints", item=item)
-                    self._check_lst(ak["room_constraints"], "room_constraints", item=item)
+                    self._check_lst(
+                        ak["properties"]["conflicts"], "conflicts", item=item
+                    )
+                    self._check_lst(
+                        ak["properties"]["dependencies"], "dependencies", item=item
+                    )
+                    self._check_lst(
+                        ak["time_constraints"], "time_constraints", item=item
+                    )
+                    self._check_lst(
+                        ak["room_constraints"], "room_constraints", item=item
+                    )
 
     def test_room_conformity_to_spec(self):
         """Test if Room JSON structure and types conform to standard."""
@@ -527,7 +533,9 @@ class JSONExportTest(TestCase):
                         f"{item} keys not as expected",
                     )
                     self.assertEqual(
-                        room["info"].keys(), {"name"}, f"{item} info keys not as expected"
+                        room["info"].keys(),
+                        {"name"},
+                        f"{item} info keys not as expected",
                     )
 
                     self._check_type(room["id"], str, "id", item=item)
@@ -535,10 +543,13 @@ class JSONExportTest(TestCase):
                     self._check_type(room["info"]["name"], str, "info/name", item=item)
 
                     self.assertTrue(
-                        room["capacity"] > 0 or room["capacity"] == -1, "invalid room capacity"
+                        room["capacity"] > 0 or room["capacity"] == -1,
+                        "invalid room capacity",
                     )
 
-                    self._check_lst(room["time_constraints"], "time_constraints", item=item)
+                    self._check_lst(
+                        room["time_constraints"], "time_constraints", item=item
+                    )
                     self._check_lst(
                         room["fulfilled_room_constraints"],
                         "fulfilled_room_constraints",
@@ -552,7 +563,8 @@ class JSONExportTest(TestCase):
                 self.setUpPerEvent(event=event)
 
                 self._check_uniqueness(
-                    chain.from_iterable(self.export_dict["timeslots"]["blocks"]), "Timeslots"
+                    chain.from_iterable(self.export_dict["timeslots"]["blocks"]),
+                    "Timeslots",
                 )
                 item = "timeslots"
                 self.assertEqual(
@@ -579,7 +591,9 @@ class JSONExportTest(TestCase):
                 )
 
                 prev_id = None
-                for timeslot in chain.from_iterable(self.export_dict["timeslots"]["blocks"]):
+                for timeslot in chain.from_iterable(
+                    self.export_dict["timeslots"]["blocks"]
+                ):
                     item = f"timeslot {timeslot['id']}"
                     self.assertEqual(
                         timeslot.keys(),
@@ -592,7 +606,9 @@ class JSONExportTest(TestCase):
                         f"{item} info keys not as expected",
                     )
                     self._check_type(timeslot["id"], str, "id", item=item)
-                    self._check_type(timeslot["info"]["start"], str, "info/start", item=item)
+                    self._check_type(
+                        timeslot["info"]["start"], str, "info/start", item=item
+                    )
                     self._check_lst(
                         timeslot["fulfilled_time_constraints"],
                         "fulfilled_time_constraints",
@@ -601,7 +617,9 @@ class JSONExportTest(TestCase):
 
                     if prev_id is not None:
                         self.assertLess(
-                            prev_id, int(timeslot["id"]), "timeslot ids must be increasing"
+                            prev_id,
+                            int(timeslot["id"]),
+                            "timeslot ids must be increasing",
                         )
                     prev_id = int(timeslot["id"])
 
@@ -612,7 +630,9 @@ class JSONExportTest(TestCase):
                 self.setUpPerEvent(event=event)
 
                 self.assertEqual(
-                    self.export_dict["participants"], [], "Empty participant list expected"
+                    self.export_dict["participants"],
+                    [],
+                    "Empty participant list expected",
                 )
 
                 info_keys = {"title": "name", "slug": "slug"}
@@ -708,7 +728,9 @@ class JSONExportTest(TestCase):
                 for slot in self.ak_slots:
                     ak = self.export_aks[str(slot.pk)]
                     self.assertEqual(slot.ak.reso, ak["info"]["reso"])
-                    self.assertEqual(slot.ak.reso, "resolution" in ak["time_constraints"])
+                    self.assertEqual(
+                        slot.ak.reso, "resolution" in ak["time_constraints"]
+                    )
 
     def test_ak_info(self):
         """Test if contents of AK info dict is correct."""
@@ -732,7 +754,9 @@ class JSONExportTest(TestCase):
 
                 for slot in self.ak_slots:
                     ak = self.export_aks[str(slot.pk)]
-                    requirements = list(slot.ak.requirements.values_list("name", flat=True))
+                    requirements = list(
+                        slot.ak.requirements.values_list("name", flat=True)
+                    )
 
                     # proxy rooms
                     if not any(constr.startswith("proxy") for constr in requirements):
@@ -829,11 +853,15 @@ class JSONExportTest(TestCase):
                     time_constraints = set()
 
                     # test if time availability of room is restricted
-                    if not Availability.is_event_covered(room.event, room.availabilities.all()):
+                    if not Availability.is_event_covered(
+                        room.event, room.availabilities.all()
+                    ):
                         time_constraints.add(f"availability-room-{room.pk}")
 
                     export_room = self.export_rooms[str(room.pk)]
-                    self.assertEqual(time_constraints, set(export_room["time_constraints"]))
+                    self.assertEqual(
+                        time_constraints, set(export_room["time_constraints"])
+                    )
 
     def test_room_fulfilledroomconstraints(self):
         """Test if room constraints fulfilled by Room are correct."""
@@ -849,7 +877,8 @@ class JSONExportTest(TestCase):
 
                     # proxy rooms
                     if not any(
-                        constr.startswith("proxy") for constr in fulfilled_room_constraints
+                        constr.startswith("proxy")
+                        for constr in fulfilled_room_constraints
                     ):
                         fulfilled_room_constraints.add("no-proxy")
 
@@ -928,7 +957,9 @@ class JSONExportTest(TestCase):
                 self.setUpPerEvent(event=event)
 
                 prev_end = None
-                for timeslot in chain.from_iterable(self.export_dict["timeslots"]["blocks"]):
+                for timeslot in chain.from_iterable(
+                    self.export_dict["timeslots"]["blocks"]
+                ):
                     start, end = self._get_timeslot_start_end(timeslot)
                     self.assertLess(start, end)
 
@@ -988,25 +1019,35 @@ class JSONExportTest(TestCase):
                 self.setUpPerEvent(event=event)
 
                 cat_avails = self._get_cat_availability()
-                for timeslot in chain.from_iterable(self.export_dict["timeslots"]["blocks"]):
+                for timeslot in chain.from_iterable(
+                    self.export_dict["timeslots"]["blocks"]
+                ):
                     start, end = self._get_timeslot_start_end(timeslot)
-                    timeslot_avail = Availability(event=self.event, start=start, end=end)
+                    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):
+                        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])
-                        ]
+                    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
@@ -1014,7 +1055,8 @@ class JSONExportTest(TestCase):
                         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())
+                            timeslot_avail,
+                            Availability.union(owner.availabilities.all()),
                         )
                     }
 
@@ -1023,7 +1065,8 @@ class JSONExportTest(TestCase):
                         f"availability-room-{room.id}"
                         for room in self.rooms
                         if self._is_restricted_and_contained_slot(
-                            timeslot_avail, Availability.union(room.availabilities.all())
+                            timeslot_avail,
+                            Availability.union(room.availabilities.all()),
                         )
                     }
 
-- 
GitLab


From 024a3447c20b9ea473e8e4b54a285e0f68b55e02 Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Mon, 27 Jan 2025 17:48:50 +0100
Subject: [PATCH 36/39] Split tests.py into two modules

---
 AKDashboard/tests.py                      |   2 +-
 AKModel/{tests.py => test_json_export.py} | 353 +--------------------
 AKModel/test_views.py                     | 363 ++++++++++++++++++++++
 AKPlan/tests.py                           |   2 +-
 AKScheduling/tests.py                     |   2 +-
 AKSubmission/tests.py                     |   2 +-
 6 files changed, 369 insertions(+), 355 deletions(-)
 rename AKModel/{tests.py => test_json_export.py} (69%)
 create mode 100644 AKModel/test_views.py

diff --git a/AKDashboard/tests.py b/AKDashboard/tests.py
index f96af9a1..31afa5e8 100644
--- a/AKDashboard/tests.py
+++ b/AKDashboard/tests.py
@@ -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.test_views import BasicViewTests
 
 
 class DashboardTests(TestCase):
diff --git a/AKModel/tests.py b/AKModel/test_json_export.py
similarity index 69%
rename from AKModel/tests.py
rename to AKModel/test_json_export.py
index 30c00dca..1def6486 100644
--- a/AKModel/tests.py
+++ b/AKModel/test_json_export.py
@@ -1,375 +1,26 @@
 import json
 import math
-import traceback
+
 from collections import defaultdict
 from datetime import datetime, timedelta
 from itertools import chain
-from typing import List
 
 from bs4 import BeautifulSoup
 from django.contrib.auth import get_user_model
-from django.contrib.messages import get_messages
-from django.contrib.messages.storage.base import Message
 from django.test import TestCase
-from django.urls import reverse_lazy, reverse
+from django.urls import reverse
 
 from AKModel.availability.models import Availability
 from AKModel.models import (
     Event,
     AKOwner,
     AKCategory,
-    AKTrack,
-    AKRequirement,
     AK,
     Room,
     AKSlot,
-    AKOrgaMessage,
-    ConstraintViolation,
     DefaultSlot,
 )
 
-
-class BasicViewTests:
-    """
-    Parent class for "standard" tests of views
-
-    Provided with a list of views and arguments (if necessary), this will test that views
-    - render correctly without errors
-    - are only reachable with the correct rights (neither too freely nor too restricted)
-
-    To do this, the test creates sample users, fixtures are loaded automatically by the django test framework.
-    It also provides helper functions, e.g., to check for correct messages to the user or more simply generate
-    the URLs to test
-
-    In this class, methods from :class:`TestCase` will be called at multiple places event though TestCase is not a
-    parent of this class but has to be included as parent in concrete implementations of this class seperately.
-    It however still makes sense to treat this class as some kind of mixin and not implement it as a child of TestCase,
-    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 = ""
-    VIEWS_STAFF_ONLY = []
-    EDIT_TESTCASES = []
-
-    def setUp(self):  # pylint: disable=invalid-name
-        """
-        Setup testing by creating sample users
-        """
-        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,
-        )
-        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,
-        )
-        self.deactivated_user = user_model.objects.create(
-            username="Test Deactivated User",
-            email="testdeactivated@example.com",
-            password="deactivatedpw",
-            is_staff=True,
-            is_active=False,
-        )
-
-    def _name_and_url(self, view_name):
-        """
-        Get full view name (with prefix if there is one) and url from raw view definition
-
-        :param view_name: raw definition of a view
-        :type view_name: (str, dict)
-        :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]
-        )
-        url = reverse(view_name_with_prefix, kwargs=view_name[1])
-        return view_name_with_prefix, url
-
-    def _assert_message(self, response, expected_message, msg_prefix=""):
-        """
-        Assert that the correct message is shown and cause test to fail if not
-
-        :param response: response to check
-        :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))
-
-        msg_count = "No message shown to user"
-        msg_content = "Wrong message, expected '{expected_message}'"
-        if msg_prefix != "":
-            msg_count = f"{msg_prefix}: {msg_count}"
-            msg_content = f"{msg_prefix}: {msg_content}"
-
-        # Check that the last message correctly reports the issue
-        # (there might be more messages from previous calls that were not already rendered)
-        self.assertGreater(len(messages), 0, msg=msg_count)
-        self.assertEqual(messages[-1].message, expected_message, msg=msg_content)
-
-    def test_views_for_200(self):
-        """
-        Test the list of public views (as specified in "VIEWS") for error-free rendering
-        """
-        for view_name in self.VIEWS:
-            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()}"
-                )
-
-    def test_access_control_staff_only(self):
-        """
-        Test whether internal views (as specified in "VIEWS_STAFF_ONLY" are visible to staff users and staff users only
-        """
-        # 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]
-            )
-            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",
-            )
-
-        # Logged in? Views should be visible
-        self.client.force_login(self.staff_user)
-        for view_name_info in self.VIEWS_STAFF_ONLY:
-            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)",
-                )
-            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()}"
-                )
-
-        # 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]
-            )
-            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",
-            )
-
-    def _to_sendable_value(self, val):
-        """
-        Create representation sendable via POST from form data
-
-        Needed to automatically check create, update and delete views
-
-        :param val: value to prepare
-        :type val: any
-        :return: prepared value (normally either raw value or primary key of complex object)
-        """
-        if isinstance(val, list):
-            return [e.pk for e in val]
-        if type(val) == "RelatedManager":  # pylint: disable=unidiomatic-typecheck
-            return [e.pk for e in val.all()]
-        return val
-
-    def test_submit_edit_form(self):
-        """
-        Test edit forms (as specified in "EDIT_TESTCASES") in the most simple way (sending them again unchanged)
-        """
-        for testcase in self.EDIT_TESTCASES:
-            self._test_submit_edit_form(testcase)
-
-    def _test_submit_edit_form(self, testcase):
-        """
-        Test a single edit form by rendering and sending it again unchanged
-
-        This will test for correct rendering, dispatching/redirecting, messages and access control handling
-
-        :param testcase: details of the form to test
-        """
-        name, url = self._name_and_url((testcase["view"], testcase["kwargs"]))
-        form_name = testcase.get("form_name", "form")
-        expected_code = testcase.get("expected_code", 302)
-        if "target_view" in testcase.keys():
-            kwargs = testcase.get("target_kwargs", testcase["kwargs"])
-            _, target_url = self._name_and_url((testcase["target_view"], kwargs))
-        else:
-            target_url = url
-        expected_message = testcase.get("expected_message", "")
-        admin_user = testcase.get("admin", False)
-
-        if admin_user:
-            self.client.force_login(self.admin_user)
-        else:
-            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})",
-        )
-
-        form = response.context[form_name]
-        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}"
-            )
-        elif expected_code == 302:
-            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}")
-
-
-class ModelViewTests(BasicViewTests, TestCase):
-    """
-    Basic view test cases for views from AKModel plus some custom tests
-    """
-
-    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"),
-    ]
-
-    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_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,
-        },
-    ]
-
-    def test_admin(self):
-        """
-        Test basic admin functionality (displaying and interacting with model instances)
-        """
-        self.client.force_login(self.admin_user)
-        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", {}))
-            elif model[1] == "room":
-                _, 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", {}))
-            response = self.client.get(url)
-            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})
-                )
-                response = self.client.get(url)
-                self.assertEqual(
-                    response.status_code,
-                    200,
-                    msg=f"Edit form for model {model[1]} ({url}) broken",
-                )
-
-    def test_wiki_export(self):
-        """
-        Test wiki export
-        This will test whether the view renders at all and whether the export list contains the correct AKs
-        """
-        self.client.force_login(self.admin_user)
-
-        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",
-                )
-                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",
-        )
-
-
 class JSONExportTest(TestCase):
     """Test if JSON export is correct.
 
diff --git a/AKModel/test_views.py b/AKModel/test_views.py
new file mode 100644
index 00000000..63984745
--- /dev/null
+++ b/AKModel/test_views.py
@@ -0,0 +1,363 @@
+import traceback
+from typing import List
+
+from django.contrib.auth import get_user_model
+from django.contrib.messages import get_messages
+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,
+)
+
+
+class BasicViewTests:
+    """
+    Parent class for "standard" tests of views
+
+    Provided with a list of views and arguments (if necessary), this will test that views
+    - render correctly without errors
+    - are only reachable with the correct rights (neither too freely nor too restricted)
+
+    To do this, the test creates sample users, fixtures are loaded automatically by the django test framework.
+    It also provides helper functions, e.g., to check for correct messages to the user or more simply generate
+    the URLs to test
+
+    In this class, methods from :class:`TestCase` will be called at multiple places event though TestCase is not a
+    parent of this class but has to be included as parent in concrete implementations of this class seperately.
+    It however still makes sense to treat this class as some kind of mixin and not implement it as a child of TestCase,
+    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 = ""
+    VIEWS_STAFF_ONLY = []
+    EDIT_TESTCASES = []
+
+    def setUp(self):  # pylint: disable=invalid-name
+        """
+        Setup testing by creating sample users
+        """
+        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,
+        )
+        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,
+        )
+        self.deactivated_user = user_model.objects.create(
+            username="Test Deactivated User",
+            email="testdeactivated@example.com",
+            password="deactivatedpw",
+            is_staff=True,
+            is_active=False,
+        )
+
+    def _name_and_url(self, view_name):
+        """
+        Get full view name (with prefix if there is one) and url from raw view definition
+
+        :param view_name: raw definition of a view
+        :type view_name: (str, dict)
+        :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]
+        )
+        url = reverse(view_name_with_prefix, kwargs=view_name[1])
+        return view_name_with_prefix, url
+
+    def _assert_message(self, response, expected_message, msg_prefix=""):
+        """
+        Assert that the correct message is shown and cause test to fail if not
+
+        :param response: response to check
+        :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))
+
+        msg_count = "No message shown to user"
+        msg_content = "Wrong message, expected '{expected_message}'"
+        if msg_prefix != "":
+            msg_count = f"{msg_prefix}: {msg_count}"
+            msg_content = f"{msg_prefix}: {msg_content}"
+
+        # Check that the last message correctly reports the issue
+        # (there might be more messages from previous calls that were not already rendered)
+        self.assertGreater(len(messages), 0, msg=msg_count)
+        self.assertEqual(messages[-1].message, expected_message, msg=msg_content)
+
+    def test_views_for_200(self):
+        """
+        Test the list of public views (as specified in "VIEWS") for error-free rendering
+        """
+        for view_name in self.VIEWS:
+            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()}"
+                )
+
+    def test_access_control_staff_only(self):
+        """
+        Test whether internal views (as specified in "VIEWS_STAFF_ONLY" are visible to staff users and staff users only
+        """
+        # 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]
+            )
+            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",
+            )
+
+        # Logged in? Views should be visible
+        self.client.force_login(self.staff_user)
+        for view_name_info in self.VIEWS_STAFF_ONLY:
+            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)",
+                )
+            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()}"
+                )
+
+        # 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]
+            )
+            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",
+            )
+
+    def _to_sendable_value(self, val):
+        """
+        Create representation sendable via POST from form data
+
+        Needed to automatically check create, update and delete views
+
+        :param val: value to prepare
+        :type val: any
+        :return: prepared value (normally either raw value or primary key of complex object)
+        """
+        if isinstance(val, list):
+            return [e.pk for e in val]
+        if type(val) == "RelatedManager":  # pylint: disable=unidiomatic-typecheck
+            return [e.pk for e in val.all()]
+        return val
+
+    def test_submit_edit_form(self):
+        """
+        Test edit forms (as specified in "EDIT_TESTCASES") in the most simple way (sending them again unchanged)
+        """
+        for testcase in self.EDIT_TESTCASES:
+            self._test_submit_edit_form(testcase)
+
+    def _test_submit_edit_form(self, testcase):
+        """
+        Test a single edit form by rendering and sending it again unchanged
+
+        This will test for correct rendering, dispatching/redirecting, messages and access control handling
+
+        :param testcase: details of the form to test
+        """
+        name, url = self._name_and_url((testcase["view"], testcase["kwargs"]))
+        form_name = testcase.get("form_name", "form")
+        expected_code = testcase.get("expected_code", 302)
+        if "target_view" in testcase.keys():
+            kwargs = testcase.get("target_kwargs", testcase["kwargs"])
+            _, target_url = self._name_and_url((testcase["target_view"], kwargs))
+        else:
+            target_url = url
+        expected_message = testcase.get("expected_message", "")
+        admin_user = testcase.get("admin", False)
+
+        if admin_user:
+            self.client.force_login(self.admin_user)
+        else:
+            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})",
+        )
+
+        form = response.context[form_name]
+        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}"
+            )
+        elif expected_code == 302:
+            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}")
+
+
+class ModelViewTests(BasicViewTests, TestCase):
+    """
+    Basic view test cases for views from AKModel plus some custom tests
+    """
+
+    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"),
+    ]
+
+    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_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,
+        },
+    ]
+
+    def test_admin(self):
+        """
+        Test basic admin functionality (displaying and interacting with model instances)
+        """
+        self.client.force_login(self.admin_user)
+        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", {}))
+            elif model[1] == "room":
+                _, 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", {}))
+            response = self.client.get(url)
+            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})
+                )
+                response = self.client.get(url)
+                self.assertEqual(
+                    response.status_code,
+                    200,
+                    msg=f"Edit form for model {model[1]} ({url}) broken",
+                )
+
+    def test_wiki_export(self):
+        """
+        Test wiki export
+        This will test whether the view renders at all and whether the export list contains the correct AKs
+        """
+        self.client.force_login(self.admin_user)
+
+        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",
+                )
+                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",
+        )
diff --git a/AKPlan/tests.py b/AKPlan/tests.py
index 69365c2b..2ea9593a 100644
--- a/AKPlan/tests.py
+++ b/AKPlan/tests.py
@@ -1,6 +1,6 @@
 from django.test import TestCase
 
-from AKModel.tests import BasicViewTests
+from AKModel.test_views import BasicViewTests
 
 
 class PlanViewTests(BasicViewTests, TestCase):
diff --git a/AKScheduling/tests.py b/AKScheduling/tests.py
index 0996eedd..b174096d 100644
--- a/AKScheduling/tests.py
+++ b/AKScheduling/tests.py
@@ -4,7 +4,7 @@ from datetime import timedelta
 from django.test import TestCase
 from django.utils import timezone
 
-from AKModel.tests import BasicViewTests
+from AKModel.test_views import BasicViewTests
 from AKModel.models import AKSlot, Event, Room
 
 class ModelViewTests(BasicViewTests, TestCase):
diff --git a/AKSubmission/tests.py b/AKSubmission/tests.py
index 018289aa..68817b55 100644
--- a/AKSubmission/tests.py
+++ b/AKSubmission/tests.py
@@ -5,7 +5,7 @@ from django.urls import reverse_lazy
 from django.utils.datetime_safe import datetime
 
 from AKModel.models import AK, AKSlot, Event
-from AKModel.tests import BasicViewTests
+from AKModel.test_views import BasicViewTests
 
 
 class ModelViewTests(BasicViewTests, TestCase):
-- 
GitLab


From 91955818e46bb0c1ddb1179016e22e2752e1b495 Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Mon, 27 Jan 2025 18:06:40 +0100
Subject: [PATCH 37/39] Address linter findings

---
 AKModel/test_json_export.py | 107 +++++++++++++++++++++---------------
 1 file changed, 62 insertions(+), 45 deletions(-)

diff --git a/AKModel/test_json_export.py b/AKModel/test_json_export.py
index 1def6486..11864ffc 100644
--- a/AKModel/test_json_export.py
+++ b/AKModel/test_json_export.py
@@ -2,6 +2,7 @@ import json
 import math
 
 from collections import defaultdict
+from collections.abc import Iterable
 from datetime import datetime, timedelta
 from itertools import chain
 
@@ -21,6 +22,7 @@ from AKModel.models import (
     DefaultSlot,
 )
 
+
 class JSONExportTest(TestCase):
     """Test if JSON export is correct.
 
@@ -44,8 +46,19 @@ class JSONExportTest(TestCase):
 
     def setUp(self):
         self.client.force_login(self.admin_user)
+        self.export_dict = {}
+        self.export_objects = {
+            "aks": {},
+            "rooms": {},
+            "participants": {},
+        }
 
-    def setUpPerEvent(self, event: Event) -> None:
+        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})
@@ -56,20 +69,17 @@ class JSONExportTest(TestCase):
         soup = BeautifulSoup(response.content, features="lxml")
         self.export_dict = json.loads(soup.find("pre").string)
 
-        self.export_aks = {ak["id"]: ak for ak in self.export_dict["aks"]}
-        self.export_rooms = {room["id"]: room for room in self.export_dict["rooms"]}
-        self.export_participants = {
+        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).all()
         self.rooms = Room.objects.filter(event__slug=event.slug).all()
-        self.aks = AK.objects.filter(event__slug=event.slug).all()
-        self.category_names = AKCategory.objects.filter(
-            event__slug=event.slug
-        ).values_list("name", flat=True)
-
         self.slots_in_an_hour = 1 / self.export_dict["timeslots"]["info"]["duration"]
         self.event = event
 
@@ -77,10 +87,10 @@ class JSONExportTest(TestCase):
         """Test if exported AKs match AKSlots of Event."""
         for event in Event.objects.all():
             with self.subTest(event=event):
-                self.setUpPerEvent(event=event)
+                self.set_up_event(event=event)
                 self.assertEqual(
                     {str(slot.pk) for slot in self.ak_slots},
-                    self.export_aks.keys(),
+                    self.export_objects["aks"].keys(),
                     "Exported AKs does not match the AKSlots of the event",
                 )
 
@@ -107,7 +117,7 @@ class JSONExportTest(TestCase):
         """Test if AK JSON structure and types conform to standard."""
         for event in Event.objects.all():
             with self.subTest(event=event):
-                self.setUpPerEvent(event=event)
+                self.set_up_event(event=event)
 
                 self._check_uniqueness(self.export_dict["aks"], "AK")
                 for ak in self.export_dict["aks"]:
@@ -167,7 +177,7 @@ class JSONExportTest(TestCase):
         """Test if Room JSON structure and types conform to standard."""
         for event in Event.objects.all():
             with self.subTest(event=event):
-                self.setUpPerEvent(event=event)
+                self.set_up_event(event=event)
 
                 self._check_uniqueness(self.export_dict["rooms"], "Room")
                 for room in self.export_dict["rooms"]:
@@ -211,7 +221,7 @@ class JSONExportTest(TestCase):
         """Test if Timeslots JSON structure and types conform to standard."""
         for event in Event.objects.all():
             with self.subTest(event=event):
-                self.setUpPerEvent(event=event)
+                self.set_up_event(event=event)
 
                 self._check_uniqueness(
                     chain.from_iterable(self.export_dict["timeslots"]["blocks"]),
@@ -278,7 +288,7 @@ class JSONExportTest(TestCase):
         """Test if rest of JSON structure and types conform to standard."""
         for event in Event.objects.all():
             with self.subTest(event=event):
-                self.setUpPerEvent(event=event)
+                self.set_up_event(event=event)
 
                 self.assertEqual(
                     self.export_dict["participants"],
@@ -306,10 +316,10 @@ class JSONExportTest(TestCase):
         """Test if all AK durations are correct."""
         for event in Event.objects.all():
             with self.subTest(event=event):
-                self.setUpPerEvent(event=event)
+                self.set_up_event(event=event)
 
                 for slot in self.ak_slots:
-                    ak = self.export_aks[str(slot.pk)]
+                    ak = self.export_objects["aks"][str(slot.pk)]
 
                     self.assertLessEqual(
                         float(slot.duration) * self.slots_in_an_hour - 1e-4,
@@ -333,10 +343,10 @@ class JSONExportTest(TestCase):
         """Test if all AK conflicts are correct."""
         for event in Event.objects.all():
             with self.subTest(event=event):
-                self.setUpPerEvent(event=event)
+                self.set_up_event(event=event)
 
                 for slot in self.ak_slots:
-                    ak = self.export_aks[str(slot.pk)]
+                    ak = self.export_objects["aks"][str(slot.pk)]
                     conflict_slots = self.ak_slots.filter(
                         ak__in=slot.ak.conflicts.all()
                     ).values_list("pk", flat=True)
@@ -356,10 +366,10 @@ class JSONExportTest(TestCase):
         """Test if all AK dependencies are correct."""
         for event in Event.objects.all():
             with self.subTest(event=event):
-                self.setUpPerEvent(event=event)
+                self.set_up_event(event=event)
 
                 for slot in self.ak_slots:
-                    ak = self.export_aks[str(slot.pk)]
+                    ak = self.export_objects["aks"][str(slot.pk)]
                     dependency_slots = self.ak_slots.filter(
                         ak__in=slot.ak.prerequisites.all()
                     ).values_list("pk", flat=True)
@@ -374,10 +384,10 @@ class JSONExportTest(TestCase):
         """Test if resolution intent of AKs is correctly exported."""
         for event in Event.objects.all():
             with self.subTest(event=event):
-                self.setUpPerEvent(event=event)
+                self.set_up_event(event=event)
 
                 for slot in self.ak_slots:
-                    ak = self.export_aks[str(slot.pk)]
+                    ak = self.export_objects["aks"][str(slot.pk)]
                     self.assertEqual(slot.ak.reso, ak["info"]["reso"])
                     self.assertEqual(
                         slot.ak.reso, "resolution" in ak["time_constraints"]
@@ -387,10 +397,10 @@ class JSONExportTest(TestCase):
         """Test if contents of AK info dict is correct."""
         for event in Event.objects.all():
             with self.subTest(event=event):
-                self.setUpPerEvent(event=event)
+                self.set_up_event(event=event)
 
                 for slot in self.ak_slots:
-                    ak = self.export_aks[str(slot.pk)]
+                    ak = self.export_objects["aks"][str(slot.pk)]
                     self.assertEqual(ak["info"]["name"], slot.ak.name)
                     self.assertEqual(
                         ak["info"]["head"], ", ".join(map(str, slot.ak.owners.all()))
@@ -401,10 +411,10 @@ class JSONExportTest(TestCase):
         """Test if AK room constraints are exported as expected."""
         for event in Event.objects.all():
             with self.subTest(event=event):
-                self.setUpPerEvent(event=event)
+                self.set_up_event(event=event)
 
                 for slot in self.ak_slots:
-                    ak = self.export_aks[str(slot.pk)]
+                    ak = self.export_objects["aks"][str(slot.pk)]
                     requirements = list(
                         slot.ak.requirements.values_list("name", flat=True)
                     )
@@ -427,7 +437,7 @@ class JSONExportTest(TestCase):
         """Test if AK time constraints are exported as expected."""
         for event in Event.objects.all():
             with self.subTest(event=event):
-                self.setUpPerEvent(event=event)
+                self.set_up_event(event=event)
 
                 for slot in self.ak_slots:
                     time_constraints = set()
@@ -455,7 +465,7 @@ class JSONExportTest(TestCase):
                         ):
                             time_constraints.add(f"availability-person-{owner.pk}")
 
-                    ak = self.export_aks[str(slot.pk)]
+                    ak = self.export_objects["aks"][str(slot.pk)]
                     self.assertEqual(
                         set(ak["time_constraints"]),
                         time_constraints,
@@ -466,11 +476,11 @@ class JSONExportTest(TestCase):
         """Test if exported Rooms match the rooms of Event."""
         for event in Event.objects.all():
             with self.subTest(event=event):
-                self.setUpPerEvent(event=event)
+                self.set_up_event(event=event)
 
                 self.assertEqual(
                     {str(room.pk) for room in self.rooms},
-                    self.export_rooms.keys(),
+                    self.export_objects["rooms"].keys(),
                     "Exported Rooms do not match the Rooms of the event",
                 )
 
@@ -478,27 +488,27 @@ class JSONExportTest(TestCase):
         """Test if room capacity is exported correctly."""
         for event in Event.objects.all():
             with self.subTest(event=event):
-                self.setUpPerEvent(event=event)
+                self.set_up_event(event=event)
 
                 for room in self.rooms:
-                    export_room = self.export_rooms[str(room.pk)]
+                    export_room = self.export_objects["rooms"][str(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.setUpPerEvent(event=event)
+                self.set_up_event(event=event)
 
                 for room in self.rooms:
-                    export_room = self.export_rooms[str(room.pk)]
+                    export_room = self.export_objects["rooms"][str(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.setUpPerEvent(event=event)
+                self.set_up_event(event=event)
 
                 for room in self.rooms:
                     time_constraints = set()
@@ -509,7 +519,7 @@ class JSONExportTest(TestCase):
                     ):
                         time_constraints.add(f"availability-room-{room.pk}")
 
-                    export_room = self.export_rooms[str(room.pk)]
+                    export_room = self.export_objects["rooms"][str(room.pk)]
                     self.assertEqual(
                         time_constraints, set(export_room["time_constraints"])
                     )
@@ -518,7 +528,7 @@ class JSONExportTest(TestCase):
         """Test if room constraints fulfilled by Room are correct."""
         for event in Event.objects.all():
             with self.subTest(event=event):
-                self.setUpPerEvent(event=event)
+                self.set_up_event(event=event)
 
                 for room in self.rooms:
                     # room properties
@@ -535,7 +545,7 @@ class JSONExportTest(TestCase):
 
                     fulfilled_room_constraints.add(f"fixed-room-{room.pk}")
 
-                    export_room = self.export_rooms[str(room.pk)]
+                    export_room = self.export_objects["rooms"][str(room.pk)]
                     self.assertEqual(
                         fulfilled_room_constraints,
                         set(export_room["fulfilled_room_constraints"]),
@@ -599,13 +609,17 @@ class JSONExportTest(TestCase):
         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)
-        return {cat_name: [event_avail] for cat_name in self.category_names}
+
+        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.setUpPerEvent(event=event)
+                self.set_up_event(event=event)
 
                 prev_end = None
                 for timeslot in chain.from_iterable(
@@ -627,12 +641,15 @@ class JSONExportTest(TestCase):
         """Test if blocks covers all default slot resp. whole event per category."""
         for event in Event.objects.all():
             with self.subTest(event=event):
-                self.setUpPerEvent(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 self.category_names:
+                for cat_name in category_names:
                     for avail in cat_avails[cat_name]:
                         # check that all category availabilities are covered
                         self.assertTrue(
@@ -667,7 +684,7 @@ class JSONExportTest(TestCase):
         """Test if fulfilled time constraints by timeslot are as expected."""
         for event in Event.objects.all():
             with self.subTest(event=event):
-                self.setUpPerEvent(event=event)
+                self.set_up_event(event=event)
 
                 cat_avails = self._get_cat_availability()
                 for timeslot in chain.from_iterable(
@@ -724,7 +741,7 @@ class JSONExportTest(TestCase):
                     # add ak constraints
                     fulfilled_time_constraints |= {
                         f"availability-ak-{ak.id}"
-                        for ak in self.aks
+                        for ak in AK.objects.filter(event=event)
                         if self._is_restricted_and_contained_slot(
                             timeslot_avail, Availability.union(ak.availabilities.all())
                         )
-- 
GitLab


From d51dc87abb471433ebaac6c6ed3bcfd4d5f67121 Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Mon, 27 Jan 2025 20:09:10 +0100
Subject: [PATCH 38/39] Adapt unit test to changes

---
 AKModel/test_json_export.py | 27 +++++++++++++++++++++++----
 1 file changed, 23 insertions(+), 4 deletions(-)

diff --git a/AKModel/test_json_export.py b/AKModel/test_json_export.py
index 11864ffc..d53cd4c5 100644
--- a/AKModel/test_json_export.py
+++ b/AKModel/test_json_export.py
@@ -136,7 +136,14 @@ class JSONExportTest(TestCase):
                     )
                     self.assertEqual(
                         ak["info"].keys(),
-                        {"name", "head", "description", "reso", "duration_in_hours"},
+                        {
+                            "name",
+                            "head",
+                            "description",
+                            "reso",
+                            "duration_in_hours",
+                            "django_ak_id",
+                        },
                         f"{item} info keys not as expected",
                     )
                     self.assertEqual(
@@ -159,6 +166,12 @@ class JSONExportTest(TestCase):
                         "info/duration_in_hours",
                         item=item,
                     )
+                    self._check_type(
+                        ak["info"]["django_ak_id"],
+                        str,
+                        "info/django_ak_id",
+                        item=item,
+                    )
 
                     self._check_lst(
                         ak["properties"]["conflicts"], "conflicts", item=item
@@ -352,9 +365,14 @@ class JSONExportTest(TestCase):
                     ).values_list("pk", flat=True)
                     conflict_pks = {str(conflict_pk) for conflict_pk in conflict_slots}
 
-                    ## Uncomment if multi-slot AKs are implemented
-                    # other_ak_slots = self.ak_slots.filter(ak=slot.ak).exclude(pk=slot.pk).values_list('pk', flat=True)
-                    # conflict_pks.update(str(other_slot_pk) for other_slot_pk in other_ak_slots)
+                    other_ak_slots = (
+                        self.ak_slots.filter(ak=slot.ak)
+                        .exclude(pk=slot.pk)
+                        .values_list("pk", flat=True)
+                    )
+                    conflict_pks.update(
+                        str(other_slot_pk) for other_slot_pk in other_ak_slots
+                    )
 
                     self.assertEqual(
                         conflict_pks,
@@ -406,6 +424,7 @@ class JSONExportTest(TestCase):
                         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"], str(slot.ak.pk))
 
     def test_ak_room_constraints(self):
         """Test if AK room constraints are exported as expected."""
-- 
GitLab


From 7fcab117cf99d1f22ff545c9dadc5fab2b822024 Mon Sep 17 00:00:00 2001
From: Felix Blanke <info@fblanke.de>
Date: Mon, 27 Jan 2025 20:43:29 +0100
Subject: [PATCH 39/39] Move AKModel test files into one module

---
 AKDashboard/tests.py                    | 2 +-
 AKModel/tests/__init__.py               | 0
 AKModel/{ => tests}/test_json_export.py | 0
 AKModel/{ => tests}/test_views.py       | 0
 AKPlan/tests.py                         | 2 +-
 AKScheduling/tests.py                   | 2 +-
 AKSubmission/tests.py                   | 2 +-
 7 files changed, 4 insertions(+), 4 deletions(-)
 create mode 100644 AKModel/tests/__init__.py
 rename AKModel/{ => tests}/test_json_export.py (100%)
 rename AKModel/{ => tests}/test_views.py (100%)

diff --git a/AKDashboard/tests.py b/AKDashboard/tests.py
index 31afa5e8..de25ffbc 100644
--- a/AKDashboard/tests.py
+++ b/AKDashboard/tests.py
@@ -6,7 +6,7 @@ from django.utils.timezone import now
 
 from AKDashboard.models import DashboardButton
 from AKModel.models import Event, AK, AKCategory
-from AKModel.test_views import BasicViewTests
+from AKModel.tests.test_views import BasicViewTests
 
 
 class DashboardTests(TestCase):
diff --git a/AKModel/tests/__init__.py b/AKModel/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/AKModel/test_json_export.py b/AKModel/tests/test_json_export.py
similarity index 100%
rename from AKModel/test_json_export.py
rename to AKModel/tests/test_json_export.py
diff --git a/AKModel/test_views.py b/AKModel/tests/test_views.py
similarity index 100%
rename from AKModel/test_views.py
rename to AKModel/tests/test_views.py
diff --git a/AKPlan/tests.py b/AKPlan/tests.py
index 2ea9593a..3f00061a 100644
--- a/AKPlan/tests.py
+++ b/AKPlan/tests.py
@@ -1,6 +1,6 @@
 from django.test import TestCase
 
-from AKModel.test_views import BasicViewTests
+from AKModel.tests.test_views import BasicViewTests
 
 
 class PlanViewTests(BasicViewTests, TestCase):
diff --git a/AKScheduling/tests.py b/AKScheduling/tests.py
index b174096d..44f25719 100644
--- a/AKScheduling/tests.py
+++ b/AKScheduling/tests.py
@@ -4,7 +4,7 @@ from datetime import timedelta
 from django.test import TestCase
 from django.utils import timezone
 
-from AKModel.test_views import BasicViewTests
+from AKModel.tests.test_views import BasicViewTests
 from AKModel.models import AKSlot, Event, Room
 
 class ModelViewTests(BasicViewTests, TestCase):
diff --git a/AKSubmission/tests.py b/AKSubmission/tests.py
index 68817b55..423a9776 100644
--- a/AKSubmission/tests.py
+++ b/AKSubmission/tests.py
@@ -5,7 +5,7 @@ from django.urls import reverse_lazy
 from django.utils.datetime_safe import datetime
 
 from AKModel.models import AK, AKSlot, Event
-from AKModel.test_views import BasicViewTests
+from AKModel.tests.test_views import BasicViewTests
 
 
 class ModelViewTests(BasicViewTests, TestCase):
-- 
GitLab