Skip to content
Snippets Groups Projects
Commit aee5627c authored by Lorenzo Conti's avatar Lorenzo Conti
Browse files

Merge branch 'refactor-main' into 'main'

Refactor main

See merge request !5
parents c7b910c2 1ae6e916
No related branches found
No related tags found
5 merge requests!262[WIP] compatibility with koma solver import/export,!261[WIP] compatibility with koma solver import/export,!260[WIP] import/export merge,!237Draft: add tests on json export,!235Merge fork for interoperability of KoMa solver
This commit is part of merge request !235. Comments created here will be created in the context of that merge request.
...@@ -293,7 +293,7 @@ class Availability(models.Model): ...@@ -293,7 +293,7 @@ class Availability(models.Model):
# event end + 1 day # event end + 1 day
full_event = Availability(event=event, start=event.start, end=event.end) full_event = Availability(event=event, start=event.start, end=event.end)
avail_union = Availability.union(availabilities) avail_union = Availability.union(availabilities)
return not avail_union or avail_union[0].contains(full_event) return any(avail.contains(full_event) for avail in avail_union)
class Meta: class Meta:
verbose_name = _('Availability') verbose_name = _('Availability')
......
...@@ -274,7 +274,7 @@ class RoomFormWithAvailabilities(AvailabilitiesFormMixin, RoomForm): ...@@ -274,7 +274,7 @@ class RoomFormWithAvailabilities(AvailabilitiesFormMixin, RoomForm):
self.fields["properties"].queryset = AKRequirement.objects.filter(event=self.instance.event) self.fields["properties"].queryset = AKRequirement.objects.filter(event=self.instance.event)
class JSONImportForm(AdminIntermediateForm): class JSONScheduleImportForm(AdminIntermediateForm):
"""Form to import an AK schedule from a json file.""" """Form to import an AK schedule from a json file."""
json_data = forms.CharField( json_data = forms.CharField(
required=True, required=True,
......
...@@ -1262,8 +1262,8 @@ msgstr "" ...@@ -1262,8 +1262,8 @@ msgstr ""
"{u} Slot(s) aktualisiert, {c} Slot(s) hinzugefügt und {d} Slot(s) gelöscht" "{u} Slot(s) aktualisiert, {c} Slot(s) hinzugefügt und {d} Slot(s) gelöscht"
#: AKModel/views/manage.py:257 #: AKModel/views/manage.py:257
msgid "AK JSON Import" msgid "AK Schedule JSON Import"
msgstr "AK-JSON-Import" msgstr "AK-Plan JSON-Import"
#: AKModel/views/room.py:37 #: AKModel/views/room.py:37
#, python-format #, python-format
......
...@@ -2,7 +2,7 @@ import itertools ...@@ -2,7 +2,7 @@ import itertools
import json import json
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Iterable from typing import Iterable, Generator
from django.db import models from django.db import models
from django.apps import apps from django.apps import apps
...@@ -17,11 +17,16 @@ from timezone_field import TimeZoneField ...@@ -17,11 +17,16 @@ from timezone_field import TimeZoneField
@dataclass @dataclass
class OptimizerTimeslot: class OptimizerTimeslot:
"""Class describing a timeslot. Used to interface with an optimizer.""" """Class describing a discrete timeslot. Used to interface with an optimizer."""
avail: "Availability" avail: "Availability"
"""The availability object corresponding to this timeslot."""
idx: int idx: int
"""The unique index of this optimizer timeslot."""
constraints: set[str] constraints: set[str]
"""The set of time constraints fulfilled by this object."""
def merge(self, other: "OptimizerTimeslot") -> "OptimizerTimeslot": def merge(self, other: "OptimizerTimeslot") -> "OptimizerTimeslot":
"""Merge with other OptimizerTimeslot. """Merge with other OptimizerTimeslot.
...@@ -33,7 +38,6 @@ class OptimizerTimeslot: ...@@ -33,7 +38,6 @@ class OptimizerTimeslot:
""" """
avail = self.avail.merge_with(other.avail) avail = self.avail.merge_with(other.avail)
constraints = self.constraints.union(other.constraints) constraints = self.constraints.union(other.constraints)
# we simply use the index of result[-1]
return OptimizerTimeslot( return OptimizerTimeslot(
avail=avail, idx=self.idx, constraints=constraints avail=avail, idx=self.idx, constraints=constraints
) )
...@@ -44,6 +48,69 @@ class OptimizerTimeslot: ...@@ -44,6 +48,69 @@ class OptimizerTimeslot:
TimeslotBlock = list[OptimizerTimeslot] TimeslotBlock = list[OptimizerTimeslot]
def merge_blocks(
blocks: Iterable[TimeslotBlock]
) -> Iterable[TimeslotBlock]:
"""Merge iterable of blocks together.
The timeslots of all blocks are grouped into maximal blocks.
Timeslots with the same start and end are identified with each other
and merged (cf `OptimizerTimeslot.merge`).
Throws a ValueError if any timeslots are overlapping but do not
share the same start and end, i.e. partial overlap is not allowed.
:param blocks: iterable of blocks to merge.
:return: iterable of merged blocks.
:rtype: iterable over lists of OptimizerTimeslot objects
"""
if not blocks:
return []
# flatten timeslot iterables to single chain
timeslot_chain = itertools.chain.from_iterable(blocks)
# sort timeslots according to start
timeslots = sorted(
timeslot_chain,
key=lambda slot: slot.avail.start
)
if not timeslots:
return []
all_blocks = []
current_block = [timeslots[0]]
timeslots = timeslots[1:]
for slot in timeslots:
if current_block and slot.avail.overlaps(current_block[-1].avail, strict=True):
if (
slot.avail.start == current_block[-1].avail.start
and slot.avail.end == current_block[-1].avail.end
):
# the same timeslot -> merge
current_block[-1] = current_block[-1].merge(slot)
else:
# partial overlap of interiors -> not supported
# TODO: Show comprehensive message in production
raise ValueError(
"Partially overlapping timeslots are not supported!"
f" ({current_block[-1].avail.simplified}, {slot.avail.simplified})"
)
elif not current_block or slot.avail.overlaps(current_block[-1].avail, strict=False):
# only endpoints in intersection -> same block
current_block.append(slot)
else:
# no overlap at all -> new block
all_blocks.append(current_block)
current_block = [slot]
if current_block:
all_blocks.append(current_block)
return all_blocks
class Event(models.Model): class Event(models.Model):
""" """
An event supplies the frame for all Aks. An event supplies the frame for all Aks.
...@@ -200,10 +267,10 @@ class Event(models.Model): ...@@ -200,10 +267,10 @@ class Event(models.Model):
slot_duration: timedelta, slot_duration: timedelta,
slot_index: int = 0, slot_index: int = 0,
constraints: set[str] | None = None, constraints: set[str] | None = None,
) -> Iterable[TimeslotBlock]: ) -> Generator[TimeslotBlock, None, int]:
"""Discretize a time range into timeslots. """Discretize a time range into timeslots.
Uses a uniform discretization into blocks of length `slot_duration`, Uses a uniform discretization into discrete slots of length `slot_duration`,
starting at `start`. No incomplete timeslots are generated, i.e. starting at `start`. No incomplete timeslots are generated, i.e.
if (`end` - `start`) is not a whole number multiple of `slot_duration` if (`end` - `start`) is not a whole number multiple of `slot_duration`
then the last incomplete timeslot is dropped. then the last incomplete timeslot is dropped.
...@@ -214,7 +281,11 @@ class Event(models.Model): ...@@ -214,7 +281,11 @@ class Event(models.Model):
:param slot_index: index of the first timeslot. Defaults to 0. :param slot_index: index of the first timeslot. Defaults to 0.
:yield: Block of optimizer timeslots as the discretization result. :yield: Block of optimizer timeslots as the discretization result.
:ytype: list of TimeslotBlock :ytype: list of OptimizerTimeslot
:return: The first slot index after the yielded blocks, i.e.
`slot_index` + total # generated timeslots
:rtype: int
""" """
# local import to prevent cyclic import # local import to prevent cyclic import
# pylint: disable=import-outside-toplevel # pylint: disable=import-outside-toplevel
...@@ -264,12 +335,15 @@ class Event(models.Model): ...@@ -264,12 +335,15 @@ class Event(models.Model):
return slot_index return slot_index
def uniform_time_slots(self, *, slots_in_an_hour: float = 1.0) -> Iterable[TimeslotBlock]: def uniform_time_slots(self, *, slots_in_an_hour: float = 1.0) -> Iterable[TimeslotBlock]:
"""Uniformly discretize the entire event into a single block of timeslots. """Uniformly discretize the entire event into blocks of timeslots.
Discretizes entire event uniformly. May not necessarily result in a single block
as slots with no room availability are dropped.
:param slots_in_an_hour: The percentage of an hour covered by a single slot. :param slots_in_an_hour: The percentage of an hour covered by a single slot.
Determines the discretization granularity. Determines the discretization granularity.
:yield: Block of optimizer timeslots as the discretization result. :yield: Block of optimizer timeslots as the discretization result.
:ytype: a single list of TimeslotBlock :ytype: list of OptimizerTimeslot
""" """
all_category_constraints = AKCategory.create_category_constraints( all_category_constraints = AKCategory.create_category_constraints(
AKCategory.objects.filter(event=self).all() AKCategory.objects.filter(event=self).all()
...@@ -308,68 +382,6 @@ class Event(models.Model): ...@@ -308,68 +382,6 @@ class Event(models.Model):
constraints=category_constraints, constraints=category_constraints,
) )
def merge_blocks(
self, blocks: Iterable[TimeslotBlock]
) -> Iterable[TimeslotBlock]:
"""Merge iterable of blocks together.
The timeslots of all blocks are grouped into maximal blocks.
Timeslots with the same start and end are identified with each other
and merged (cf `OptimizerTimeslot.merge`).
Throws a ValueError if any timeslots are overlapping but do not
share the same start and end, i.e. partial overlap is not allowed.
:param blocks: iterable of blocks to merge.
:return: iterable of merged blocks.
:rtype: iterable over lists of OptimizerTimeslot objects
"""
if not blocks:
return []
# flatten timeslot iterables to single chain
timeslot_chain = itertools.chain.from_iterable(blocks)
# sort timeslots according to start
timeslots = sorted(
timeslot_chain,
key=lambda slot: slot.avail.start
)
if not timeslots:
return []
all_blocks = []
current_block = [timeslots[0]]
timeslots = timeslots[1:]
for slot in timeslots:
if current_block and slot.avail.overlaps(current_block[-1].avail, strict=True):
if (
slot.avail.start == current_block[-1].avail.start
and slot.avail.end == current_block[-1].avail.end
):
# the same timeslot -> merge
current_block[-1] = current_block[-1].merge(slot)
else:
# partial overlap of interiors -> not supported
# TODO: Show comprehensive message in production
raise ValueError(
"Partially overlapping timeslots are not supported!"
f" ({current_block[-1].avail.simplified}, {slot.avail.simplified})"
)
elif not current_block or slot.avail.overlaps(current_block[-1].avail, strict=False):
# only endpoints in intersection -> same block
current_block.append(slot)
else:
# no overlap at all -> new block
all_blocks.append(current_block)
current_block = [slot]
if current_block:
all_blocks.append(current_block)
return all_blocks
def schedule_from_json(self, schedule: str) -> None: def schedule_from_json(self, schedule: str) -> None:
"""Load AK schedule from a json string. """Load AK schedule from a json string.
...@@ -384,7 +396,7 @@ class Event(models.Model): ...@@ -384,7 +396,7 @@ class Event(models.Model):
timeslot_dict = { timeslot_dict = {
timeslot.idx: timeslot timeslot.idx: timeslot
for block in self.merge_blocks(self.default_time_slots(slots_in_an_hour=slots_in_an_hour)) for block in merge_blocks(self.default_time_slots(slots_in_an_hour=slots_in_an_hour))
for timeslot in block for timeslot in block
} }
...@@ -925,7 +937,7 @@ class AKSlot(models.Model): ...@@ -925,7 +937,7 @@ class AKSlot(models.Model):
# self.slots_in_an_hour is set in AKJSONExportView # self.slots_in_an_hour is set in AKJSONExportView
data = { data = {
"id": str(self.pk), "id": str(self.pk),
"duration": int(self.duration * self.slots_in_an_hour), "duration": round(self.duration * self.slots_in_an_hour),
"properties": {}, "properties": {},
"room_constraints": [constraint.name "room_constraints": [constraint.name
for constraint in self.ak.requirements.all()], for constraint in self.ak.requirements.all()],
......
...@@ -214,7 +214,9 @@ class ModelViewTests(BasicViewTests, TestCase): ...@@ -214,7 +214,9 @@ class ModelViewTests(BasicViewTests, TestCase):
('admin:event_status', {'event_slug': 'kif42'}), ('admin:event_status', {'event_slug': 'kif42'}),
('admin:event_requirement_overview', {'event_slug': 'kif42'}), ('admin:event_requirement_overview', {'event_slug': 'kif42'}),
('admin:ak_csv_export', {'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_wiki_export', {'slug': 'kif42'}),
('admin:ak_schedule_json_import', {'event_slug': 'kif42'}),
('admin:ak_delete_orga_messages', {'event_slug': 'kif42'}), ('admin:ak_delete_orga_messages', {'event_slug': 'kif42'}),
('admin:ak_slide_export', {'event_slug': 'kif42'}), ('admin:ak_slide_export', {'event_slug': 'kif42'}),
('admin:default-slots-editor', {'event_slug': 'kif42'}), ('admin:default-slots-editor', {'event_slug': 'kif42'}),
......
...@@ -5,7 +5,7 @@ from rest_framework.routers import DefaultRouter ...@@ -5,7 +5,7 @@ from rest_framework.routers import DefaultRouter
import AKModel.views.api import AKModel.views.api
from AKModel.views.manage import ExportSlidesView, PlanPublishView, PlanUnpublishView, DefaultSlotEditorView, \ from AKModel.views.manage import ExportSlidesView, PlanPublishView, PlanUnpublishView, DefaultSlotEditorView, \
AKsByUserView, AKJSONImportView AKsByUserView, AKScheduleJSONImportView
from AKModel.views.ak import AKRequirementOverview, AKCSVExportView, AKJSONExportView, AKWikiExportView, \ from AKModel.views.ak import AKRequirementOverview, AKCSVExportView, AKJSONExportView, AKWikiExportView, \
AKMessageDeleteView AKMessageDeleteView
from AKModel.views.event_wizard import NewEventWizardStartView, NewEventWizardPrepareImportView, \ from AKModel.views.event_wizard import NewEventWizardStartView, NewEventWizardPrepareImportView, \
...@@ -99,8 +99,8 @@ def get_admin_urls_event(admin_site): ...@@ -99,8 +99,8 @@ def get_admin_urls_event(admin_site):
name="ak_csv_export"), name="ak_csv_export"),
path('<slug:event_slug>/ak-json-export/', admin_site.admin_view(AKJSONExportView.as_view()), path('<slug:event_slug>/ak-json-export/', admin_site.admin_view(AKJSONExportView.as_view()),
name="ak_json_export"), name="ak_json_export"),
path('<slug:event_slug>/ak-json-import/', admin_site.admin_view(AKJSONImportView.as_view()), path('<slug:event_slug>/ak-schedule-json-import/', admin_site.admin_view(AKScheduleJSONImportView.as_view()),
name="ak_json_import"), name="ak_schedule_json_import"),
path('<slug:slug>/ak-wiki-export/', admin_site.admin_view(AKWikiExportView.as_view()), path('<slug:slug>/ak-wiki-export/', admin_site.admin_view(AKWikiExportView.as_view()),
name="ak_wiki_export"), name="ak_wiki_export"),
path('<slug:event_slug>/delete-orga-messages/', admin_site.admin_view(AKMessageDeleteView.as_view()), path('<slug:event_slug>/delete-orga-messages/', admin_site.admin_view(AKMessageDeleteView.as_view()),
......
...@@ -9,7 +9,7 @@ from django.views.generic import ListView, DetailView ...@@ -9,7 +9,7 @@ from django.views.generic import ListView, DetailView
from AKModel.availability.models import Availability from AKModel.availability.models import Availability
from AKModel.metaviews.admin import AdminViewMixin, FilterByEventSlugMixin, EventSlugMixin, IntermediateAdminView, \ from AKModel.metaviews.admin import AdminViewMixin, FilterByEventSlugMixin, EventSlugMixin, IntermediateAdminView, \
IntermediateAdminActionView IntermediateAdminActionView
from AKModel.models import AKRequirement, AKSlot, Event, AKOrgaMessage, AK, Room, AKOwner from AKModel.models import AKRequirement, AKSlot, DefaultSlot, Event, AKOrgaMessage, AK, Room, AKOwner, merge_blocks
class AKRequirementOverview(AdminViewMixin, FilterByEventSlugMixin, ListView): class AKRequirementOverview(AdminViewMixin, FilterByEventSlugMixin, ListView):
...@@ -52,12 +52,15 @@ class AKJSONExportView(AdminViewMixin, FilterByEventSlugMixin, ListView): ...@@ -52,12 +52,15 @@ class AKJSONExportView(AdminViewMixin, FilterByEventSlugMixin, ListView):
def _test_slot_contained(self, slot: Availability, availabilities: List[Availability]) -> bool: 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) return any(availability.contains(slot) for availability in availabilities)
def _test_event_covered(self, availabilities: List[Availability]) -> bool: 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) return not Availability.is_event_covered(self.event, availabilities)
def _test_fixed_ak(self, ak_id, slot: Availability, ak_fixed: dict) -> bool: def _test_ak_fixed_in_slot(self, ak_id, slot: Availability, ak_fixed: dict) -> bool:
"""Test if AK defined by `ak_id` is fixed to happen during slot."""
if not ak_id in ak_fixed: if not ak_id in ak_fixed:
return False return False
...@@ -65,8 +68,9 @@ class AKJSONExportView(AdminViewMixin, FilterByEventSlugMixin, ListView): ...@@ -65,8 +68,9 @@ class AKJSONExportView(AdminViewMixin, FilterByEventSlugMixin, ListView):
return fixed_slot.overlaps(slot, strict=True) return fixed_slot.overlaps(slot, strict=True)
def _test_add_constraint(self, slot: Availability, availabilities: List[Availability]) -> bool: 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 ( return (
self._test_event_covered(availabilities) self._test_event_not_covered(availabilities)
and self._test_slot_contained(slot, availabilities) and self._test_slot_contained(slot, availabilities)
) )
...@@ -110,27 +114,38 @@ class AKJSONExportView(AdminViewMixin, FilterByEventSlugMixin, ListView): ...@@ -110,27 +114,38 @@ class AKJSONExportView(AdminViewMixin, FilterByEventSlugMixin, ListView):
if (values := AKSlot.objects.select_related().filter(ak__pk=ak_id, fixed=True)).exists() if (values := AKSlot.objects.select_related().filter(ak__pk=ak_id, fixed=True)).exists()
} }
for block in self.event.merge_blocks(self.event.default_time_slots(slots_in_an_hour=SLOTS_IN_AN_HOUR)): if DefaultSlot.objects.filter(event=self.event).exists():
# discretize default slots if they exists
blocks = merge_blocks(self.event.default_time_slots(slots_in_an_hour=SLOTS_IN_AN_HOUR))
else:
blocks = self.event.uniform_time_slots(slots_in_an_hour=SLOTS_IN_AN_HOUR)
for block in blocks:
current_block = [] current_block = []
for timeslot in block: for timeslot in block:
time_constraints = [] time_constraints = []
# if reso_deadline is set and timeslot ends before it,
# add fulfilled time constraint 'resolution'
if self.event.reso_deadline is None or timeslot.avail.end < self.event.reso_deadline: if self.event.reso_deadline is None or timeslot.avail.end < self.event.reso_deadline:
time_constraints.append("resolution") time_constraints.append("resolution")
# add fulfilled time constraints for all AKs that cannot happen during full event
time_constraints.extend([ time_constraints.extend([
f"availability-ak-{ak_id}" f"availability-ak-{ak_id}"
for ak_id, availabilities in ak_availabilities.items() for ak_id, availabilities in ak_availabilities.items()
if ( if (
self._test_add_constraint(timeslot.avail, availabilities) self._test_add_constraint(timeslot.avail, availabilities)
or self._test_fixed_ak(ak_id, timeslot.avail, ak_fixed) or self._test_ak_fixed_in_slot(ak_id, timeslot.avail, ak_fixed)
) )
]) ])
# add fulfilled time constraints for all persons that are not available for full event
time_constraints.extend([ time_constraints.extend([
f"availability-person-{person_id}" f"availability-person-{person_id}"
for person_id, availabilities in person_availabilities.items() for person_id, availabilities in person_availabilities.items()
if self._test_add_constraint(timeslot.avail, availabilities) if self._test_add_constraint(timeslot.avail, availabilities)
]) ])
# add fulfilled time constraints for all rooms that are not available for full event
time_constraints.extend([ time_constraints.extend([
f"availability-room-{room_id}" f"availability-room-{room_id}"
for room_id, availabilities in room_availabilities.items() for room_id, availabilities in room_availabilities.items()
......
...@@ -14,7 +14,7 @@ from django.views.generic import TemplateView, DetailView ...@@ -14,7 +14,7 @@ from django.views.generic import TemplateView, DetailView
from django_tex.core import render_template_with_context, run_tex_in_directory from django_tex.core import render_template_with_context, run_tex_in_directory
from django_tex.response import PDFResponse from django_tex.response import PDFResponse
from AKModel.forms import SlideExportForm, DefaultSlotEditorForm, JSONImportForm from AKModel.forms import SlideExportForm, DefaultSlotEditorForm, JSONScheduleImportForm
from AKModel.metaviews.admin import EventSlugMixin, IntermediateAdminView, IntermediateAdminActionView, AdminViewMixin from AKModel.metaviews.admin import EventSlugMixin, IntermediateAdminView, IntermediateAdminActionView, AdminViewMixin
from AKModel.models import ConstraintViolation, Event, DefaultSlot, AKOwner from AKModel.models import ConstraintViolation, Event, DefaultSlot, AKOwner
...@@ -249,12 +249,12 @@ class AKsByUserView(AdminViewMixin, EventSlugMixin, DetailView): ...@@ -249,12 +249,12 @@ class AKsByUserView(AdminViewMixin, EventSlugMixin, DetailView):
template_name = "admin/AKModel/aks_by_user.html" template_name = "admin/AKModel/aks_by_user.html"
class AKJSONImportView(EventSlugMixin, IntermediateAdminView): class AKScheduleJSONImportView(EventSlugMixin, IntermediateAdminView):
""" """
View: Import an AK schedule from a json file that can be pasted into this view. View: Import an AK schedule from a json file that can be pasted into this view.
""" """
form_class = JSONImportForm form_class = JSONScheduleImportForm
title = _("AK JSON Import") title = _("AK Schedule JSON Import")
def form_valid(self, form): def form_valid(self, form):
self.event.schedule_from_json(form.data["json_data"]) self.event.schedule_from_json(form.data["json_data"])
......
...@@ -135,7 +135,7 @@ class EventAKsWidget(TemplateStatusWidget): ...@@ -135,7 +135,7 @@ class EventAKsWidget(TemplateStatusWidget):
}, },
{ {
"text": _("Import AK schedule from JSON"), "text": _("Import AK schedule from JSON"),
"url": reverse_lazy("admin:ak_json_import", kwargs={"event_slug": context["event"].slug}), "url": reverse_lazy("admin:ak_schedule_json_import", kwargs={"event_slug": context["event"].slug}),
}, },
{ {
"text": _("Export AKs as CSV"), "text": _("Export AKs as CSV"),
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment