diff --git a/ephios/core/forms/events.py b/ephios/core/forms/events.py index 2234141eb..6b88eb84e 100644 --- a/ephios/core/forms/events.py +++ b/ephios/core/forms/events.py @@ -18,9 +18,9 @@ from guardian.shortcuts import assign_perm, get_objects_for_user, get_users_with_perms, remove_perm from recurrence.forms import RecurrenceField -from ephios.core import signup from ephios.core.dynamic_preferences_registry import event_type_preference_registry from ephios.core.models import Event, EventType, LocalParticipation, Shift, UserProfile +from ephios.core.signup.methods import enabled_signup_methods from ephios.core.widgets import MultiUserProfileWidget from ephios.extra.crispy import AbortLink from ephios.extra.permissions import get_groups_with_perms @@ -171,7 +171,7 @@ class Meta: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - signup_methods = list(signup.enabled_signup_methods()) + signup_methods = list(enabled_signup_methods()) if self.instance and (method_slug := self.instance.signup_method_slug): if method_slug not in map(operator.attrgetter("slug"), signup_methods): signup_methods.append(self.instance.signup_method) @@ -221,7 +221,7 @@ class EventDuplicationForm(forms.Form): class EventTypeForm(forms.ModelForm): class Meta: model = EventType - fields = ["title", "can_grant_qualification", "color"] + fields = ["title", "color"] widgets = {"color": ColorInput()} def clean_color(self): diff --git a/ephios/core/ical.py b/ephios/core/ical.py index 7f1e3362e..fe8ece146 100644 --- a/ephios/core/ical.py +++ b/ephios/core/ical.py @@ -1,5 +1,6 @@ from django.conf import settings from django.contrib.auth import get_user_model +from django.db.models import Prefetch from django.shortcuts import get_object_or_404 from django_ical.views import ICalFeed from guardian.shortcuts import get_users_with_perms @@ -48,9 +49,20 @@ def __init__(self, user): super().__init__() self.user = user + def item_start_datetime(self, item): + return item.participations.all()[0].start_time + + def item_end_datetime(self, item): + return item.participations.all()[0].end_time + def items(self): - return self.user.get_shifts( - with_participation_state_in=[AbstractParticipation.States.CONFIRMED] + shift_ids = self.user.participations.filter( + state=AbstractParticipation.States.CONFIRMED + ).values_list("shift", flat=True) + return ( + Shift.objects.filter(pk__in=shift_ids) + .select_related("event") + .prefetch_related(Prefetch("participations", queryset=self.user.participations.all())) ) diff --git a/ephios/core/migrations/0014_auto_20211106_1852.py b/ephios/core/migrations/0014_auto_20211106_1852.py new file mode 100644 index 000000000..67dbf619e --- /dev/null +++ b/ephios/core/migrations/0014_auto_20211106_1852.py @@ -0,0 +1,84 @@ +# Generated by Django 3.2.9 on 2021-11-06 17:52 + +import django.db.models.deletion +import django.db.models.manager +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0013_auto_20211027_2203"), + ] + + operations = [ + migrations.AlterModelOptions( + name="event", + options={ + "base_manager_name": "all_objects", + "default_manager_name": "objects", + "permissions": [("view_past_event", "Can view past events")], + "verbose_name": "event", + "verbose_name_plural": "events", + }, + ), + migrations.AlterModelManagers( + name="event", + managers=[ + ("objects", django.db.models.manager.Manager()), + ("all_objects", django.db.models.manager.Manager()), + ], + ), + migrations.RemoveField( + model_name="eventtype", + name="can_grant_qualification", + ), + migrations.AddField( + model_name="abstractparticipation", + name="comment", + field=models.CharField(blank=True, max_length=255, verbose_name="Comment"), + ), + migrations.AddField( + model_name="abstractparticipation", + name="individual_end_time", + field=models.DateTimeField(null=True, verbose_name="individual end time"), + ), + migrations.AddField( + model_name="abstractparticipation", + name="individual_start_time", + field=models.DateTimeField(null=True, verbose_name="individual start time"), + ), + migrations.AddField( + model_name="notification", + name="created_at", + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AlterField( + model_name="abstractparticipation", + name="state", + field=models.IntegerField( + choices=[ + (0, "requested"), + (1, "confirmed"), + (2, "declined by user"), + (3, "rejected by responsible"), + (4, "getting dispatched"), + ], + default=4, + verbose_name="state", + ), + ), + migrations.AlterField( + model_name="localparticipation", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="participations", + to=settings.AUTH_USER_MODEL, + verbose_name="Participant", + ), + ), + ] diff --git a/ephios/core/models/events.py b/ephios/core/models/events.py index 9fc3df9c6..38c90bd40 100644 --- a/ephios/core/models/events.py +++ b/ephios/core/models/events.py @@ -3,8 +3,6 @@ from datetime import timedelta from typing import TYPE_CHECKING -import pytz -from django.conf import settings from django.db import models, transaction from django.db.models import ( BooleanField, @@ -18,11 +16,14 @@ SlugField, TextField, ) +from django.db.models.functions import Coalesce from django.utils import formats from django.utils.text import slugify +from django.utils.timezone import localtime from django.utils.translation import gettext_lazy as _ from dynamic_preferences.models import PerInstancePreferenceModel from guardian.shortcuts import assign_perm +from polymorphic.managers import PolymorphicManager from polymorphic.models import PolymorphicModel from ephios.extra.json import CustomJSONDecoder, CustomJSONEncoder @@ -31,7 +32,8 @@ if TYPE_CHECKING: from ephios.core.models import UserProfile - from ephios.core.signup import AbstractParticipant, SignupStats + from ephios.core.signup.methods import BaseSignupMethod, SignupStats + from ephios.core.signup.participants import AbstractParticipant class ActiveManager(Manager): @@ -41,7 +43,6 @@ def get_queryset(self): class EventType(Model): title = CharField(_("title"), max_length=254) - can_grant_qualification = BooleanField(_("can grant qualification")) color = CharField(_("color"), max_length=7, default="#343a40") class Meta: @@ -102,7 +103,7 @@ def is_multi_day(self): def get_signup_stats(self) -> "SignupStats": """Return a SignupStats object aggregated over all shifts of this event, or a default""" - from ephios.core.signup import SignupStats + from ephios.core.signup.methods import SignupStats default_for_no_shifts = SignupStats.ZERO @@ -138,7 +139,42 @@ def activate(self): register_model_for_logging(Event, ModelFieldsLogConfig()) -class AbstractParticipation(PolymorphicModel): +class ParticipationManager(PolymorphicManager): + def get_queryset(self): + return ( + super() + .get_queryset() + .annotate( + start_time=Coalesce("individual_start_time", "shift__start_time"), + end_time=Coalesce("individual_end_time", "shift__end_time"), + ) + ) + + +class DatetimeDisplayMixin: + """ + Date and time formatting utilities used for start_time and end_time attributes + in AbstractParticipation and Shift. + """ + + def get_start_time(self): + return self.start_time + + def get_end_time(self): + return self.end_time + + def get_date_display(self): + start_time = localtime(self.get_start_time()) + return f"{formats.date_format(start_time, 'l')}, {formats.date_format(start_time, 'SHORT_DATE_FORMAT')}" + + def get_time_display(self): + return f"{formats.time_format(localtime(self.get_start_time()))} - {formats.time_format(localtime(self.get_end_time()))}" + + def get_datetime_display(self): + return f"{self.get_date_display()}, {self.get_time_display()}" + + +class AbstractParticipation(DatetimeDisplayMixin, PolymorphicModel): class States(models.IntegerChoices): REQUESTED = 0, _("requested") CONFIRMED = 1, _("confirmed") @@ -153,15 +189,34 @@ def labels_dict(cls): shift = ForeignKey( "Shift", on_delete=models.CASCADE, verbose_name=_("shift"), related_name="participations" ) - state = IntegerField(_("state"), choices=States.choices) + state = IntegerField(_("state"), choices=States.choices, default=States.GETTING_DISPATCHED) data = models.JSONField(default=dict, verbose_name=_("Signup data")) + """ + Overwrites shift time. Use `start_time` and `end_time` to get the applicable time (implemented with a custom manager). + """ + individual_start_time = DateTimeField(_("individual start time"), null=True) + individual_end_time = DateTimeField(_("individual end time"), null=True) + + # human readable comment + comment = models.CharField(_("Comment"), max_length=255, blank=True) + """ The finished flag is used to make sure the participation_finished signal is only sent out once, even if the shift time is changed afterwards. """ finished = models.BooleanField(default=False, verbose_name=_("finished")) + objects = ParticipationManager() + + def has_customized_signup(self): + return bool( + self.individual_start_time + or self.individual_end_time + or self.comment + or self.shift.signup_method.has_customized_signup(self) + ) + @property def hours_value(self): td = self.shift.end_time - self.shift.start_time @@ -180,6 +235,9 @@ def __str__(self): except NotImplementedError: return super().__str__() + def is_in_positive_state(self): + return self.state in {self.States.CONFIRMED, self.States.REQUESTED} + PARTICIPATION_LOG_CONFIG = ModelFieldsLogConfig( unlogged_fields=["id", "data", "abstractparticipation_ptr"], @@ -187,7 +245,7 @@ def __str__(self): ) -class Shift(Model): +class Shift(DatetimeDisplayMixin, Model): event = ForeignKey( Event, on_delete=models.CASCADE, related_name="shifts", verbose_name=_("event") ) @@ -206,22 +264,18 @@ class Meta: db_table = "shift" @property - def signup_method(self): - from ephios.core.signup import signup_method_from_slug + def signup_method(self) -> "BaseSignupMethod": + from ephios.core.signup.methods import signup_method_from_slug try: - return signup_method_from_slug(self.signup_method_slug, self) + event = self.event + except Event.DoesNotExist: + event = None + try: + return signup_method_from_slug(self.signup_method_slug, self, event=event) except ValueError: return None - def get_start_end_time_display(self): - tz = pytz.timezone(settings.TIME_ZONE) - start_time = self.start_time.astimezone(tz) - return ( - f"{formats.date_format(start_time, 'l')}, {formats.date_format(start_time, 'SHORT_DATE_FORMAT')}, " - + f"{formats.time_format(start_time)} - {formats.time_format(self.end_time.astimezone(tz))}" - ) - def get_participants(self, with_state_in=frozenset({AbstractParticipation.States.CONFIRMED})): for participation in self.participations.filter(state__in=with_state_in): yield participation.participant @@ -230,7 +284,7 @@ def get_absolute_url(self): return f"{self.event.get_absolute_url()}#shift-{self.pk}" def __str__(self): - return f"{self.event.title} ({self.get_start_end_time_display()})" + return f"{self.event.title} ({self.get_datetime_display()})" class ShiftLogConfig(ModelFieldsLogConfig): @@ -243,13 +297,17 @@ def object_to_attach_logentries_to(self, instance): def initial_log_recorders(self, instance): # pylint: disable=undefined-variable yield from super().initial_log_recorders(instance) - yield DerivedFieldsLogRecorder( - lambda shift: { - _("Signup method"): str(method.verbose_name) - if (method := shift.signup_method) - else None - } - ) + + def get_signup_method_name_mapping(shift): + from ephios.core.signup.methods import installed_signup_methods + + v = None + for method in installed_signup_methods(): + if method.slug == shift.signup_method_slug: + v = str(method.verbose_name) + return {_("Signup method"): v} + + yield DerivedFieldsLogRecorder(get_signup_method_name_mapping) register_model_for_logging(Shift, ShiftLogConfig()) @@ -257,7 +315,10 @@ def initial_log_recorders(self, instance): class LocalParticipation(AbstractParticipation): user: "UserProfile" = ForeignKey( - "UserProfile", on_delete=models.CASCADE, verbose_name=_("Participant") + "UserProfile", + on_delete=models.CASCADE, + verbose_name=_("Participant"), + related_name="participations", ) def save(self, *args, **kwargs): @@ -289,7 +350,7 @@ class PlaceholderParticipation(AbstractParticipation): @property def participant(self) -> "AbstractParticipant": from ephios.core.models.users import Qualification - from ephios.core.signup.methods import PlaceholderParticipant + from ephios.core.signup.participants import PlaceholderParticipant return PlaceholderParticipant( first_name=self.first_name, diff --git a/ephios/core/models/users.py b/ephios/core/models/users.py index 7ff11c3d4..575b74ec0 100644 --- a/ephios/core/models/users.py +++ b/ephios/core/models/users.py @@ -131,7 +131,7 @@ def is_minor(self): return self.age < 18 def as_participant(self): - from ephios.core.signup import LocalUserParticipant + from ephios.core.signup.participants import LocalUserParticipant return LocalUserParticipant( first_name=self.first_name, @@ -150,25 +150,17 @@ def qualifications(self): expires=Max(F("grants__expires"), filter=Q(grants__user=self)), ) - def get_shifts(self, with_participation_state_in): - from ephios.core.models import Shift - - shift_ids = self.localparticipation_set.filter( - state__in=with_participation_state_in - ).values_list("shift", flat=True) - return Shift.objects.filter(pk__in=shift_ids).select_related("event") - def get_workhour_items(self): from ephios.core.models import AbstractParticipation participations = ( - self.localparticipation_set.filter(state=AbstractParticipation.States.CONFIRMED) + self.participations.filter(state=AbstractParticipation.States.CONFIRMED) .annotate( duration=ExpressionWrapper( - (F("shift__end_time") - F("shift__start_time")), + (F("end_time") - F("start_time")), output_field=models.DurationField(), ), - date=ExpressionWrapper(TruncDate(F("shift__start_time")), output_field=DateField()), + date=ExpressionWrapper(TruncDate(F("start_time")), output_field=DateField()), reason=F("shift__event__title"), ) .values("duration", "date", "reason") @@ -457,6 +449,7 @@ class Notification(Model): data = models.JSONField( blank=True, default=dict, encoder=CustomJSONEncoder, decoder=CustomJSONDecoder ) + created_at = models.DateTimeField(auto_now_add=True) @functools.cached_property def notification_type(self): diff --git a/ephios/core/pdf.py b/ephios/core/pdf.py index 5b5fefc91..7afb2b699 100644 --- a/ephios/core/pdf.py +++ b/ephios/core/pdf.py @@ -136,7 +136,7 @@ def get_story(self): for shift in self.event.shifts.all(): story.append(Spacer(height=1 * cm, width=19 * cm)) - story.append(Paragraph(shift.get_start_end_time_display(), self.style["Heading2"])) + story.append(Paragraph(shift.get_datetime_display(), self.style["Heading2"])) data = [ [_("Meeting time"), formats.time_format(shift.meeting_time.astimezone(tz))], ] + [ diff --git a/ephios/core/services/notifications/types.py b/ephios/core/services/notifications/types.py index 37894e7d3..eee6dd53b 100644 --- a/ephios/core/services/notifications/types.py +++ b/ephios/core/services/notifications/types.py @@ -1,3 +1,4 @@ +from typing import List from urllib.parse import urljoin from django.conf import settings @@ -13,7 +14,7 @@ from ephios.core.models import AbstractParticipation, Event, LocalParticipation, UserProfile from ephios.core.models.users import Consequence, Notification from ephios.core.signals import register_notification_types -from ephios.core.signup import LocalUserParticipant +from ephios.core.signup.participants import LocalUserParticipant def installed_notification_types(): @@ -169,9 +170,13 @@ def get_url(cls, notification): return urljoin(settings.GET_SITE_URL(), event.get_absolute_url()) -class ParticipationConfirmedNotification(AbstractNotificationHandler): - slug = "ephios_participation_confirmed" - title = _("Your participation has been confirmed") +class ParticipationMixin: + @classmethod + def get_url(cls, notification): + shift = AbstractParticipation.objects.get( + id=notification.data.get("participation_id") + ).shift + return urljoin(settings.GET_SITE_URL(), shift.get_absolute_url()) @classmethod def send(cls, participation: AbstractParticipation): @@ -186,6 +191,11 @@ def send(cls, participation: AbstractParticipation): data=dict(participation_id=participation.id, email=participation.participant.email), ) + +class ParticipationConfirmedNotification(ParticipationMixin, AbstractNotificationHandler): + slug = "ephios_participation_confirmed" + title = _("Your participation has been confirmed") + @classmethod def get_subject(cls, notification): event = AbstractParticipation.objects.get( @@ -195,25 +205,46 @@ def get_subject(cls, notification): @classmethod def as_plaintext(cls, notification): - shift = AbstractParticipation.objects.get( + participation: AbstractParticipation = AbstractParticipation.objects.get( id=notification.data.get("participation_id") - ).shift - return _("Your participation for {shift} is now confirmed.").format(shift=shift) + ) + message = _("Your participation for {shift} is now confirmed.").format( + shift=participation.shift + ) + if participation.has_customized_signup(): + message += f'\n\n{_("Your time is")} {participation.get_time_display()}' + return message + + +class ParticipationRejectedNotification(ParticipationMixin, AbstractNotificationHandler): + slug = "ephios_participation_rejected" + title = _("Your participation has been rejected") @classmethod - def get_url(cls, notification): + def get_subject(cls, notification): event = AbstractParticipation.objects.get( id=notification.data.get("participation_id") ).shift.event - return urljoin(settings.GET_SITE_URL(), event.get_absolute_url()) + return _("Participation rejected for {event}").format(event=event) + @classmethod + def as_plaintext(cls, notification): + shift = AbstractParticipation.objects.get( + id=notification.data.get("participation_id") + ).shift + return _("Your participation for {shift} has been rejected by a responsible user.").format( + shift=shift + ) -class ParticipationRejectedNotification(AbstractNotificationHandler): - slug = "ephios_participation_rejected" - title = _("Your participation has been rejected") + +class ParticipationCustomizationNotification(ParticipationMixin, AbstractNotificationHandler): + slug = "ephios_participation_customized" + title = _("A confirmed participation of yours has been tweaked by a responsible") @classmethod - def send(cls, participation: AbstractParticipation): + def send( + cls, participation: AbstractParticipation, claims: List[str] + ): # pylint: disable=arguments-differ user = ( participation.user if participation.get_real_instance_class() == LocalParticipation @@ -222,63 +253,70 @@ def send(cls, participation: AbstractParticipation): Notification.objects.create( slug=cls.slug, user=user, - data=dict(participation_id=participation.id, email=participation.participant.email), + data=dict( + participation_id=participation.id, + email=participation.participant.email, + claims=claims, + ), ) @classmethod def get_subject(cls, notification): - event = AbstractParticipation.objects.get( + shift = AbstractParticipation.objects.get( id=notification.data.get("participation_id") - ).shift.event - return _("Participation rejected for {event}").format(event=event) + ).shift + return _("Participation tweaked for {shift}").format(shift=shift) @classmethod def as_plaintext(cls, notification): shift = AbstractParticipation.objects.get( id=notification.data.get("participation_id") ).shift - return _("Your participation for {shift} has been rejected by a responsible user.").format( - shift=shift - ) - - @classmethod - def get_url(cls, notification): - event = AbstractParticipation.objects.get( - id=notification.data.get("participation_id") - ).shift.event - return urljoin(settings.GET_SITE_URL(), event.get_absolute_url()) + message = _( + "Your participation for {shift} has been tweaked by a responsible user." + ).format(shift=shift) + message += "\n\n" + "\n".join(f"- {claim}" for claim in notification.data.get("claims", [])) + return message -class ResponsibleParticipationRequested(AbstractNotificationHandler): - slug = "ephios_participation_responsible_requested" - title = _("A participation has been requested for your event") +class ResponsibleMixin: + def __init__(self, participation: AbstractParticipation): + self.participation = participation - @classmethod - def send(cls, participation: AbstractParticipation): - responsible_users = get_users_with_perms( - participation.shift.event, only_with_perms_in=["change_event"] + def responsible_users(self): + users = get_users_with_perms( + self.participation.shift.event, only_with_perms_in=["change_event"] ).distinct() - disposition_url = urljoin( - settings.GET_SITE_URL(), - reverse("core:shift_disposition", kwargs=dict(pk=participation.shift.pk)), - ) - notifications = [] - for user in responsible_users: + for user in users: if ( - not participation.get_real_instance_class() == LocalParticipation - or user != participation.user + self.participation.get_real_instance_class() != LocalParticipation + or user != self.participation.user ): - notifications.append( - Notification( - slug=cls.slug, - user=user, - data=dict( - participation_id=participation.id, - disposition_url=disposition_url, - ), - ) + yield user + + def get_data(self, **kwargs): + kwargs["disposition_url"] = urljoin( + settings.GET_SITE_URL(), + reverse("core:shift_disposition", kwargs=dict(pk=self.participation.shift.pk)), + ) + kwargs["participation_id"] = self.participation.id + return kwargs + + def send(self): + Notification.objects.bulk_create( + [ + Notification( + slug=self.slug, + user=user, + data=self.get_data(), ) - Notification.objects.bulk_create(notifications) + for user in self.responsible_users() + ] + ) + + @classmethod + def get_url(cls, notification): + return notification.data.get("disposition_url") @classmethod def get_subject(cls, notification): @@ -290,6 +328,11 @@ def get_subject(cls, notification): state=participation_state, event=participation.shift.event ) + +class ResponsibleParticipationRequestedNotification(ResponsibleMixin, AbstractNotificationHandler): + slug = "ephios_participation_responsible_requested" + title = _("A participation has been requested for your event") + @classmethod def as_plaintext(cls, notification): participation = AbstractParticipation.objects.get( @@ -307,9 +350,53 @@ def as_plaintext(cls, notification): participant=participation.participant, shift=participation.shift ) + +class ResponsibleConfirmedParticipationDeclinedNotification( + ResponsibleMixin, AbstractNotificationHandler +): + slug = "ephios_participation_responsible_confirmed_declined" + title = _("A participant declined after having been confirmed for your event") + @classmethod - def get_url(cls, notification): - return notification.data.get("disposition_url") + def as_plaintext(cls, notification): + participation = AbstractParticipation.objects.get( + id=notification.data.get("participation_id") + ) + return _("{participant} declined their participation in {shift}.").format( + participant=participation.participant, shift=participation.shift + ) + + +class ResponsibleConfirmedParticipationCustomizedNotification( + ResponsibleMixin, AbstractNotificationHandler +): + slug = "ephios_participation_responsible_confirmed_customized" + title = _("A confirmed participant altered their participation") + + @classmethod + def get_subject(cls, notification): + participation = AbstractParticipation.objects.get( + id=notification.data.get("participation_id") + ) + return _("Participation altered for {event}").format(event=participation.shift.event) + + def __init__(self, participation: AbstractParticipation, claims: List[str]): + super().__init__(participation) + self.claims = claims + + def get_data(self, **kwargs): + return super().get_data(claims=self.claims) + + @classmethod + def as_plaintext(cls, notification): + participation = AbstractParticipation.objects.get( + id=notification.data.get("participation_id") + ) + message = _("{participant} altered their participation in {shift}.").format( + participant=participation.participant, shift=participation.shift + ) + message += "\n\n" + "\n".join(f"- {claim}" for claim in notification.data.get("claims", [])) + return message class EventReminderNotification(AbstractNotificationHandler): @@ -437,7 +524,10 @@ def as_plaintext(cls, notification): NewProfileNotification, ParticipationRejectedNotification, ParticipationConfirmedNotification, - ResponsibleParticipationRequested, + ParticipationCustomizationNotification, + ResponsibleParticipationRequestedNotification, + ResponsibleConfirmedParticipationDeclinedNotification, + ResponsibleConfirmedParticipationCustomizedNotification, NewEventNotification, EventReminderNotification, CustomEventParticipantNotification, diff --git a/ephios/core/signals.py b/ephios/core/signals.py index aca1419ae..17b487d7b 100644 --- a/ephios/core/signals.py +++ b/ephios/core/signals.py @@ -16,6 +16,13 @@ subclasses of ``ephios.core.signup.methods.BaseSignupMethod``. """ +check_participant_signup = PluginSignal() +""" +This signal is sent out so receivers can prevent signup for a shift or provide feedback for dispatchers. +Receivers will receive a ``participant`` and ``method`` keyword argument and +should return an instance of the fitting! subclass of ``ephios.core.signup.methods.ParticipationError`` or None. +""" + footer_link = PluginSignal() """ This signal is sent out to get links for that page footer. Receivers should return a dict of diff --git a/ephios/core/signup/__init__.py b/ephios/core/signup/__init__.py index 4437b77c9..e69de29bb 100644 --- a/ephios/core/signup/__init__.py +++ b/ephios/core/signup/__init__.py @@ -1 +0,0 @@ -from .methods import * # no-qa diff --git a/ephios/core/signup/disposition.py b/ephios/core/signup/disposition.py index 634e26470..9a9919cd7 100644 --- a/ephios/core/signup/disposition.py +++ b/ephios/core/signup/disposition.py @@ -8,24 +8,36 @@ from django.views.generic.detail import SingleObjectMixin from django_select2.forms import ModelSelect2Widget -from ephios.core.models import AbstractParticipation, Qualification, Shift, UserProfile +from ephios.core.models import ( + AbstractParticipation, + LocalParticipation, + Qualification, + Shift, + UserProfile, +) +from ephios.core.services.notifications.types import ( + ParticipationConfirmedNotification, + ParticipationCustomizationNotification, + ParticipationRejectedNotification, +) +from ephios.core.signup.methods import BaseParticipationForm from ephios.extra.mixins import CustomPermissionRequiredMixin -class BaseDispositionParticipationForm(forms.ModelForm): +class BaseDispositionParticipationForm(BaseParticipationForm): disposition_participation_template = "core/disposition/fragment_participation.html" def __init__(self, **kwargs): super().__init__(**kwargs) self.can_delete = self.instance.state == AbstractParticipation.States.GETTING_DISPATCHED + self.fields["comment"].disabled = True try: self.shift = self.instance.shift except AttributeError as e: raise ValueError(f"{type(self)} must be initialized with an instance.") from e - class Meta: - model = AbstractParticipation - fields = ["state"] + class Meta(BaseParticipationForm.Meta): + fields = ["state", "individual_start_time", "individual_end_time", "comment"] widgets = dict(state=forms.HiddenInput(attrs={"class": "state-input"})) @@ -148,7 +160,7 @@ def get_template_names(self): def post(self, request, *args, **kwargs): shift = self.object - from ephios.core.signup import PlaceholderParticipant + from ephios.core.signup.participants import PlaceholderParticipant participant = PlaceholderParticipant( first_name=request.POST["first_name"], @@ -187,36 +199,38 @@ def get_formset(self): ) return formset - def post(self, request, *args, **kwargs): - formset = self.get_formset() - if formset.is_valid(): - formset.save() - - for participation, changed_fields in formset.changed_objects: - from ephios.core.models import LocalParticipation - - if "state" in changed_fields and ( - not participation.get_real_instance_class() == LocalParticipation - or participation.user != request.user - ): + def _send_participant_notifications(self, formset): + for participation, changed_fields in formset.changed_objects: + if ( + participation.get_real_instance_class() != LocalParticipation + or participation.user != self.request.user + ): + if "state" in changed_fields: + # send state updates if participation.state == AbstractParticipation.States.CONFIRMED: - from ephios.core.services.notifications.types import ( - ParticipationConfirmedNotification, - ) - ParticipationConfirmedNotification.send(participation) elif participation.state == AbstractParticipation.States.RESPONSIBLE_REJECTED: - from ephios.core.services.notifications.types import ( - ParticipationRejectedNotification, - ) - ParticipationRejectedNotification.send(participation) + elif participation.state == AbstractParticipation.States.CONFIRMED: + form: BaseParticipationForm = next( + filter(lambda f, p=participation: f.instance == p, formset.forms) + ) + if claims := form.get_customization_notification_info(): + # If state didn't change, but confirmed participation was customized, notify about that. + ParticipationCustomizationNotification.send(participation, claims) + + def post(self, request, *args, **kwargs): + formset = self.get_formset() + if not formset.is_valid(): + return self.get(request, *args, **kwargs, formset=formset) + + formset.save() + self._send_participant_notifications(formset) - self.object.participations.filter( - state=AbstractParticipation.States.GETTING_DISPATCHED - ).non_polymorphic().delete() - return redirect(self.object.event.get_absolute_url()) - return self.get(request, *args, **kwargs, formset=formset) + self.object.participations.filter( + state=AbstractParticipation.States.GETTING_DISPATCHED + ).non_polymorphic().delete() + return redirect(self.object.event.get_absolute_url()) def get_context_data(self, **kwargs): kwargs.setdefault("formset", self.get_formset()) diff --git a/ephios/core/signup/methods.py b/ephios/core/signup/methods.py index c4278627b..1c0af645c 100644 --- a/ephios/core/signup/methods.py +++ b/ephios/core/signup/methods.py @@ -3,32 +3,41 @@ import logging from argparse import Namespace from collections import OrderedDict -from datetime import date from typing import List, Optional +from crispy_forms.bootstrap import FormActions +from crispy_forms.helper import FormHelper +from crispy_forms.layout import HTML, Field, Layout from django import forms from django.contrib import messages -from django.contrib.auth import get_user_model from django.core.exceptions import PermissionDenied, ValidationError from django.db import transaction -from django.db.models import Q, QuerySet +from django.db.models import Q from django.shortcuts import redirect from django.template import Context, Template -from django.template.defaultfilters import yesno +from django.template.loader import get_template from django.urls import reverse -from django.utils import formats, timezone +from django.utils import timezone +from django.utils.formats import date_format from django.utils.functional import SimpleLazyObject, cached_property, classproperty -from django.utils.safestring import mark_safe +from django.utils.timezone import localtime from django.utils.translation import gettext_lazy as _ -from django.views import View - -from ephios.core.models import AbstractParticipation, LocalParticipation, Qualification, Shift +from django.views.generic import FormView + +from ephios.core.models import AbstractParticipation, Shift +from ephios.core.services.notifications.types import ( + ResponsibleConfirmedParticipationCustomizedNotification, + ResponsibleConfirmedParticipationDeclinedNotification, +) +from ephios.core.signals import ( + check_participant_signup, + participant_from_request, + register_signup_methods, +) +from ephios.core.signup.participants import AbstractParticipant +from ephios.extra.utils import format_anything from ephios.extra.widgets import CustomSplitDateTimeWidget -from ..models.events import PlaceholderParticipation -from ..signals import participant_from_request, register_signup_methods -from .disposition import BaseDispositionParticipationForm - logger = logging.getLogger(__name__) @@ -49,194 +58,286 @@ def signup_method_from_slug(slug, shift=None, event=None): raise ValueError(_("Signup Method '{slug}' was not found.").format(slug=slug)) -@dataclasses.dataclass(frozen=True) -class AbstractParticipant: - first_name: str - last_name: str - qualifications: QuerySet = dataclasses.field(hash=False) - date_of_birth: Optional[date] - email: Optional[str] # if set to None, no notifications are sent - - def get_age(self, today: date = None): - if self.date_of_birth is None: - return None - today, born = today or date.today(), self.date_of_birth - return today.year - born.year - ((today.month, today.day) < (born.month, born.day)) - - def __str__(self): - return f"{self.first_name} {self.last_name}" +class ParticipationError(ValidationError): + """Superclass of errors used in the signup mechanism.""" - def new_participation(self, shift): - raise NotImplementedError - def participation_for(self, shift): - """Return the participation object for a shift. Return None if it does not exist.""" - raise NotImplementedError +class ActionDisallowedError(ValidationError): + """Error to return if the participant cannot perform an action on a shift.""" - def all_participations(self): - """Return all participations for this participant""" - raise NotImplementedError - @functools.lru_cache - def collect_all_qualifications(self) -> set: - return Qualification.collect_all_included_qualifications(self.qualifications) +class SignupDisallowedError(ActionDisallowedError): + """ + Error to return if the participant cannot sign up for a shift, + even if formally fit, because of certain other circumstances. + """ - def has_qualifications(self, qualifications): - return set(qualifications) <= self.collect_all_qualifications() - def reverse_signup_action(self, shift): - raise NotImplementedError +class DeclineDisallowedError(ActionDisallowedError): + """Error to return if the participant cannot decline a shift.""" - def reverse_event_detail(self, event): - raise NotImplementedError - @property - def icon(self): - return mark_safe('') +class ParticipantUnfitError(ParticipationError): + """ + Error to return if the participant is unfit for a shift, + regardless of participation state or situation. + """ -@dataclasses.dataclass(frozen=True) -class LocalUserParticipant(AbstractParticipant): - user: get_user_model() +def prevent_getting_participant_from_request_user(request): + """ + To prevent plugin authors from accessing the participant using request.user, the SignupView + uses this method to block access to `as_participant` of the request.user attribute value. + """ + original_user = request.user - def new_participation(self, shift): - return LocalParticipation(shift=shift, user=self.user) + class ProtectedUser(SimpleLazyObject): + def as_participant(self): + raise Exception("Access of request.user.as_participant in SignupViews is not allowed.") - def participation_for(self, shift): - try: - return LocalParticipation.objects.get(shift=shift, user=self.user) - except LocalParticipation.DoesNotExist: - return None + request.user = ProtectedUser(lambda: original_user) - def all_participations(self): - return LocalParticipation.objects.filter(user=self.user) - def reverse_signup_action(self, shift): - return reverse("core:signup_action", kwargs=dict(pk=shift.pk)) +def get_nonlocal_participant_from_request(request): + for _, participant in participant_from_request.send(sender=None, request=request): + if participant is not None: + return participant + raise PermissionDenied - def reverse_event_detail(self, event): - return event.get_absolute_url() +class BaseParticipationForm(forms.ModelForm): + individual_start_time = forms.SplitDateTimeField( + label=_("Individual start time"), widget=CustomSplitDateTimeWidget, required=False + ) + individual_end_time = forms.SplitDateTimeField( + label=_("Individual end time"), + widget=CustomSplitDateTimeWidget, + required=False, + ) -@dataclasses.dataclass(frozen=True) -class PlaceholderParticipant(AbstractParticipant): - def new_participation(self, shift): - return PlaceholderParticipation( - shift=shift, first_name=self.first_name, last_name=self.last_name - ) + def clean_individual_start_time(self): + if self.cleaned_data["individual_start_time"] == self.shift.start_time: + return None + return self.cleaned_data["individual_start_time"] - def participation_for(self, shift): - try: - return PlaceholderParticipation.objects.get( - shift=shift, first_name=self.first_name, last_name=self.last_name - ) - except PlaceholderParticipation.DoesNotExist: + def clean_individual_end_time(self): + if self.cleaned_data["individual_end_time"] == self.shift.end_time: return None + return self.cleaned_data["individual_end_time"] + + def clean(self): + cleaned_data = super().clean() + start = cleaned_data["individual_start_time"] or self.shift.start_time + end = cleaned_data["individual_end_time"] or self.shift.end_time + if end < start: + self.add_error("individual_end_time", _("End time must not be before start time.")) + return cleaned_data + + class Meta: + model = AbstractParticipation + fields = ["individual_start_time", "individual_end_time", "comment"] + + def __init__(self, *args, **kwargs): + instance = kwargs["instance"] + self.shift = getattr(self, "shift", instance.shift) + kwargs["initial"] = { + **kwargs.get("initial", {}), + "individual_start_time": instance.individual_start_time or self.shift.start_time, + "individual_end_time": instance.individual_end_time or self.shift.end_time, + } + super().__init__(*args, **kwargs) + + def get_customization_notification_info(self): + """ + Return a list of human readable messages for changed participation attributes responsibles should be informed about. + This should not include the participation state, but customization aspects such as individual times, detailed disposition information, etc. + """ + assert self.is_valid() + info = [] + for time in ["start_time", "end_time"]: + if (field_name := f"individual_{time}") in self.changed_data: + info.append( + _("{label} was changed from {initial} to {current}.").format( + label=self.fields[field_name].label, + initial=date_format( + localtime(self.initial[field_name].astimezone()), + format="SHORT_DATETIME_FORMAT", + ), + current=date_format( + localtime(self.cleaned_data[field_name] or getattr(self.shift, time)), + format="TIME_FORMAT", + ), + ) + ) - def all_participations(self): - return AbstractParticipation.objects.none() + return info - def reverse_signup_action(self, shift): - raise NotImplementedError - def reverse_event_detail(self, event): - raise NotImplementedError +class BaseSignupForm(BaseParticipationForm): + def _get_field_layout(self): + return Layout(*(Field(name) for name in self.fields)) - @property - def icon(self): - return mark_safe( - f'' + def _get_buttons(self): + if ( + p := self.participant.participation_for(self.method.shift) + ) is not None and p.is_in_positive_state(): + buttons = [ + HTML( + f'' + ) + ] + else: + buttons = [ + HTML( + f'' + ) + ] + buttons.append( + HTML( + f'{_("Cancel")}' + ) + ) + if self.method.can_decline(self.participant): + buttons.append( + HTML( + f'' + ) + ) + return buttons + + def __init__(self, *args, **kwargs): + self.method = kwargs.pop("method") + self.targets_positive_state = kwargs.pop("targets_positive_state") + self.shift: Shift = self.method.shift + self.participant: AbstractParticipant = kwargs.pop("participant") + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.layout = Layout( + self._get_field_layout(), + FormActions(*self._get_buttons()), ) - -class ParticipationError(ValidationError): - pass - - -def prevent_getting_participant_from_request_user(request): - original_user = request.user - - class ProtectedUser(SimpleLazyObject): - def as_participant(self): - raise Exception("Access of request.user.as_participant in SignupViews is not allowed.") - - request.user = ProtectedUser(lambda: original_user) - - -def get_nonlocal_participant_from_session(request): - for _, participant in participant_from_request.send(sender=None, request=request): - if participant is not None: - return participant - raise PermissionDenied + if not self.shift.signup_method.configuration.user_can_customize_signup_times: + self.fields["individual_start_time"].disabled = True + self.fields["individual_end_time"].disabled = True + + def clean(self): + cleaned_data = super().clean() + self._validate_conflicting_participations(self, self.targets_positive_state) + return cleaned_data + + def _validate_conflicting_participations(self, form, targets_positive_state): + if not targets_positive_state: + return + if conflicts := get_conflicting_participations( + participant=form.instance.participant, + start_time=form.cleaned_data["individual_start_time"] or self.shift.start_time, + end_time=form.cleaned_data["individual_end_time"] or self.shift.end_time, + shift=self.shift, + total=False, + ): + form.add_error("individual_start_time", "") + form.add_error( + "individual_end_time", + _("You are already confirmed for other shifts at this time: {shifts}.").format( + shifts=", ".join(str(shift) for shift in conflicts) + ), + ) -class BaseSignupView(View): +class BaseSignupView(FormView): """ This View reacts to the signup or decline buttons being pressed using a POST request. - It can be modified to not directly create a participation, but show a form first, then - create the participation. - Beware that request.user might be anonymous. You should only act on participants acquired using `get_participant`. """ shift: Shift = ... method: "BaseSignupMethod" = ... + form_class = BaseSignupForm + template_name = "core/signup.html" + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs.update( + { + "method": self.method, + "participant": self.participant, + "instance": self.participation, + "targets_positive_state": self.request.POST.get("signup_choice") == "sign_up", + } + ) + return kwargs + + @cached_property + def participation(self): + return self.method.get_participation_for(self.participant) def setup(self, request, *args, **kwargs): super().setup(request, *args, **kwargs) self.participant: AbstractParticipant = kwargs["participant"] prevent_getting_participant_from_request_user(request) - def dispatch(self, request, *args, **kwargs): - if (choice := request.POST.get("signup_choice")) is not None: - if choice == "sign_up": - return self.signup_pressed() + def form_valid(self, form): + if (choice := self.request.POST.get("signup_choice")) is not None: + if choice == "sign_up" and self.method.can_sign_up(self.participant): + return self.signup_pressed(form) + if choice == "customize" and self.method.can_customize_signup(self.participant): + return self.customize_pressed(form) if choice == "decline": - return self.decline_pressed() - raise ValueError(_("'{choice}' is not a valid signup action.").format(choice=choice)) - return super().dispatch(request, *args, **kwargs) + return self.decline_pressed(form) + messages.error(self.request, _("This action is not allowed.")) + return redirect(self.participant.reverse_event_detail(self.shift.event)) + form.add_error(None, _("Form submission is missing the mode of signup.")) + return self.form_invalid(form) + + def customize_pressed(self, form): + form.save() + if claims := form.get_customization_notification_info(): + ResponsibleConfirmedParticipationCustomizedNotification(form.instance, claims).send() + messages.success(self.request, _("Your participation was saved.")) + return redirect(self.participant.reverse_event_detail(self.shift.event)) - def signup_pressed(self, **signup_kwargs): + def signup_pressed(self, form): try: with transaction.atomic(): - self.method.perform_signup(self.participant, **signup_kwargs) - messages.success( - self.request, - self.method.signup_success_message.format(shift=self.shift), - ) + participation = form.save() + self.method.perform_signup(self.participant, participation, **form.cleaned_data) except ParticipationError as errors: for error in errors: messages.error(self.request, self.method.signup_error_message.format(error=error)) + else: + messages.success( + self.request, + self.method.signup_success_message.format(shift=self.shift), + ) return redirect(self.participant.reverse_event_detail(self.shift.event)) - def decline_pressed(self, **decline_kwargs): + def decline_pressed(self, form): try: with transaction.atomic(): - self.method.perform_decline(self.participant, **decline_kwargs) - messages.info( - self.request, self.method.decline_success_message.format(shift=self.shift) - ) + participation = form.save() + self.method.perform_decline(self.participant, participation, **form.cleaned_data) except ParticipationError as errors: for error in errors: messages.error(self.request, self.method.decline_error_message.format(error=error)) + else: + messages.info( + self.request, self.method.decline_success_message.format(shift=self.shift) + ) return redirect(self.participant.reverse_event_detail(self.shift.event)) def check_event_is_active(method, participant): if not method.shift.event.active: - return ParticipationError(_("The event is not active.")) + return ActionDisallowedError(_("The event is not active.")) def check_participation_state_for_signup(method, participant): participation = participant.participation_for(method.shift) if participation is not None: - if participation.state == AbstractParticipation.States.REQUESTED: - return ParticipationError(_("You have already requested a participation.")) - if participation.state == AbstractParticipation.States.CONFIRMED: - return ParticipationError(_("You are already signed up.")) if participation.state == AbstractParticipation.States.RESPONSIBLE_REJECTED: - return ParticipationError(_("You have been rejected.")) + return SignupDisallowedError(_("You have been rejected.")) def check_participation_state_for_decline(method, participant): @@ -246,11 +347,11 @@ def check_participation_state_for_decline(method, participant): participation.state == AbstractParticipation.States.CONFIRMED and not method.configuration.user_can_decline_confirmed ): - return ParticipationError(_("You cannot decline by yourself.")) + return DeclineDisallowedError(_("You cannot decline by yourself.")) if participation.state == AbstractParticipation.States.RESPONSIBLE_REJECTED: - return ParticipationError(_("You have been rejected.")) + return DeclineDisallowedError(_("You have been rejected.")) if participation.state == AbstractParticipation.States.USER_DECLINED: - return ParticipationError(_("You have already declined participating.")) + return DeclineDisallowedError(_("You have already declined participating.")) def check_inside_signup_timeframe(method, participant): @@ -258,7 +359,7 @@ def check_inside_signup_timeframe(method, participant): if method.configuration.signup_until is not None: last_time = min(last_time, method.configuration.signup_until) if timezone.now() > last_time: - return ParticipationError(_("The signup period is over.")) + return ActionDisallowedError(_("The signup period is over.")) def check_participant_age(method, participant): @@ -266,22 +367,89 @@ def check_participant_age(method, participant): day = method.shift.start_time.date() age = participant.get_age(day) if minimum_age is not None and age is not None and age < minimum_age: - return ParticipationError( + return ParticipantUnfitError( _("You are too young. The minimum age is {age}.").format(age=minimum_age) ) -def get_conflicting_participations(shift, participant): - return participant.all_participations().filter( +def check_participant_signup_signal(method, participant): + errors = [] + for _, result in check_participant_signup.send(None, method=method, participant=participant): + if result is not None: + errors.append(result) + return errors + + +def get_conflicting_participations( + participant: AbstractParticipant, shift: Shift, start_time=None, end_time=None, total=False +): + """ + Return a queryset of participations of a participant in conflict with + a (potential) participation specified by the arguments. + + Parameters: + participant: AbstractParticipant to check conflict for + shift: conflicting participations for this shift + start_time, end_time: specify times other than the default times of `shift` + total (bool): If True, only return conflicts that can't be + resolved by only participating in only part of `shift`. + + Returns: + a queryset of participations + """ + start_time = start_time or shift.start_time + end_time = end_time or shift.end_time + qs = participant.all_participations().filter( ~Q(shift=shift) & Q(state=AbstractParticipation.States.CONFIRMED) - & Q(shift__start_time__lt=shift.end_time, shift__end_time__gt=shift.start_time) + & Q(start_time__lt=end_time, end_time__gt=start_time) ) + if total: + qs = qs.filter(start_time__lte=shift.start_time, end_time__gte=shift.end_time) + return qs + + +def check_conflicting_participations(method, participant): + start_time, end_time = method.shift.start_time, method.shift.end_time + if participation := participant.participation_for(method.shift): + start_time, end_time = participation.start_time, participation.end_time + + # if users can provide individual times, only total conflicts should block signup + total = getattr(method.configuration, "user_can_customize_signup_times", False) + if conflicts := get_conflicting_participations( + participant=participant, + shift=method.shift, + start_time=start_time, + end_time=end_time, + total=total, + ): + return SignupDisallowedError( + _("You are already confirmed for other shifts at this time: {shifts}.").format( + shifts=", ".join(str(shift) for shift in conflicts) + ) + ) -def check_conflicting_shifts(method, participant): - if get_conflicting_participations(method.shift, participant).exists(): - return ParticipationError(_("You are already confirmed for another shift at this time.")) +class BaseSignupMethodConfigurationForm(forms.Form): + minimum_age = forms.IntegerField( + required=False, min_value=1, max_value=999, initial=None, label=_("Minimum age") + ) + signup_until = forms.SplitDateTimeField( + required=False, + widget=CustomSplitDateTimeWidget, + initial=None, + label=_("Signup until"), + ) + user_can_decline_confirmed = forms.BooleanField( + label=_("Confirmed users can decline by themselves"), + required=False, + help_text=_("only if the signup timeframe has not ended"), + ) + user_can_customize_signup_times = forms.BooleanField( + label=_("Users can provide individual start and end times"), + required=False, + initial=True, + ) class BaseSignupMethod: @@ -301,16 +469,20 @@ def disposition_participation_form_class(self): This form will be used for participations in disposition. Set to None if you don't want to support the default disposition. """ + from .disposition import BaseDispositionParticipationForm + return BaseDispositionParticipationForm - @property - def configuration_form_class(self): - return forms.Form + configuration_form_class = BaseSignupMethodConfigurationForm @property def signup_view_class(self): return BaseSignupView + @property + def shift_state_template_name(self): + raise NotImplementedError + description = """""" # use _ == gettext_lazy! @@ -326,7 +498,10 @@ def __init__(self, shift, event=None): self.shift = shift self.event = getattr(shift, "event", event) self.configuration = Namespace( - **{name: config["default"] for name, config in self.get_configuration_fields().items()} + **{ + name: field.initial + for name, field in self.configuration_form_class.base_fields.items() + } ) if shift is not None: for key, value in shift.signup_configuration.items(): @@ -338,72 +513,127 @@ def signup_view(self): @property def _signup_checkers(self): + """Return a list of methods that check if the participant can sign up for the shift.""" return [ check_event_is_active, check_participation_state_for_signup, check_inside_signup_timeframe, + check_conflicting_participations, check_participant_age, - check_conflicting_shifts, + check_participant_signup_signal, ] @property def _decline_checkers(self): + """Return a list of methods that check if the participant can decline the shift.""" return [ check_event_is_active, check_participation_state_for_decline, check_inside_signup_timeframe, ] + def _run_checkers(self, participant, checkers) -> List[ParticipationError]: + errors = [] + for checker in checkers: + # checkers can return None, a single ParticipationError, or a list of them + if (error := checker(self, participant)) is not None: + if isinstance(error, list): + errors.extend(error) + else: + errors.append(error) + return errors + @functools.lru_cache() def get_signup_errors(self, participant) -> List[ParticipationError]: - return [ - error - for checker in self._signup_checkers - if (error := checker(self, participant)) is not None - ] + """Return a list of ParticipationErrors that describe reasons for not being able to sign up.""" + return self._run_checkers(participant, self._signup_checkers) @functools.lru_cache() def get_decline_errors(self, participant): - return [ - error - for checker in self._decline_checkers - if (error := checker(self, participant)) is not None - ] + """Return a list of ParticipationErrors that describe reasons for not being able to decline.""" + return self._run_checkers(participant, self._decline_checkers) + + @functools.lru_cache() + def get_participant_errors(self, participant) -> List[ParticipationError]: + """ + Return errors for whether the participant fulfills the requirements set by the signup methods for participation. + This runs a subset of the check for `get_signup_errors`. + """ + return list( + filter( + lambda error: isinstance(error, ParticipantUnfitError), + self.get_signup_errors(participant), + ) + ) def can_decline(self, participant): + """ + Return whether the participant is allowed to decline. + """ return not self.get_decline_errors(participant) def can_sign_up(self, participant): + """ + Return whether the participant is allowed to perform signup. + """ + signupable_state = ( + p := participant.participation_for(self.shift) + ) is None or not p.is_in_positive_state() + return signupable_state and not self.get_signup_errors(participant) + + def can_customize_signup(self, participant): + """ + Return whether the participant gets shown the option to customize their participation. + """ + positive_state = ( + p := participant.participation_for(self.shift) + ) is not None and p.is_in_positive_state() + + if positive_state: + # If in positive state, check that you can decline and then sign up again. + return self.can_decline(participant) and not self.get_signup_errors(participant) return not self.get_signup_errors(participant) - def get_participation_for(self, participant): + def has_customized_signup(self, participation): + """ + Return whether the participation was customized in a way specific to this signup method. + """ + # This method should most likely check the participation's data attribute for modifications it has done. + # 'Customized' in this context means that the dispositioning person should give special attention to this participation. + return False + + def get_participation_for(self, participant) -> AbstractParticipation: return participant.participation_for(self.shift) or participant.new_participation( self.shift ) - def perform_signup(self, participant: AbstractParticipant, **kwargs) -> AbstractParticipation: + def perform_signup( + self, participant: AbstractParticipant, participation=None, **kwargs + ) -> AbstractParticipation: """ Creates and/or configures a participation object for a given participant and sends out notifications. - Passes the participation and kwargs to configure_participation to do configuration specific to the signup method + Passes the participation and kwargs to configure_participation to do configuration specific to the signup method. """ - from ephios.core.services.notifications.types import ResponsibleParticipationRequested + from ephios.core.services.notifications.types import ( + ResponsibleParticipationRequestedNotification, + ) if errors := self.get_signup_errors(participant): raise ParticipationError(errors) - participation = self._configure_participation( - self.get_participation_for(participant), **kwargs - ) + participation = participation or self.get_participation_for(participant) + participation = self._configure_participation(participation, **kwargs) participation.save() - ResponsibleParticipationRequested.send(participation) + ResponsibleParticipationRequestedNotification(participation).send() return participation - def perform_decline(self, participant, **kwargs): + def perform_decline(self, participant, participation=None, **kwargs): """Create and configure a declining participation object for the given participant. `kwargs` may contain further instructions from a e.g. a form.""" if errors := self.get_decline_errors(participant): raise ParticipationError(errors) - participation = self.get_participation_for(participant) + participation = participation or self.get_participation_for(participant) participation.state = AbstractParticipation.States.USER_DECLINED participation.save() + ResponsibleConfirmedParticipationDeclinedNotification(participation).send() return participation def _configure_participation( @@ -411,52 +641,21 @@ def _configure_participation( ) -> AbstractParticipation: """ Configure the given participation object for signup according to the method's configuration. - You need at least to set the participations state. `kwargs` may contain further instructions from e.g. a form. + You need at least to set the participations state, as that is not done with the participation form. + `kwargs` contains the signup form's cleaned_data. """ raise NotImplementedError - def get_configuration_fields(self): - return OrderedDict( - { - "minimum_age": { - "formfield": forms.IntegerField(required=False, min_value=1, max_value=999), - "default": 16, - "publish_with_label": _("Minimum age"), - }, - "signup_until": { - "formfield": forms.SplitDateTimeField( - required=False, widget=CustomSplitDateTimeWidget - ), - "default": None, - "publish_with_label": _("Signup until"), - "format": functools.partial( - formats.date_format, format="SHORT_DATETIME_FORMAT" - ), - }, - "user_can_decline_confirmed": { - "formfield": forms.BooleanField( - label=_("Confirmed users can decline by themselves"), - required=False, - help_text=_("only if the signup timeframe has not ended"), - ), - "default": False, - "publish_with_label": _("Can decline after confirmation"), - "format": yesno, - }, - } - ) - def get_signup_info(self): """ Return key/value pairs about the configuration to show in exports etc. """ - fields = self.get_configuration_fields() + form_class = self.configuration_form_class return OrderedDict( { - label: field.get("format", str)(value) - for key, field in fields.items() - if (label := field.get("publish_with_label", False)) - and (value := getattr(self.configuration, key)) + label: getattr(form_class, f"format_{name}", format_anything)(value) + for name, field in form_class.base_fields.items() + if (label := field.label) and (value := getattr(self.configuration, name)) } ) @@ -488,7 +687,36 @@ def get_signup_stats(self) -> "SignupStats": max_count=max_count, ) - def render_shift_state(self, request): + def render(self, context): + """ + Render the state/participations of the shift. + Match the signature of template.render for use with the include template tag: + {% include shift.signup_method %} + By default, this loads `shift_state_template_name` and renders it using context from `get_shift_state_context_data`. + """ + with context.update(self.get_shift_state_context_data(context.request)): + return get_template(self.shift_state_template_name).template.render(context) + + def get_shift_state_context_data(self, request, **kwargs): + """ + Additionally to the context of the event detail view, provide context for rendering `shift_state_template_name`. + """ + kwargs["shift"] = self.shift + kwargs["participations"] = self.shift.participations.filter( + state__in={ + AbstractParticipation.States.REQUESTED, + AbstractParticipation.States.CONFIRMED, + } + ).order_by("-state") + if self.disposition_participation_form_class is not None: + kwargs["disposition_url"] = ( + reverse("core:shift_disposition", kwargs=dict(pk=self.shift.pk)) + if request.user.has_perm("core.change_event", obj=self.shift.event) + else None + ) + return kwargs + + def render_shift_state(self, context): """ Render html that will be shown in the shift info box. Use it to inform about the current state of the shift and participations. @@ -511,8 +739,6 @@ def get_configuration_form(self, *args, **kwargs): if self.shift is not None: kwargs.setdefault("initial", self.configuration.__dict__) form = self.configuration_form_class(*args, **kwargs) - for name, config in self.get_configuration_fields().items(): - form.fields[name] = config["formfield"] return form def render_configuration_form(self, *args, form=None, **kwargs): @@ -533,7 +759,7 @@ class SignupStats: max_count: Optional[int] # None means infinite max @classproperty - def ZERO(self): + def ZERO(cls): # pylint: disable=no-self-argument return SignupStats( requested_count=0, confirmed_count=0, diff --git a/ephios/core/signup/participants.py b/ephios/core/signup/participants.py new file mode 100644 index 000000000..daebe87d6 --- /dev/null +++ b/ephios/core/signup/participants.py @@ -0,0 +1,113 @@ +import dataclasses +import functools +from datetime import date +from typing import Optional + +from django.contrib.auth import get_user_model +from django.db.models import QuerySet +from django.urls import reverse +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ + +from ephios.core.models import AbstractParticipation, LocalParticipation, Qualification +from ephios.core.models.events import PlaceholderParticipation + + +@dataclasses.dataclass(frozen=True) +class AbstractParticipant: + first_name: str + last_name: str + qualifications: QuerySet = dataclasses.field(hash=False) + date_of_birth: Optional[date] + email: Optional[str] # if set to None, no notifications are sent + + def get_age(self, today: date = None): + if self.date_of_birth is None: + return None + today, born = today or date.today(), self.date_of_birth + return today.year - born.year - ((today.month, today.day) < (born.month, born.day)) + + def __str__(self): + return f"{self.first_name} {self.last_name}" + + def new_participation(self, shift): + raise NotImplementedError + + def participation_for(self, shift): + """Return the participation object for a shift. Return None if it does not exist.""" + raise NotImplementedError + + def all_participations(self): + """Return all participations for this participant""" + raise NotImplementedError + + @functools.lru_cache + def collect_all_qualifications(self) -> set: + return Qualification.collect_all_included_qualifications(self.qualifications) + + def has_qualifications(self, qualifications): + return set(qualifications) <= self.collect_all_qualifications() + + def reverse_signup_action(self, shift): + raise NotImplementedError + + def reverse_event_detail(self, event): + raise NotImplementedError + + @property + def icon(self): + return mark_safe('') + + +@dataclasses.dataclass(frozen=True) +class LocalUserParticipant(AbstractParticipant): + user: get_user_model() + + def new_participation(self, shift): + return LocalParticipation(shift=shift, user=self.user) + + def participation_for(self, shift): + try: + return LocalParticipation.objects.get(shift=shift, user=self.user) + except LocalParticipation.DoesNotExist: + return None + + def all_participations(self): + return LocalParticipation.objects.filter(user=self.user) + + def reverse_signup_action(self, shift): + return reverse("core:signup_action", kwargs=dict(pk=shift.pk)) + + def reverse_event_detail(self, event): + return event.get_absolute_url() + + +@dataclasses.dataclass(frozen=True) +class PlaceholderParticipant(AbstractParticipant): + def new_participation(self, shift): + return PlaceholderParticipation( + shift=shift, first_name=self.first_name, last_name=self.last_name + ) + + def participation_for(self, shift): + try: + return PlaceholderParticipation.objects.get( + shift=shift, first_name=self.first_name, last_name=self.last_name + ) + except PlaceholderParticipation.DoesNotExist: + return None + + def all_participations(self): + return AbstractParticipation.objects.none() + + def reverse_signup_action(self, shift): + raise NotImplementedError + + def reverse_event_detail(self, event): + raise NotImplementedError + + @property + def icon(self): + return mark_safe( + f'' + ) diff --git a/ephios/core/templates/core/disposition/base_participation.html b/ephios/core/templates/core/disposition/base_participation.html index 3fddb64c4..f5e4d3c58 100644 --- a/ephios/core/templates/core/disposition/base_participation.html +++ b/ephios/core/templates/core/disposition/base_participation.html @@ -1,5 +1,5 @@
- {% block body %} + {% block participation_body %} {% endblock %}
diff --git a/ephios/core/templates/core/disposition/disposition.html b/ephios/core/templates/core/disposition/disposition.html index a0530a731..d49d06d09 100644 --- a/ephios/core/templates/core/disposition/disposition.html +++ b/ephios/core/templates/core/disposition/disposition.html @@ -43,6 +43,7 @@
+ {% for form in formset %} {% if form.instance.state == states.GETTING_DISPATCHED %} {% include participant_template %} @@ -119,4 +120,5 @@
{{ states.USER_DECLINED.label|capfirst }}
+ {% endblock %} diff --git a/ephios/core/templates/core/disposition/fragment_participation.html b/ephios/core/templates/core/disposition/fragment_participation.html index 75c3a44fa..30978f8de 100644 --- a/ephios/core/templates/core/disposition/fragment_participation.html +++ b/ephios/core/templates/core/disposition/fragment_participation.html @@ -1,33 +1,62 @@ {% extends "core/disposition/base_participation.html" %} +{% load ephios_crispy %} +{% load crispy_forms_filters %} {% load user_extras %} {% load i18n %} -{% block body %} -
-
-
+{% block participation_body %} +
+
+
{{ form.instance.participant.icon }} {{ form.instance.participant }} - {% with form.instance.participant|conflicting_shifts:shift as conflicting_shifts %} - {% if conflicting_shifts %} - + {% with form.instance|conflicting_participations as conflicts %} + {% if conflicts %} + - {% endif %} - {% endwith %} - {% for qualification in form.instance.participant.qualifications|get_relevant_qualifications %} - {{ qualification }} - {% endfor %} -
-
- {% if form.can_delete %} - {% endif %} -
+ {% endwith %} + {% for qualification in form.instance.participant.qualifications|get_relevant_qualifications %} + {{ qualification }} + {% endfor %} + + {% block participation_detail %} + {% endblock %} +
+
+ {% block participation_controls %} + {% endblock %} + + {% if form.can_delete %} + + {% endif %} +
+
+
+
+ {{ form.individual_start_time|as_crispy_field }} + {{ form.individual_end_time|as_crispy_field }} + {% if form.comment.initial %} + {{ form.comment|as_crispy_field }} + {% endif %} + {% block participation_form %} + {% endblock %} +
-
+
{% endblock %} \ No newline at end of file diff --git a/ephios/core/templates/core/event_detail.html b/ephios/core/templates/core/event_detail.html index 999f28c5a..71dd6eae0 100644 --- a/ephios/core/templates/core/event_detail.html +++ b/ephios/core/templates/core/event_detail.html @@ -99,8 +99,8 @@
{% with start_time=event.get_start_time end_time=event.get_end_time %} {% if start_time %} - - {{ start_time|date:"D" }}, + + {{ start_time|date:"D" }}, {% if not event.is_multi_day %} {{ start_time|date:"SHORT_DATE_FORMAT" }} {{ start_time|date:"TIME_FORMAT" }} – @@ -110,7 +110,7 @@
{% translate "to" %} {{ end_time|date:"SHORT_DATE_FORMAT" }} {% endif %} - + {% endif %} {% endwith %}
diff --git a/ephios/core/templates/core/fragments/shift_box_big.html b/ephios/core/templates/core/fragments/shift_box_big.html index 94ea75079..9a3124654 100644 --- a/ephios/core/templates/core/fragments/shift_box_big.html +++ b/ephios/core/templates/core/fragments/shift_box_big.html @@ -1,25 +1,54 @@ -{% load static %} {% load event_extras %} +{% load event_extras %} +{% load static %} {% load i18n %} {% load humanize %}
- {% if not without_controls %} - {% with mannequin=request|shift_mannequin:shift %} -
- {{ mannequin }} mannequin icon -
- {% endwith %} - {% endif %} -
- {{ shift.start_time|date:"l" }}, {{ shift.start_time|date:"SHORT_DATE_FORMAT" }} -
- {{ shift.start_time|time }} - {{ shift.end_time|time }} - {{ shift.meeting_time|time }} {% translate "Meetup" %} -
- {% if not without_controls and "change_event" in event_perms %} + + {% setvar request|participation_from_request:shift as participation %} + {% with mannequin=participation|participation_mannequin_style style=participation|participation_css_style %} +
+ {{ mannequin }} mannequin icon +
+
+ + {% if participation.start_time|date:"SHORT_DATE_FORMAT" != shift.start_time|date:"SHORT_DATE_FORMAT" and participation.is_in_positive_state %} + + {{ participation.start_time|date:"l" }}, {{ participation.start_time|date:"SHORT_DATE_FORMAT" }} + + {% else %} + {{ shift.start_time|date:"l" }}, {{ shift.start_time|date:"SHORT_DATE_FORMAT" }} + {% endif %} + +
+ + {% if participation.individual_start_time and participation.is_in_positive_state %} + {{ participation.start_time|time }} + {% else %} + {{ shift.start_time|time }} + {% endif %} + - + {% if participation.individual_end_time and participation.is_in_positive_state %} + {{ participation.end_time|time }} + {% else %} + {{ shift.end_time|time }} + {% endif %} + + + + {{ shift.meeting_time|time }} {% translate "Meetup" %} + + +
+ {% endwith %} + + {% if "change_event" in event_perms %}
{% endif %}
+
- {% if not without_controls %} - {{ request|render_shift_state:shift }} - {% endif %} + {% include shift.signup_method with own_participation=participation %}
+