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
1 merge request!5Refactor main
Pipeline #266930 passed
......@@ -293,7 +293,7 @@ class Availability(models.Model):
# event end + 1 day
full_event = Availability(event=event, start=event.start, end=event.end)
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:
verbose_name = _('Availability')
......
......@@ -274,7 +274,7 @@ class RoomFormWithAvailabilities(AvailabilitiesFormMixin, RoomForm):
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."""
json_data = forms.CharField(
required=True,
......
......@@ -1262,8 +1262,8 @@ msgstr ""
"{u} Slot(s) aktualisiert, {c} Slot(s) hinzugefügt und {d} Slot(s) gelöscht"
#: AKModel/views/manage.py:257
msgid "AK JSON Import"
msgstr "AK-JSON-Import"
msgid "AK Schedule JSON Import"
msgstr "AK-Plan JSON-Import"
#: AKModel/views/room.py:37
#, python-format
......
......@@ -2,7 +2,7 @@ import itertools
import json
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Iterable
from typing import Iterable, Generator
from django.db import models
from django.apps import apps
......@@ -17,11 +17,16 @@ from timezone_field import TimeZoneField
@dataclass
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"
"""The availability object corresponding to this timeslot."""
idx: int
"""The unique index of this optimizer timeslot."""
constraints: set[str]
"""The set of time constraints fulfilled by this object."""
def merge(self, other: "OptimizerTimeslot") -> "OptimizerTimeslot":
"""Merge with other OptimizerTimeslot.
......@@ -33,7 +38,6 @@ class OptimizerTimeslot:
"""
avail = self.avail.merge_with(other.avail)
constraints = self.constraints.union(other.constraints)
# we simply use the index of result[-1]
return OptimizerTimeslot(
avail=avail, idx=self.idx, constraints=constraints
)
......@@ -44,6 +48,69 @@ class 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):
"""
An event supplies the frame for all Aks.
......@@ -200,10 +267,10 @@ class Event(models.Model):
slot_duration: timedelta,
slot_index: int = 0,
constraints: set[str] | None = None,
) -> Iterable[TimeslotBlock]:
) -> Generator[TimeslotBlock, None, int]:
"""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.
if (`end` - `start`) is not a whole number multiple of `slot_duration`
then the last incomplete timeslot is dropped.
......@@ -214,7 +281,11 @@ class Event(models.Model):
:param slot_index: index of the first timeslot. Defaults to 0.
: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
# pylint: disable=import-outside-toplevel
......@@ -264,12 +335,15 @@ class Event(models.Model):
return slot_index
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.
Determines the discretization granularity.
: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(
AKCategory.objects.filter(event=self).all()
......@@ -308,68 +382,6 @@ class Event(models.Model):
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:
"""Load AK schedule from a json string.
......@@ -384,7 +396,7 @@ class Event(models.Model):
timeslot_dict = {
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
}
......@@ -925,7 +937,7 @@ class AKSlot(models.Model):
# self.slots_in_an_hour is set in AKJSONExportView
data = {
"id": str(self.pk),
"duration": int(self.duration * self.slots_in_an_hour),
"duration": round(self.duration * self.slots_in_an_hour),
"properties": {},
"room_constraints": [constraint.name
for constraint in self.ak.requirements.all()],
......
......@@ -214,7 +214,9 @@ class ModelViewTests(BasicViewTests, TestCase):
('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'}),
......
......@@ -5,7 +5,7 @@ from rest_framework.routers import DefaultRouter
import AKModel.views.api
from AKModel.views.manage import ExportSlidesView, PlanPublishView, PlanUnpublishView, DefaultSlotEditorView, \
AKsByUserView, AKJSONImportView
AKsByUserView, AKScheduleJSONImportView
from AKModel.views.ak import AKRequirementOverview, AKCSVExportView, AKJSONExportView, AKWikiExportView, \
AKMessageDeleteView
from AKModel.views.event_wizard import NewEventWizardStartView, NewEventWizardPrepareImportView, \
......@@ -99,8 +99,8 @@ def get_admin_urls_event(admin_site):
name="ak_csv_export"),
path('<slug:event_slug>/ak-json-export/', admin_site.admin_view(AKJSONExportView.as_view()),
name="ak_json_export"),
path('<slug:event_slug>/ak-json-import/', admin_site.admin_view(AKJSONImportView.as_view()),
name="ak_json_import"),
path('<slug:event_slug>/ak-schedule-json-import/', admin_site.admin_view(AKScheduleJSONImportView.as_view()),
name="ak_schedule_json_import"),
path('<slug:slug>/ak-wiki-export/', admin_site.admin_view(AKWikiExportView.as_view()),
name="ak_wiki_export"),
path('<slug:event_slug>/delete-orga-messages/', admin_site.admin_view(AKMessageDeleteView.as_view()),
......
......@@ -9,7 +9,7 @@ from django.views.generic import ListView, DetailView
from AKModel.availability.models import Availability
from AKModel.metaviews.admin import AdminViewMixin, FilterByEventSlugMixin, EventSlugMixin, IntermediateAdminView, \
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):
......@@ -52,12 +52,15 @@ class AKJSONExportView(AdminViewMixin, FilterByEventSlugMixin, ListView):
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_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)
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:
return False
......@@ -65,8 +68,9 @@ class AKJSONExportView(AdminViewMixin, FilterByEventSlugMixin, ListView):
return fixed_slot.overlaps(slot, strict=True)
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_covered(availabilities)
self._test_event_not_covered(availabilities)
and self._test_slot_contained(slot, availabilities)
)
......@@ -110,27 +114,38 @@ class AKJSONExportView(AdminViewMixin, FilterByEventSlugMixin, ListView):
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 = []
for timeslot in block:
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:
time_constraints.append("resolution")
# add fulfilled time constraints for all AKs that cannot happen during full event
time_constraints.extend([
f"availability-ak-{ak_id}"
for ak_id, availabilities in ak_availabilities.items()
if (
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([
f"availability-person-{person_id}"
for person_id, availabilities in person_availabilities.items()
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([
f"availability-room-{room_id}"
for room_id, availabilities in room_availabilities.items()
......
......@@ -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.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.models import ConstraintViolation, Event, DefaultSlot, AKOwner
......@@ -249,12 +249,12 @@ class AKsByUserView(AdminViewMixin, EventSlugMixin, DetailView):
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.
"""
form_class = JSONImportForm
title = _("AK JSON Import")
form_class = JSONScheduleImportForm
title = _("AK Schedule JSON Import")
def form_valid(self, form):
self.event.schedule_from_json(form.data["json_data"])
......
......@@ -135,7 +135,7 @@ class EventAKsWidget(TemplateStatusWidget):
},
{
"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"),
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please to comment