diff --git a/AKModel/admin.py b/AKModel/admin.py index 56833b20bfdd2471e0bb52fe0e94291981dbb79c..0b17c5523d0e26263bd5487e72dd8b2796b6bdb6 100644 --- a/AKModel/admin.py +++ b/AKModel/admin.py @@ -17,7 +17,7 @@ from simple_history.admin import SimpleHistoryAdmin from AKModel.availability.models import Availability from AKModel.forms import RoomFormWithAvailabilities from AKModel.models import Event, AKOwner, AKCategory, AKTrack, AKRequirement, AK, AKSlot, Room, AKOrgaMessage, \ - ConstraintViolation, DefaultSlot, AKType + ConstraintViolation, DefaultSlot, AKType, EventParticipant, AKPreference from AKModel.urls import get_admin_urls_event_wizard, get_admin_urls_event from AKModel.views.ak import AKResetInterestView, AKResetInterestCounterView from AKModel.views.manage import CVMarkResolvedView, CVSetLevelViolationView, CVSetLevelWarningView @@ -572,6 +572,50 @@ class DefaultSlotAdmin(EventTimezoneFormMixin, admin.ModelAdmin): form = DefaultSlotAdminForm +@admin.register(EventParticipant) +class EventParticipantAdmin(PrepopulateWithNextActiveEventMixin, admin.ModelAdmin): + """ + Admin interface for EventParticipant + """ + model = EventParticipant + list_display = ['name', 'institution', 'event'] + list_filter = ['event', 'institution'] + list_editable = [] + ordering = ['name'] + + +class AKPreferenceAdminForm(forms.ModelForm): + """ + Adapted admin form for AK preferences for usage in :class:`AKPreferenceAdmin`) + """ + class Meta: + widgets = { + 'participant': forms.Select, + 'slot': forms.Select, + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Filter possible values for foreign keys & m2m when event is specified + if hasattr(self.instance, "event") and self.instance.event is not None: + self.fields["participant"].queryset = EventParticipant.objects.filter(event=self.instance.event) + self.fields["slot"].queryset = AKSlot.objects.filter(event=self.instance.event) + + +@admin.register(AKPreference) +class AKPreferenceAdmin(PrepopulateWithNextActiveEventMixin, admin.ModelAdmin): + """ + Admin interface for AK preferences. + Uses an adapted form (see :class:`AKPreferenceAdminForm`) + """ + model = AKPreference + form = AKPreferenceAdminForm + list_display = ['preference', 'participant', 'slot', 'event'] + list_filter = ['event', 'slot', 'participant'] + list_editable = [] + ordering = ['participant', 'preference', 'slot'] + + # Define a new User admin class UserAdmin(BaseUserAdmin): """ diff --git a/AKModel/availability/models.py b/AKModel/availability/models.py index e2a64b225d2ca4a3da117003e29f5ff399a76976..e58ba070c574d8d5601ce5665bd19398827e3c6e 100644 --- a/AKModel/availability/models.py +++ b/AKModel/availability/models.py @@ -10,7 +10,7 @@ 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, AKCategory +from AKModel.models import Event, AKOwner, Room, AK, AKCategory, EventParticipant zero_time = datetime.time(0, 0) @@ -24,6 +24,7 @@ zero_time = datetime.time(0, 0) # enable availabilities for AKs and AKCategories # add verbose names and help texts to model attributes # adapt or extemd documentation +# add participants class Availability(models.Model): @@ -79,20 +80,48 @@ class Availability(models.Model): verbose_name=_('AK Category'), help_text=_('AK Category whose availability this is'), ) + participant = models.ForeignKey( + to=EventParticipant, + related_name='availabilities', + on_delete=models.CASCADE, + null=True, + blank=True, + verbose_name=_('Participant'), + help_text=_('Participant whose availability this is'), + ) start = models.DateTimeField() end = models.DateTimeField() def __str__(self) -> str: person = self.person.name if self.person else None + participant = self.participant.name if self.participant else None room = getattr(self.room, 'name', None) event = getattr(getattr(self, 'event', None), 'name', None) ak = getattr(self.ak, 'name', None) ak_category = getattr(self.ak_category, 'name', None) - return f'Availability(event={event}, person={person}, room={room}, ak={ak}, ak category={ak_category})' + arg_list = [ + f"event={event}", + f"person={person}", + f"room={room}", + f"ak={ak}", + f"ak category={ak_category}", + f"participant={participant}", + ] + return f'Availability({", ".join(arg_list)})' def __hash__(self): return hash( - (getattr(self, 'event', None), self.person, self.room, self.ak, self.ak_category, self.start, self.end)) + ( + getattr(self, 'event', None), + self.person, + self.room, + self.ak, + self.ak_category, + self.participant, + self.start, + self.end, + ) + ) def __eq__(self, other: 'Availability') -> bool: """Comparisons like ``availability1 == availability2``. @@ -103,7 +132,7 @@ class Availability(models.Model): return all( ( getattr(self, attribute, None) == getattr(other, attribute, None) - for attribute in ['event', 'person', 'room', 'ak', 'ak_category', 'start', 'end'] + for attribute in ['event', 'person', 'room', 'ak', 'ak_category', 'participant', 'start', 'end'] ) ) @@ -260,6 +289,7 @@ class Availability(models.Model): room: Room | None = None, ak: AK | None = None, ak_category: AKCategory | None = None, + participant: EventParticipant | None = None, ) -> "Availability": """ Create an availability covering exactly the time between event start and event end. @@ -278,7 +308,7 @@ class Availability(models.Model): timeframe_end = event.end # adapt to our event model timeframe_end = timeframe_end + datetime.timedelta(days=1) return Availability(start=timeframe_start, end=timeframe_end, event=event, person=person, - room=room, ak=ak, ak_category=ak_category) + room=room, ak=ak, ak_category=ak_category, participant=participant) def is_covered(self, availabilities: List['Availability']): """Check if list of availibilities cover this object. diff --git a/AKModel/locale/de_DE/LC_MESSAGES/django.po b/AKModel/locale/de_DE/LC_MESSAGES/django.po index a489e73bee123c50d73e91683a90ce4d7df5b886..297ebfad2a3427e3addbaaa8314bd9a67bc0846f 100644 --- a/AKModel/locale/de_DE/LC_MESSAGES/django.po +++ b/AKModel/locale/de_DE/LC_MESSAGES/django.po @@ -1066,6 +1066,73 @@ msgstr "Konflikte" msgid "Prerequisites" msgstr "Voraussetzungen" +#: AKModel/availability/models.py:89 AKModel/models.py:1358 +#: AKModel/models.py:1435 +msgid "Participant" +msgstr "Teilnehmer*in" + +#: AKModel/availability/models.py:90 +msgid "Participant whose availability this is" +msgstr "Teilnehmer*in, deren Verfügbarkeit hier abgebildet wird" + +#: AKModel/models.py:1359 +msgid "Participants" +msgstr "Teilnehmende" + +#: AKModel/models.py:1363 +msgid "" +"Name to identify a participant by (in case of questions from the organizers)" +msgstr "Name, zur Identifikation bei Rückfragen von den Organisator*innen" + +#: AKModel/models.py:1370 +msgid "Participant's Requirements" +msgstr "Anforderungen der Teilnehmer*in" + +#: AKModel/models.py:1370 +#, python-brace-format +msgid "Anonymous {pk}" +msgstr "Anonym {pk}" + +#: AKModel/models.py:1428 +msgid "AK Preference" +msgstr "AK Präferenz" + +#: AKModel/models.py:1429 +msgid "AK Preferences" +msgstr "AK Präferenzen" + +#: AKModel/models.py:1436 +msgid "Participant this preference belongs to" +msgstr "Teilnehmer*in, zu der die Präferenz gehört" + +#: AKModel/models.py:1439 +msgid "AK Slot this preference belongs to" +msgstr "AK-Slot zu dem die Präferenz gehört" + +#: AKModel/models.py:1445 +msgid "Ignore" +msgstr "Ignorieren" + +#: AKModel/models.py:1446 +msgid "Prefer" +msgstr "Präferenz" + +#: AKModel/models.py:1447 +msgid "Strong prefer" +msgstr "Große Präferenz" + +#: AKModel/models.py:1448 +msgid "Required" +msgstr "Erforderlich" + +#: AKModel/models.py:1450 +msgid "Preference" +msgstr "Präferenz" + +#: AKModel/models.py:1451 +msgid "Preference level for the AK" +msgstr "Präferenz-Level für den AK" + #: AKModel/site.py:13 AKModel/site.py:14 msgid "Administration" msgstr "Verwaltung" diff --git a/AKModel/migrations/0065_eventparticipant_akpreference_and_more.py b/AKModel/migrations/0065_eventparticipant_akpreference_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..e169b484d675e0c3f94756e15794f4776700836c --- /dev/null +++ b/AKModel/migrations/0065_eventparticipant_akpreference_and_more.py @@ -0,0 +1,132 @@ +# Generated by Django 4.2.13 on 2025-02-10 10:23 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("AKModel", "0064_event_export_slot"), + ] + + operations = [ + migrations.CreateModel( + name="EventParticipant", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField( + blank=True, + help_text="Name to identify a participant by (in case of questions from the organizers)", + max_length=64, + verbose_name="Nickname", + ), + ), + ( + "institution", + models.CharField( + blank=True, + help_text="Uni etc.", + max_length=128, + verbose_name="Institution", + ), + ), + ( + "event", + models.ForeignKey( + help_text="Associated event", + on_delete=django.db.models.deletion.CASCADE, + to="AKModel.event", + verbose_name="Event", + ), + ), + ], + options={ + "verbose_name": "Participant", + "verbose_name_plural": "Participants", + "ordering": ["name"], + }, + ), + migrations.CreateModel( + name="AKPreference", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "preference", + models.PositiveSmallIntegerField( + choices=[ + (0, "Ignore"), + (1, "Prefer"), + (2, "Strong prefer"), + (3, "Required"), + ], + default=0, + help_text="Preference level for the AK", + verbose_name="Preference", + ), + ), + ( + "ak", + models.ForeignKey( + help_text="AK this preference belongs to", + on_delete=django.db.models.deletion.CASCADE, + to="AKModel.ak", + verbose_name="AK", + ), + ), + ( + "event", + models.ForeignKey( + help_text="Associated event", + on_delete=django.db.models.deletion.CASCADE, + to="AKModel.event", + verbose_name="Event", + ), + ), + ( + "participant", + models.ForeignKey( + help_text="Participant this preference belongs to", + on_delete=django.db.models.deletion.CASCADE, + to="AKModel.eventparticipant", + verbose_name="Participant", + ), + ), + ], + options={ + "verbose_name": "AK Preference", + "verbose_name_plural": "AK Preferences", + }, + ), + migrations.AddField( + model_name="availability", + name="participant", + field=models.ForeignKey( + blank=True, + help_text="Participant whose availability this is", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="availabilities", + to="AKModel.eventparticipant", + verbose_name="Participant", + ), + ), + ] diff --git a/AKModel/migrations/0066_akpreference_slot_alter_akpreference_unique_together_and_more.py b/AKModel/migrations/0066_akpreference_slot_alter_akpreference_unique_together_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..29f94ea84c6b32c7f5f231574c4cfc68447bed72 --- /dev/null +++ b/AKModel/migrations/0066_akpreference_slot_alter_akpreference_unique_together_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.13 on 2025-02-10 22:31 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("AKModel", "0065_eventparticipant_akpreference_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="akpreference", + name="slot", + field=models.ForeignKey( + default=None, + help_text="AKSlot this preference belongs to", + on_delete=django.db.models.deletion.CASCADE, + to="AKModel.akslot", + verbose_name="AKSlot", + ), + preserve_default=False, + ), + migrations.AlterUniqueTogether( + name="akpreference", + unique_together={("event", "participant", "slot")}, + ), + migrations.RemoveField( + model_name="akpreference", + name="ak", + ), + ] diff --git a/AKModel/migrations/0067_eventparticipant_requirements_and_more.py b/AKModel/migrations/0067_eventparticipant_requirements_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..8f1bd8e211af2c60a8d539c63f9011ab4c5dbf3c --- /dev/null +++ b/AKModel/migrations/0067_eventparticipant_requirements_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 4.2.13 on 2025-02-11 00:23 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "AKModel", + "0066_akpreference_slot_alter_akpreference_unique_together_and_more", + ), + ] + + operations = [ + migrations.AddField( + model_name="eventparticipant", + name="requirements", + field=models.ManyToManyField( + blank=True, + help_text="Participant's Requirements", + to="AKModel.akrequirement", + verbose_name="Requirements", + ), + ), + migrations.AlterField( + model_name="akpreference", + name="slot", + field=models.ForeignKey( + help_text="AK Slot this preference belongs to", + on_delete=django.db.models.deletion.CASCADE, + to="AKModel.akslot", + verbose_name="AK Slot", + ), + ), + ] diff --git a/AKModel/models.py b/AKModel/models.py index 3b560e3f7378969f6d9171338ec85013da27c9c1..02160ec4a5024a2a76844ef30d63c80446f82b00 100644 --- a/AKModel/models.py +++ b/AKModel/models.py @@ -559,6 +559,8 @@ class Event(models.Model): rooms = Room.objects.filter(event=self).order_by() slots = AKSlot.objects.filter(event=self).order_by() + participants = EventParticipant.objects.filter(event=self).order_by() + owners = AKOwner.objects.filter(event=self).order_by().all() ak_availabilities = { ak.pk: Availability.union(ak.availabilities.all()) @@ -570,7 +572,11 @@ class Event(models.Model): } person_availabilities = { person.pk: Availability.union(person.availabilities.all()) - for person in AKOwner.objects.filter(event=self) + for person in owners + } + participant_availabilities = { + participant.pk: Availability.union(participant.availabilities.all()) + for participant in EventParticipant.objects.filter(event=self) } blocks = list(self.discretize_timeslots()) @@ -619,6 +625,11 @@ class Event(models.Model): _generate_time_constraints("room", room_availabilities, timeslot.avail) ) + # add fulfilled time constraints for all participants that are not available for full event + time_constraints.extend( + _generate_time_constraints("participant", participant_availabilities, timeslot.avail) + ) + # add fulfilled time constraints for all AKSlots fixed to happen during timeslot time_constraints.extend([ f"fixed-akslot-{slot.id}" @@ -628,6 +639,7 @@ class Event(models.Model): time_constraints.extend(timeslot.constraints) time_constraints.extend(block_timeconstraints) + time_constraints.sort() current_block.append({ "id": timeslot.idx, @@ -635,7 +647,7 @@ class Event(models.Model): "start": timeslot.avail.start.astimezone(self.timezone).strftime("%Y-%m-%d %H:%M"), "end": timeslot.avail.end.astimezone(self.timezone).strftime("%Y-%m-%d %H:%M"), }, - "fulfilled_time_constraints": sorted(time_constraints), + "fulfilled_time_constraints": time_constraints, }) timeslots["blocks"].append(current_block) @@ -651,14 +663,37 @@ class Event(models.Model): if hasattr(self, attr) and getattr(self, attr): info_dict[attr] = getattr(self, attr) - return { - "participants": [], + data = { + "participants": [p.as_json_dict() for p in participants], "rooms": [r.as_json_dict() for r in rooms], "timeslots": timeslots, "info": info_dict, "aks": [ak.as_json_dict() for ak in slots], } + if EventParticipant.objects.exists(): + next_participant_pk = EventParticipant.objects.latest("pk").pk + 1 + else: + next_participant_pk = 1 + # add one dummy participant per owner + # this ensures that the hard constraints from each owner are considered + for new_pk, owner in enumerate(owners, next_participant_pk): + owned_slots = slots.filter(ak__owners=owner).order_by().all() + if not owned_slots: + continue + new_participant_data = { + "id": new_pk, + "info": {"name": f"{owner} [AKOwner]"}, + "room_constraints": [], + "time_constraints": [], + "preferences": [ + {"ak_id": slot.pk, "required": True, "preference_score": -1} + for slot in owned_slots + ] + } + data["participants"].append(new_participant_data) + return data + class AKOwner(models.Model): """ An AKOwner describes the person organizing/holding an AK. @@ -1091,8 +1126,7 @@ class Room(models.Model): "name": self.name, }, "capacity": self.capacity, - "fulfilled_room_constraints": [constraint.name - for constraint in self.properties.all()], + "fulfilled_room_constraints": list(self.properties.values_list("name", flat=True)), "time_constraints": time_constraints } @@ -1238,20 +1272,14 @@ class AKSlot(models.Model): "id": self.pk, "duration": math.ceil(self.duration / self.event.export_slot - ceil_offet_eps), "properties": { - "conflicts": - sorted( - [conflict.pk for conflict in conflict_slots.all()] - + [second_slot.pk for second_slot in other_ak_slots.all()] - ), - "dependencies": sorted([dep.pk for dep in dependency_slots.all()]), + "conflicts": list((conflict_slots | other_ak_slots).values_list("pk", flat=True).order_by()), + "dependencies": list(dependency_slots.values_list("pk", flat=True).order_by()), }, - "room_constraints": [constraint.name - for constraint in self.ak.requirements.all()], + "room_constraints": list(self.ak.requirements.values_list("name", flat=True).order_by()), "time_constraints": ["resolution"] if self.ak.reso else [], "info": { "name": self.ak.name, - "head": ", ".join([str(owner) - for owner in self.ak.owners.all()]), + "head": ", ".join([str(owner) for owner in self.ak.owners.order_by().all()]), "description": self.ak.description, "reso": self.ak.reso, "duration_in_hours": float(self.duration), @@ -1575,3 +1603,126 @@ class DefaultSlot(models.Model): def __str__(self): return f"{self.event}: {self.start_simplified} - {self.end_simplified}" + + +class EventParticipant(models.Model): + """ A participant describes a person taking part in an event.""" + + class Meta: + verbose_name = _('Participant') + verbose_name_plural = _('Participants') + ordering = ['name'] + + name = models.CharField(max_length=64, blank=True, verbose_name=_('Nickname'), + help_text=_('Name to identify a participant by (in case of questions from the organizers)')) + institution = models.CharField(max_length=128, blank=True, verbose_name=_('Institution'), help_text=_('Uni etc.')) + + event = models.ForeignKey(to=Event, on_delete=models.CASCADE, verbose_name=_('Event'), + help_text=_('Associated event')) + + requirements = models.ManyToManyField(to=AKRequirement, blank=True, verbose_name=_('Requirements'), + help_text=_("Participant's Requirements")) + + def __str__(self) -> str: + string = _("Anonymous {pk}").format(pk=self.pk) if not self.name else self.name + if self.institution: + string += f" ({self.institution})" + return string + + @property + def availabilities(self): + """ + Get all availabilities associated to this EventParticipant + :return: availabilities + :rtype: QuerySet[Availability] + """ + return "Availability".objects.filter(participant=self) + + def as_json_dict(self) -> dict[str, Any]: + """Return a json representation of this participant object. + + :return: The json dict representation is constructed + following the input specification of the KoMa conference optimizer, cf. + https://github.com/Die-KoMa/ak-plan-optimierung/wiki/Input-&-output-format + :rtype: dict[str, Any] + """ + # local import to prevent cyclic import + # pylint: disable=import-outside-toplevel + from AKModel.availability.models import Availability + + data = { + "id": self.pk, + "info": {"name": str(self)}, + "room_constraints": list(self.requirements.values_list("name", flat=True).order_by()), + "time_constraints": [], + } + data["preferences"] = [ + pref.as_json_dict() + for pref in AKPreference.objects.filter( + participant=self, preference__gt=0 + ).select_related("slot").order_by() + ] + + avails = self.availabilities.all() + if avails and not Availability.is_event_covered(self.event, avails): + # participant has restricted availability + if AKPreference.objects.filter( + event=self.event, + participant=self, + preference=AKPreference.PreferenceLevel.REQUIRED, + ): + # partipant is actually required for AKs + data["time_constraints"].append(f"availability-participant-{self.pk}") + + data["time_constraints"].sort() + return data + + +class AKPreference(models.Model): + """Model representing the preference of a participant to an AK.""" + + class Meta: + verbose_name = _('AK Preference') + verbose_name_plural = _('AK Preferences') + unique_together = [['event', 'participant', 'slot']] + + event = models.ForeignKey(to=Event, on_delete=models.CASCADE, verbose_name=_('Event'), + help_text=_('Associated event')) + + participant = models.ForeignKey(to=EventParticipant, on_delete=models.CASCADE, verbose_name=_('Participant'), + help_text=_('Participant this preference belongs to')) + + slot = models.ForeignKey(to=AKSlot, on_delete=models.CASCADE, verbose_name=_('AK Slot'), + help_text=_('AK Slot this preference belongs to')) + + class PreferenceLevel(models.IntegerChoices): + """ + Possible preference values + """ + IGNORE = 0, _('Ignore') + PREFER = 1, _('Prefer') + STRONG_PREFER = 2, _("Strong prefer") + REQUIRED = 3, _("Required") + + preference = models.PositiveSmallIntegerField(verbose_name=_('Preference'), choices=PreferenceLevel.choices, + help_text=_('Preference level for the AK'), + blank=False, + default=PreferenceLevel.IGNORE) + + def __str__(self) -> str: + return "AKPreference: " + json.dumps(self.as_json_dict()) + + def as_json_dict(self) -> dict[str, int | bool]: + """Return a json representation of this AKPreference object. + + :return: The json dict representation is constructed + following the input specification of the KoMa conference optimizer, cf. + https://github.com/Die-KoMa/ak-plan-optimierung/wiki/Input-&-output-format + :rtype: dict[str, Any] + """ + preference_score = self.preference if self.preference != self.PreferenceLevel.REQUIRED else -1 + return { + "ak_id": self.slot.pk, + "required": self.preference == self.PreferenceLevel.REQUIRED, + "preference_score": preference_score + } diff --git a/AKModel/tests/test_json_export.py b/AKModel/tests/test_json_export.py index f0cbba1c8c655a30c4329b9d70d726cad4c7e0d3..50482ce10c010b7d05fb9d8a3af529e63cfdfd88 100644 --- a/AKModel/tests/test_json_export.py +++ b/AKModel/tests/test_json_export.py @@ -12,7 +12,17 @@ from django.urls import reverse from jsonschema.exceptions import best_match from AKModel.availability.models import Availability -from AKModel.models import AK, AKCategory, AKOwner, AKSlot, DefaultSlot, Event, Room +from AKModel.models import ( + AK, + AKCategory, + AKOwner, + AKPreference, + AKSlot, + DefaultSlot, + Event, + EventParticipant, + Room, +) from AKModel.utils import construct_schema_validator @@ -52,7 +62,10 @@ class JSONExportTest(TestCase): self.ak_slots: Iterable[AKSlot] = [] self.rooms: Iterable[Room] = [] + self.participants: Iterable[EventParticipant] = [] + self.owners: Iterable[AKOwner] = [] self.slots_in_an_hour: float = 1.0 + self.max_participant_pk = 0 self.event: Event | None = None def set_up_event(self, event: Event) -> None: @@ -83,6 +96,15 @@ class JSONExportTest(TestCase): .all() ) self.rooms = Room.objects.filter(event__slug=event.slug).all() + self.participants = EventParticipant.objects.filter( + event__slug=event.slug + ).all() + self.owners = AKOwner.objects.filter(event__slug=event.slug).all() + + self.max_participant_pk = ( + self.participants.latest("pk").pk if self.participants else 0 + ) + self.slots_in_an_hour = 1 / self.export_dict["timeslots"]["info"]["duration"] self.event = event @@ -167,12 +189,6 @@ class JSONExportTest(TestCase): with self.subTest(event=event): self.set_up_event(event=event) - 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): @@ -611,7 +627,7 @@ class JSONExportTest(TestCase): # add owner constraints fulfilled_time_constraints |= { f"availability-person-{owner.id}" - for owner in AKOwner.objects.filter(event=self.event).all() + for owner in self.owners if self._is_restricted_and_contained_slot( timeslot_avail, Availability.union(owner.availabilities.all()), @@ -628,6 +644,16 @@ class JSONExportTest(TestCase): ) } + # add participant constraints + fulfilled_time_constraints |= { + f"availability-participant-{participant.id}" + for participant in self.participants + if self._is_restricted_and_contained_slot( + timeslot_avail, + Availability.union(participant.availabilities.all()), + ) + } + # add ak constraints fulfilled_time_constraints |= { f"availability-ak-{ak.id}" @@ -692,3 +718,153 @@ class JSONExportTest(TestCase): self.assertEqual( block_names, self.export_dict["timeslots"]["info"]["blocknames"] ) + + def _owner_has_ak(self, owner: AKOwner) -> bool: + owned_aks = self.ak_slots.filter(ak__owners=owner).all() + return bool(owned_aks) + + def test_all_participants_exported(self): + """Test if exported Rooms match the rooms of Event.""" + for event in Event.objects.all(): + with self.subTest(event=event): + self.set_up_event(event=event) + + participant_ids = set(self.participants.values_list("pk", flat=True)) + for idx, owner in enumerate(self.owners, self.max_participant_pk + 1): + if self._owner_has_ak(owner): + participant_ids.add(idx) + + self.assertEqual( + participant_ids, + self.export_objects["participants"].keys(), + "Exported Participants does not match the Participants of the event", + ) + + def test_participant_info(self): + """Test if contents of participants info dict is correct.""" + for event in Event.objects.all(): + with self.subTest(event=event): + self.set_up_event(event=event) + + for participant in self.participants: + export_participant = self.export_objects["participants"][ + participant.pk + ] + self.assertEqual( + str(participant), export_participant["info"]["name"] + ) + + for idx, owner in enumerate(self.owners, self.max_participant_pk + 1): + if not self._owner_has_ak(owner): + continue + export_participant = self.export_objects["participants"][idx] + self.assertEqual( + str(owner) + " [AKOwner]", export_participant["info"]["name"] + ) + + def test_participant_timeconstraints(self): + """Test if participant time constraints are exported as expected.""" + for event in Event.objects.all(): + with self.subTest(event=event): + self.set_up_event(event=event) + for participant in self.participants: + export_participant = self.export_objects["participants"][ + participant.pk + ] + + time_constraints = set() + participant_avails = participant.availabilities.all() + if participant_avails and not Availability.is_event_covered( + self.event, participant_avails + ): + # participant has restricted availability + if AKPreference.objects.filter( + event=self.event, + participant=participant, + preference=AKPreference.PreferenceLevel.REQUIRED, + ): + # partipant is actually required for AKs + time_constraints.add( + f"availability-participant-{participant.pk}" + ) + + self.assertEqual( + set(export_participant["time_constraints"]), time_constraints + ) + + # dummy participants have no time constraints + for idx, owner in enumerate(self.owners, self.max_participant_pk + 1): + if not self._owner_has_ak(owner): + continue + export_participant = self.export_objects["participants"][idx] + self.assertEqual(export_participant["time_constraints"], []) + + def test_participant_roomconstraints(self): + """Test if participant room constraints are exported as expected.""" + for event in Event.objects.all(): + with self.subTest(event=event): + self.set_up_event(event=event) + for participant in self.participants: + export_participant = self.export_objects["participants"][ + participant.pk + ] + room_constraints = [ + constr.name for constr in participant.requirements.all() + ] + self.assertCountEqual( + export_participant["room_constraints"], room_constraints + ) + + for idx, owner in enumerate(self.owners, self.max_participant_pk + 1): + if not self._owner_has_ak(owner): + continue + export_participant = self.export_objects["participants"][idx] + self.assertEqual(export_participant["room_constraints"], []) + + def test_preferences(self): + """Test if preferences are exported as expected.""" + + def _preference_json(pref: AKPreference): + return { + "ak_id": pref.slot.pk, + "required": pref.preference == AKPreference.PreferenceLevel.REQUIRED, + "preference_score": ( + pref.preference + if pref.preference != AKPreference.PreferenceLevel.REQUIRED + else -1 + ), + } + + for event in Event.objects.all(): + with self.subTest(event=event): + self.set_up_event(event=event) + for participant in self.participants: + export_participant = self.export_objects["participants"][ + participant.pk + ] + preferences = [ + _preference_json(pref) + for pref in AKPreference.objects.filter( + participant=participant, preference__gt=0 + ).select_related("slot") + ] + self.assertCountEqual( + export_participant["preferences"], preferences + ) + + for idx, owner in enumerate(self.owners, self.max_participant_pk + 1): + owned_slots = self.ak_slots.filter(ak__owners=owner).all() + if not owned_slots: + continue + preferences = [ + { + "ak_id": slot.pk, + "required": True, + "preference_score": -1, + } + for slot in owned_slots + ] + export_participant = self.export_objects["participants"][idx] + self.assertCountEqual( + export_participant["preferences"], preferences + ) diff --git a/AKModel/tests/test_views.py b/AKModel/tests/test_views.py index 639847458a866c62304164bc64b08570b8d151d5..eb9c842da13c548984f3ee8c9591849f06fe8144 100644 --- a/AKModel/tests/test_views.py +++ b/AKModel/tests/test_views.py @@ -5,20 +5,20 @@ 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, reverse_lazy from AKModel.models import ( - Event, - AKOwner, + AK, AKCategory, - AKTrack, + AKOrgaMessage, + AKOwner, AKRequirement, - AK, - Room, AKSlot, - AKOrgaMessage, + AKTrack, ConstraintViolation, DefaultSlot, + Event, + Room, )