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