diff --git a/AKModel/admin.py b/AKModel/admin.py index a90b3ff2686172b36f5c92a3000779decfa00263..cd3c6cd5082739f1e1ae38836ebd1929c5b2181c 100644 --- a/AKModel/admin.py +++ b/AKModel/admin.py @@ -2,6 +2,7 @@ from django.contrib import admin +from AKModel.availability import Availability from AKModel.models import Event, AKOwner, AKType, AKTrack, AKTag, AKRequirement, AK, Room, AKSlot admin.site.register(Event) @@ -17,3 +18,5 @@ admin.site.register(AK) admin.site.register(Room) admin.site.register(AKSlot) + +admin.site.register(Availability) diff --git a/AKModel/availability.py b/AKModel/availability.py new file mode 100644 index 0000000000000000000000000000000000000000..7afa2fb9938f73d05bca36380dad695e127fc4bf --- /dev/null +++ b/AKModel/availability.py @@ -0,0 +1,236 @@ +# This part of the code was adapted from pretalx (https://github.com/pretalx/pretalx) +# Copyright 2017-2019, Tobias Kunze +# Original Copyrights licensed under the Apache License, Version 2.0 http://www.apache.org/licenses/LICENSE-2.0 +# Changes are marked in the code + +import datetime +from typing import List + +from django.db import models +from django.utils.functional import cached_property +from django.utils.translation import gettext_lazy as _ + +from AKModel.models import Event, AKOwner, Room, AK, AKType + +zero_time = datetime.time(0, 0) + + +# CHANGES: +# ScopeManager and LogMixin removed as they are not used in this project +# adapted to event, people and room models +# remove serialization as requirements are not covered +# add translation +# add meta class +# enable availabilites for AKs and AKTypes +# add verbose names and help texts to model attributes +class Availability(models.Model): + """The Availability class models when people or rooms are available for. + + The power of this class is not within its rather simple data model, + but with the operations available on it. An availability object can + span multiple days, but due to our choice of input widget, it will + usually only span a single day at most. + """ + event = models.ForeignKey( + to=Event, + related_name='availabilities', + on_delete=models.CASCADE, + verbose_name=_('Event'), + help_text=_('Associated event'), + ) + person = models.ForeignKey( + to=AKOwner, + related_name='availabilities', + on_delete=models.CASCADE, + null=True, + blank=True, + verbose_name=_('Person'), + help_text=_('Person whose availability this is'), + ) + room = models.ForeignKey( + to=Room, + related_name='availabilities', + on_delete=models.CASCADE, + null=True, + blank=True, + verbose_name=_('Room'), + help_text=_('Room whose availability this is'), + ) + ak = models.ForeignKey( + to=AK, + related_name='availabilities', + on_delete=models.CASCADE, + null=True, + blank=True, + verbose_name=_('AK'), + help_text=_('AK whose availability this is'), + ) + ak_type = models.ForeignKey( + to=AKType, + related_name='availabilities', + on_delete=models.CASCADE, + null=True, + blank=True, + verbose_name=_('AK Type'), + help_text=_('AK Type whose availability this is'), + ) + start = models.DateTimeField() + end = models.DateTimeField() + + def __str__(self) -> str: + person = self.person.name if self.person else None + room = getattr(self.room, 'name', None) + event = getattr(getattr(self, 'event', None), 'name', None) + ak = getattr(self.ak, 'name', None) + ak_type = getattr(self.ak_type, 'name', None) + return f'Availability(event={event}, person={person}, room={room}, ak={ak}, ak type={ak_type})' + + def __hash__(self): + return hash((getattr(self, 'event', None), self.person, self.room, self.ak, self.ak_type, self.start, self.end)) + + def __eq__(self, other: 'Availability') -> bool: + """Comparisons like ``availability1 == availability2``. + + Checks if ``event``, ``person``, ``room``, ``ak``, ``ak_type``, ``start`` and ``end`` + are the same. + """ + return all( + [ + getattr(self, attribute, None) == getattr(other, attribute, None) + for attribute in ['event', 'person', 'room', 'ak', 'ak_type', 'start', 'end'] + ] + ) + + @cached_property + def all_day(self) -> bool: + """Checks if the Availability spans one (or, technically: multiple) + complete day.""" + return self.start.time() == zero_time and self.end.time() == zero_time + + def overlaps(self, other: 'Availability', strict: bool) -> bool: + """Test if two Availabilities overlap. + + :param other: + :param strict: Only count a real overlap as overlap, not direct adjacency. + """ + + if not isinstance(other, Availability): + raise Exception('Please provide an Availability object') + + if strict: + return ( + (self.start <= other.start < self.end) + or (self.start < other.end <= self.end) + or (other.start <= self.start < other.end) + or (other.start < self.end <= other.end) + ) + return ( + (self.start <= other.start <= self.end) + or (self.start <= other.end <= self.end) + or (other.start <= self.start <= other.end) + or (other.start <= self.end <= other.end) + ) + + def contains(self, other: 'Availability') -> bool: + """Tests if this availability starts before and ends after the + other.""" + return self.start <= other.start and self.end >= other.end + + def merge_with(self, other: 'Availability') -> 'Availability': + """Return a new Availability which spans the range of this one and the + given one.""" + + if not isinstance(other, Availability): + raise Exception('Please provide an Availability object.') + if not other.overlaps(self, strict=False): + raise Exception('Only overlapping Availabilities can be merged.') + + return Availability( + start=min(self.start, other.start), end=max(self.end, other.end) + ) + + def __or__(self, other: 'Availability') -> 'Availability': + """Performs the merge operation: ``availability1 | availability2``""" + return self.merge_with(other) + + def intersect_with(self, other: 'Availability') -> 'Availability': + """Return a new Availability which spans the range covered both by this + one and the given one.""" + + if not isinstance(other, Availability): + raise Exception('Please provide an Availability object.') + if not other.overlaps(self, False): + raise Exception('Only overlapping Availabilities can be intersected.') + + return Availability( + start=max(self.start, other.start), end=min(self.end, other.end) + ) + + def __and__(self, other: 'Availability') -> 'Availability': + """Performs the intersect operation: ``availability1 & + availability2``""" + return self.intersect_with(other) + + @classmethod + def union(cls, availabilities: List['Availability']) -> List['Availability']: + """Return the minimal list of Availability objects which are covered by + at least one given Availability.""" + if not availabilities: + return [] + + availabilities = sorted(availabilities, key=lambda a: a.start) + result = [availabilities[0]] + availabilities = availabilities[1:] + + for avail in availabilities: + if avail.overlaps(result[-1], False): + result[-1] = result[-1].merge_with(avail) + else: + result.append(avail) + + return result + + @classmethod + def _pair_intersection( + cls, + availabilities_a: List['Availability'], + availabilities_b: List['Availability'], + ) -> List['Availability']: + """return the list of Availabilities, which are covered by each of the + given sets.""" + result = [] + + # yay for O(b*a) time! I am sure there is some fancy trick to make this faster, + # but we're dealing with less than 100 items in total, sooo.. ¯\_(ツ)_/¯ + for a in availabilities_a: + for b in availabilities_b: + if a.overlaps(b, True): + result.append(a.intersect_with(b)) + + return result + + @classmethod + def intersection( + cls, *availabilitysets: List['Availability'] + ) -> List['Availability']: + """Return the list of Availabilities which are covered by all of the + given sets.""" + + # get rid of any overlaps and unmerged ranges in each set + availabilitysets = [cls.union(avialset) for avialset in availabilitysets] + # bail out for obvious cases (there are no sets given, one of the sets is empty) + if not availabilitysets: + return [] + if not all(availabilitysets): + return [] + # start with the very first set ... + result = availabilitysets[0] + for availset in availabilitysets[1:]: + # ... subtract each of the other sets + result = cls._pair_intersection(result, availset) + return result + + class Meta: + verbose_name = _('Availability') + verbose_name_plural = _('Availabilities') + ordering = ['event', 'start'] diff --git a/AKModel/locale/de_DE/LC_MESSAGES/django.po b/AKModel/locale/de_DE/LC_MESSAGES/django.po index 0f0eab2d64820f4b0bfcff6e906837a498839d2f..700d82e36966af0893c5dae80d50f98778bbc9c6 100644 --- a/AKModel/locale/de_DE/LC_MESSAGES/django.po +++ b/AKModel/locale/de_DE/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2019-10-12 12:40+0000\n" +"POT-Creation-Date: 2019-10-12 14:29+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" @@ -11,8 +11,60 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: models.py:10 models.py:44 models.py:58 models.py:70 models.py:81 -#: models.py:95 models.py:136 +#: availability.py:38 models.py:18 models.py:31 models.py:81 models.py:122 +#: models.py:139 models.py:159 +msgid "Event" +msgstr "Event" + +#: availability.py:39 models.py:32 models.py:82 models.py:123 models.py:140 +#: models.py:160 +msgid "Associated event" +msgstr "Zugehöriges Event" + +#: availability.py:47 +msgid "Person" +msgstr "Person" + +#: availability.py:48 +msgid "Person whose availability this is" +msgstr "Person deren Verfügbarkeit hier abgebildet wird" + +#: availability.py:56 models.py:143 models.py:153 +msgid "Room" +msgstr "Raum" + +#: availability.py:57 +msgid "Room whose availability this is" +msgstr "Raum dessen Verfügbarkeit hier abgebildet wird" + +#: availability.py:65 models.py:126 models.py:152 +msgid "AK" +msgstr "AK" + +#: availability.py:66 +#, fuzzy +#| msgid "Availabilities" +msgid "AK whose availability this is" +msgstr "Verfügbarkeiten" + +#: availability.py:74 models.py:48 +msgid "AK Type" +msgstr "AK Typ" + +#: availability.py:75 +msgid "AK Type whose availability this is" +msgstr "AK Typ dessen Verfügbarkeit hier abgebildet wird" + +#: availability.py:234 +msgid "Availability" +msgstr "Verfügbarkeit" + +#: availability.py:235 +msgid "Availabilities" +msgstr "Verfügbarkeiten" + +#: models.py:10 models.py:44 models.py:56 models.py:68 models.py:79 +#: models.py:93 models.py:133 msgid "Name" msgstr "Name" @@ -52,11 +104,6 @@ msgstr "Aktiver Status" msgid "Marks currently active events" msgstr "Markiert aktuell aktive Events" -#: models.py:18 models.py:31 models.py:83 models.py:125 models.py:144 -#: models.py:164 -msgid "Event" -msgstr "Event" - #: models.py:19 msgid "Events" msgstr "Events" @@ -85,7 +132,7 @@ msgstr "Instutution" msgid "Uni etc." msgstr "Universität o.ä." -#: models.py:29 models.py:103 +#: models.py:29 models.py:101 msgid "Web Link" msgstr "Internet Link" @@ -93,10 +140,6 @@ msgstr "Internet Link" msgid "Link to Homepage" msgstr "Link zu Homepage oder Webseite" -#: models.py:32 models.py:84 models.py:126 models.py:145 models.py:165 -msgid "Associated event" -msgstr "Zugehöriges Event" - #: models.py:35 msgid "AK Owner" msgstr "AK Leitung" @@ -109,234 +152,222 @@ msgstr "AK Leitungen" msgid "Name of the AK Type" msgstr "Name des AK Typs" -#: models.py:45 models.py:59 +#: models.py:45 models.py:57 msgid "Color" msgstr "Farbe" -#: models.py:45 models.py:59 +#: models.py:45 models.py:57 msgid "Color for displaying" msgstr "Farbe für die Anzeige" -#: models.py:50 -msgid "AK Type" -msgstr "AK Typ" - -#: models.py:51 +#: models.py:49 msgid "AK Types" msgstr "AK Typen" -#: models.py:58 +#: models.py:56 msgid "Name of the AK Track" msgstr "Name des AK Tracks" -#: models.py:62 +#: models.py:60 msgid "AK Track" msgstr "AK Track" -#: models.py:63 +#: models.py:61 msgid "AK Tracks" msgstr "AK Tracks" -#: models.py:70 +#: models.py:68 msgid "Name of the AK Tag" msgstr "Name das AK Tags" -#: models.py:73 +#: models.py:71 msgid "AK Tag" msgstr "AK Tag" -#: models.py:74 +#: models.py:72 msgid "AK Tags" msgstr "AK Tags" -#: models.py:81 +#: models.py:79 msgid "Name of the Requirement" msgstr "Name der Anforderung" -#: models.py:87 +#: models.py:85 msgid "AK Requirement" msgstr "AK Anforderung" -#: models.py:88 +#: models.py:86 msgid "AK Requirements" msgstr "AK Anforderungen" -#: models.py:95 +#: models.py:93 msgid "Name of the AK" msgstr "Name des AKs" -#: models.py:96 +#: models.py:94 msgid "Short Name" msgstr "Kurzer Name" -#: models.py:97 +#: models.py:95 msgid "Name displayed in the schedule" msgstr "Name zur Anzeige im AK Plan" -#: models.py:98 +#: models.py:96 msgid "Description" msgstr "Beschreibung" -#: models.py:98 +#: models.py:96 msgid "Description of the AK" msgstr "Beschreibung des AKs" -#: models.py:100 +#: models.py:98 msgid "Owners" msgstr "Leitungen" -#: models.py:100 +#: models.py:98 msgid "Those organizing the AK" msgstr "Menschen, die den AK organisieren und halten" -#: models.py:103 +#: models.py:101 msgid "Link to wiki page" msgstr "Link zur Wiki Seite" -#: models.py:105 +#: models.py:103 msgid "Type" msgstr "Typ" -#: models.py:105 +#: models.py:103 msgid "Type of the AK" msgstr "Typ des AKs" -#: models.py:106 +#: models.py:104 msgid "Tags" msgstr "Tags" -#: models.py:106 +#: models.py:104 msgid "Tags provided by owners" msgstr "Tags, die durch die AK Leitung vergeben wurden" -#: models.py:107 +#: models.py:105 msgid "Track" msgstr "Track" -#: models.py:108 +#: models.py:106 msgid "Track the AK belongs to" msgstr "Track zu dem der AK gehört" -#: models.py:110 +#: models.py:108 msgid "Resolution Intention" msgstr "Resolutionsabsicht" -#: models.py:111 +#: models.py:109 msgid "Intends to submit a resolution" msgstr "Beabsichtigt eine Resolution einzureichen" -#: models.py:112 +#: models.py:110 msgid "Requirements" msgstr "Anforderungen" -#: models.py:113 +#: models.py:111 msgid "AK's Requirements" msgstr "Anforderungen des AKs" -#: models.py:115 +#: models.py:113 msgid "Conflicting AKs" msgstr "AK Konflikte" -#: models.py:116 +#: models.py:114 msgid "AKs that conflict and thus must not take place at the same time" msgstr "AKs, die Konflikte haben und deshalb nicht gleichzeitig stattfinden dürfen" -#: models.py:117 +#: models.py:115 msgid "Prerequisite AKs" msgstr "Vorausgesetzte AKs" -#: models.py:118 +#: models.py:116 msgid "AKs that should precede this AK in the schedule" msgstr "AKS die im AK Plan vor diesem AK stattfinden müssen" -#: models.py:121 +#: models.py:118 msgid "Internal Notes" msgstr "Interne Notizen" -#: models.py:121 +#: models.py:118 msgid "Notes to organizers" msgstr "Notizen an die Organisator*innen" -#: models.py:123 +#: models.py:120 msgid "Interest" msgstr "Interesse" -#: models.py:123 +#: models.py:120 msgid "Expected number of people" msgstr "Erwartete Personenzahl" -#: models.py:129 models.py:157 -msgid "AK" -msgstr "AK" - -#: models.py:130 +#: models.py:127 msgid "AKs" msgstr "AKs" -#: models.py:136 +#: models.py:133 msgid "Name or number of the room" msgstr "Name oder Nummer des Raums" -#: models.py:137 +#: models.py:134 msgid "Building" msgstr "Gebäude" -#: models.py:137 +#: models.py:134 msgid "Name/number of the building" msgstr "Name oder Nummer des Gebäudes" -#: models.py:138 +#: models.py:135 msgid "Capacity" msgstr "Kapazität" -#: models.py:138 +#: models.py:135 msgid "Maximum number of people" msgstr "Maximale Personenzahl" -#: models.py:139 +#: models.py:136 msgid "Properties" msgstr "Eigenschaften" -#: models.py:140 +#: models.py:137 msgid "AK requirements fulfilled by the room" msgstr "AK Anforderungen, die dieser Raum erfüllt" -#: models.py:148 models.py:158 -msgid "Room" -msgstr "Raum" - -#: models.py:149 +#: models.py:144 msgid "Rooms" msgstr "Räume" -#: models.py:157 +#: models.py:152 msgid "AK being mapped" msgstr "AK, der zugeordnet wird" -#: models.py:159 +#: models.py:154 msgid "Room the AK will take place in" msgstr "Raum in dem der AK stattfindet" -#: models.py:160 +#: models.py:155 msgid "Slot Begin" msgstr "Beginn des Slots" -#: models.py:160 +#: models.py:155 msgid "Time and date the slot begins" msgstr "Zeit und Datum zu der der AK beginnt" -#: models.py:161 +#: models.py:156 msgid "Duration" msgstr "Dauer" -#: models.py:162 +#: models.py:157 msgid "Length in hours" msgstr "Länge in Stunden" -#: models.py:168 +#: models.py:163 msgid "AK Slot" msgstr "AK Slot" -#: models.py:169 +#: models.py:164 msgid "AK Slots" msgstr "AK Slot" diff --git a/AKModel/migrations/0007_availability.py b/AKModel/migrations/0007_availability.py new file mode 100644 index 0000000000000000000000000000000000000000..9084fb78be045e71f696af01acaf1dae3e21877f --- /dev/null +++ b/AKModel/migrations/0007_availability.py @@ -0,0 +1,41 @@ +# Generated by Django 2.2.6 on 2019-10-12 14:35 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('AKModel', '0006_translation_akmodel'), + ] + + operations = [ + migrations.CreateModel( + name='Availability', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('start', models.DateTimeField()), + ('end', models.DateTimeField()), + ('ak', models.ForeignKey(blank=True, help_text='AK whose availability this is', null=True, + on_delete=django.db.models.deletion.CASCADE, related_name='availabilities', + to='AKModel.AK', verbose_name='AK')), + ('ak_type', models.ForeignKey(blank=True, help_text='AK Type whose availability this is', null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='availabilities', to='AKModel.AKType', + verbose_name='AK Type')), + ('event', models.ForeignKey(help_text='Associated event', on_delete=django.db.models.deletion.CASCADE, + related_name='availabilities', to='AKModel.Event', verbose_name='Event')), + ('person', models.ForeignKey(blank=True, help_text='Person whose availability this is', null=True, + on_delete=django.db.models.deletion.CASCADE, related_name='availabilities', + to='AKModel.AKOwner', verbose_name='Person')), + ('room', models.ForeignKey(blank=True, help_text='Room whose availability this is', null=True, + on_delete=django.db.models.deletion.CASCADE, related_name='availabilities', + to='AKModel.Room', verbose_name='Room')), + ], + options={ + 'verbose_name': 'Availability', + 'verbose_name_plural': 'Availabilities', + 'ordering': ['event', 'start'], + }, + ), + ] diff --git a/AKModel/models.py b/AKModel/models.py index 54498b26512e123a4c400b1ecdb5bd1df7f94d3e..aad105de2feb217bfbe62173a7d863c62446d2b3 100644 --- a/AKModel/models.py +++ b/AKModel/models.py @@ -44,8 +44,6 @@ class AKType(models.Model): name = models.CharField(max_length=64, unique=True, verbose_name=_('Name'), help_text=_('Name of the AK Type')) color = models.CharField(max_length=7, blank=True, verbose_name=_('Color'), help_text=_('Color for displaying')) - # TODO model availability - class Meta: verbose_name = _('AK Type') verbose_name_plural = _('AK Types') @@ -116,7 +114,6 @@ class AK(models.Model): help_text=_('AKs that conflict and thus must not take place at the same time')) prerequisites = models.ManyToManyField(to='AK', blank=True, verbose_name=_('Prerequisite AKs'), help_text=_('AKs that should precede this AK in the schedule')) - # TODO model availability notes = models.TextField(blank=True, verbose_name=_('Internal Notes'), help_text=_('Notes to organizers')) @@ -139,8 +136,6 @@ class Room(models.Model): properties = models.ManyToManyField(to=AKRequirement, verbose_name=_('Properties'), help_text=_('AK requirements fulfilled by the room')) - # TODO model availability - event = models.ForeignKey(to=Event, on_delete=models.CASCADE, verbose_name=_('Event'), help_text=_('Associated event'))