From 62ebc57b7f67e59a5e8e0beffe63da73e3b9dce0 Mon Sep 17 00:00:00 2001 From: Felix Rindt Date: Sun, 3 Oct 2021 17:40:17 +0200 Subject: [PATCH 01/36] add individual startend time fields --- .../migrations/0013_auto_20211003_1734.py | 32 +++++++++++++++++++ ephios/core/models/events.py | 22 +++++++++++++ ...lificationconfiguration_expiration_date.py | 22 +++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 ephios/core/migrations/0013_auto_20211003_1734.py create mode 100644 ephios/plugins/eventautoqualification/migrations/0004_alter_eventautoqualificationconfiguration_expiration_date.py diff --git a/ephios/core/migrations/0013_auto_20211003_1734.py b/ephios/core/migrations/0013_auto_20211003_1734.py new file mode 100644 index 000000000..4a22584ee --- /dev/null +++ b/ephios/core/migrations/0013_auto_20211003_1734.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.7 on 2021-10-03 15:34 + +from django.db import migrations, models + +import ephios.core.models.users + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0012_rename_included_qualifications_qualification_includes"), + ] + + operations = [ + migrations.AddField( + model_name="abstractparticipation", + name="individual_end_time", + field=models.DateTimeField(null=True, verbose_name="end time"), + ), + migrations.AddField( + model_name="abstractparticipation", + name="individual_start_time", + field=models.DateTimeField(null=True, verbose_name="start time"), + ), + migrations.AlterField( + model_name="qualificationgrant", + name="expires", + field=ephios.core.models.users.ExpirationDateField( + blank=True, null=True, verbose_name="expiration date" + ), + ), + ] diff --git a/ephios/core/models/events.py b/ephios/core/models/events.py index 9fc3df9c6..1ec6cec96 100644 --- a/ephios/core/models/events.py +++ b/ephios/core/models/events.py @@ -18,11 +18,13 @@ SlugField, TextField, ) +from django.db.models.functions import Coalesce from django.utils import formats from django.utils.text import slugify 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 @@ -138,6 +140,18 @@ def activate(self): register_model_for_logging(Event, ModelFieldsLogConfig()) +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 AbstractParticipation(PolymorphicModel): class States(models.IntegerChoices): REQUESTED = 0, _("requested") @@ -156,12 +170,20 @@ def labels_dict(cls): state = IntegerField(_("state"), choices=States.choices) 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(_("start time"), null=True) + individual_end_time = DateTimeField(_("end time"), null=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() + @property def hours_value(self): td = self.shift.end_time - self.shift.start_time diff --git a/ephios/plugins/eventautoqualification/migrations/0004_alter_eventautoqualificationconfiguration_expiration_date.py b/ephios/plugins/eventautoqualification/migrations/0004_alter_eventautoqualificationconfiguration_expiration_date.py new file mode 100644 index 000000000..b45ef148b --- /dev/null +++ b/ephios/plugins/eventautoqualification/migrations/0004_alter_eventautoqualificationconfiguration_expiration_date.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.7 on 2021-10-03 15:34 + +from django.db import migrations + +import ephios.core.models.users + + +class Migration(migrations.Migration): + + dependencies = [ + ("eventautoqualification", "0003_alter_eventautoqualificationconfiguration_id"), + ] + + operations = [ + migrations.AlterField( + model_name="eventautoqualificationconfiguration", + name="expiration_date", + field=ephios.core.models.users.ExpirationDateField( + blank=True, null=True, verbose_name="Expiration date" + ), + ), + ] From 235583fbd88d1e5334fe5de1dbe55d335b93a953 Mon Sep 17 00:00:00 2001 From: Felix Rindt Date: Sun, 3 Oct 2021 18:38:29 +0200 Subject: [PATCH 02/36] save start_time in perform_signup --- ephios/core/signup/methods.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/ephios/core/signup/methods.py b/ephios/core/signup/methods.py index c4278627b..5d80c890b 100644 --- a/ephios/core/signup/methods.py +++ b/ephios/core/signup/methods.py @@ -390,9 +390,13 @@ def perform_signup(self, participant: AbstractParticipant, **kwargs) -> Abstract if errors := self.get_signup_errors(participant): raise ParticipationError(errors) - participation = self._configure_participation( - self.get_participation_for(participant), **kwargs - ) + participation = self.get_participation_for(participant) + + for attr in ("start_time", "end_time"): + if attr in kwargs: + setattr(participation, f"individual_{attr}", kwargs.pop(attr)) + + participation = self._configure_participation(participation, **kwargs) participation.save() ResponsibleParticipationRequested.send(participation) return participation @@ -533,7 +537,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, From 70fad8329ccce5106709f1e3f3e07f980d404450 Mon Sep 17 00:00:00 2001 From: Felix Rindt Date: Sat, 9 Oct 2021 14:36:27 +0200 Subject: [PATCH 03/36] implement signup logic --- ephios/core/forms/events.py | 2 +- ...emove_eventtype_can_grant_qualification.py | 17 ++++ ephios/core/models/events.py | 1 - ephios/core/signup/methods.py | 78 +++++++++++++++---- .../core/fragments/shift_box_big.html | 14 +++- ephios/core/templates/core/signup.html | 16 ++++ ephios/core/urls.py | 2 +- ephios/core/views/eventtype.py | 4 +- ephios/extra/management/commands/devdata.py | 4 +- .../basesignup/signup/section_based.py | 3 +- tests/conftest.py | 2 +- 11 files changed, 114 insertions(+), 29 deletions(-) create mode 100644 ephios/core/migrations/0014_remove_eventtype_can_grant_qualification.py create mode 100644 ephios/core/templates/core/signup.html diff --git a/ephios/core/forms/events.py b/ephios/core/forms/events.py index 2234141eb..1d1bd612a 100644 --- a/ephios/core/forms/events.py +++ b/ephios/core/forms/events.py @@ -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/migrations/0014_remove_eventtype_can_grant_qualification.py b/ephios/core/migrations/0014_remove_eventtype_can_grant_qualification.py new file mode 100644 index 000000000..fe4d0ed71 --- /dev/null +++ b/ephios/core/migrations/0014_remove_eventtype_can_grant_qualification.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.7 on 2021-10-05 10:34 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0013_auto_20211003_1734"), + ] + + operations = [ + migrations.RemoveField( + model_name="eventtype", + name="can_grant_qualification", + ), + ] diff --git a/ephios/core/models/events.py b/ephios/core/models/events.py index 1ec6cec96..a0b6a056d 100644 --- a/ephios/core/models/events.py +++ b/ephios/core/models/events.py @@ -43,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: diff --git a/ephios/core/signup/methods.py b/ephios/core/signup/methods.py index 5d80c890b..ee49ee164 100644 --- a/ephios/core/signup/methods.py +++ b/ephios/core/signup/methods.py @@ -6,6 +6,9 @@ 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 @@ -20,7 +23,7 @@ from django.utils.functional import SimpleLazyObject, cached_property, classproperty from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ -from django.views import View +from django.views.generic import FormView from ephios.core.models import AbstractParticipation, LocalParticipation, Qualification, Shift from ephios.extra.widgets import CustomSplitDateTimeWidget @@ -170,37 +173,82 @@ def get_nonlocal_participant_from_session(request): raise PermissionDenied -class BaseSignupView(View): +class BaseSignupForm(forms.Form): + start_time = forms.SplitDateTimeField( + label=_("Start time"), widget=CustomSplitDateTimeWidget, required=False + ) + end_time = forms.SplitDateTimeField( + label=_("End time"), + widget=CustomSplitDateTimeWidget, + required=False, + ) + comment = forms.CharField(label=_("Comment"), required=False) + + def get_field_layout(self): + return Layout(*(Field(name) for name in self.fields)) + + def __init__(self, *args, **kwargs): + self.method = kwargs.pop("method") + self.participant: AbstractParticipant = kwargs.pop("participant") + super().__init__(*args, **kwargs) + + self.helper = FormHelper() + self.helper.layout = Layout( + self.get_field_layout(), + FormActions( + HTML( + f'' + ), + HTML( + f'{_("Cancel")}' + ), + HTML( + f'' + ), + ), + ) + + +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_initial(self): + return { + "start_time": self.shift.start_time, + "end_time": self.shift.end_time, + } + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs.update({"method": self.method, "participant": self.participant}) + return kwargs 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: + def form_valid(self, form): + if (choice := self.request.POST.get("signup_choice")) is not None: if choice == "sign_up": - return self.signup_pressed() + return self.signup_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) + return self.form_invalid(form) - def signup_pressed(self, **signup_kwargs): + def signup_pressed(self, form): try: with transaction.atomic(): - self.method.perform_signup(self.participant, **signup_kwargs) + self.method.perform_signup(self.participant, **form.cleaned_data) messages.success( self.request, self.method.signup_success_message.format(shift=self.shift), @@ -210,10 +258,10 @@ def signup_pressed(self, **signup_kwargs): messages.error(self.request, self.method.signup_error_message.format(error=error)) 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) + self.method.perform_decline(self.participant, **form.cleaned_data) messages.info( self.request, self.method.decline_success_message.format(shift=self.shift) ) diff --git a/ephios/core/templates/core/fragments/shift_box_big.html b/ephios/core/templates/core/fragments/shift_box_big.html index 94ea75079..b218b3f37 100644 --- a/ephios/core/templates/core/fragments/shift_box_big.html +++ b/ephios/core/templates/core/fragments/shift_box_big.html @@ -64,11 +64,17 @@ {% endif %} {% if can_sign_up %} - - + - + + {% endif %} {% if can_decline %} diff --git a/ephios/core/templates/core/signup.html b/ephios/core/templates/core/signup.html new file mode 100644 index 000000000..5c18e480d --- /dev/null +++ b/ephios/core/templates/core/signup.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% load crispy_forms_tags %} +{% load i18n %} +{% load static %} + +{% block title %} + {% translate "Signup" %} +{% endblock %} + +{% block content %} + + + {% crispy form %} +{% endblock %} diff --git a/ephios/core/urls.py b/ephios/core/urls.py index e478cf289..8a2250e68 100644 --- a/ephios/core/urls.py +++ b/ephios/core/urls.py @@ -110,7 +110,7 @@ name="event_bulk_delete", ), path( - "shifts//signup-action/", + "shifts//signup/", LocalUserShiftActionView.as_view(), name="signup_action", ), diff --git a/ephios/core/views/eventtype.py b/ephios/core/views/eventtype.py index 42df5a2e4..a479de498 100644 --- a/ephios/core/views/eventtype.py +++ b/ephios/core/views/eventtype.py @@ -74,9 +74,9 @@ class EventTypeCreateView( accept_object_perms = False template_name = "core/eventtype_form.html" model = EventType - fields = ["title", "can_grant_qualification"] + fields = ["title"] success_message = _( - "Eventtype succesfully created. More settings for this eventtype can be managed below. " + "Event type was created. More settings for this event type can be managed below." ) def get_success_url(self): diff --git a/ephios/extra/management/commands/devdata.py b/ephios/extra/management/commands/devdata.py index 9a11d44d7..ba2c67422 100644 --- a/ephios/extra/management/commands/devdata.py +++ b/ephios/extra/management/commands/devdata.py @@ -49,8 +49,8 @@ def create_objects(): assign_perm("auth.change_group", managers) assign_perm("auth.delete_group", managers) - service_type = EventType.objects.create(title=_("Service"), can_grant_qualification=False) - EventType.objects.create(title=_("Training"), can_grant_qualification=True) + service_type = EventType.objects.create(title=_("Service")) + EventType.objects.create(title=_("Training")) user = UserProfile( email="user@localhost", diff --git a/ephios/plugins/basesignup/signup/section_based.py b/ephios/plugins/basesignup/signup/section_based.py index 4872c847f..6c659b236 100644 --- a/ephios/plugins/basesignup/signup/section_based.py +++ b/ephios/plugins/basesignup/signup/section_based.py @@ -11,7 +11,6 @@ from django.template.loader import get_template from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from django.views.generic import FormView from django_select2.forms import Select2MultipleWidget from dynamic_preferences.registries import global_preferences_registry @@ -152,7 +151,7 @@ class SectionSignupForm(forms.Form): ) -class SectionBasedSignupView(FormView, BaseSignupView): +class SectionBasedSignupView(BaseSignupView): template_name = "basesignup/section_based/signup.html" @cached_property diff --git a/tests/conftest.py b/tests/conftest.py index e2b934841..471bdda9c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -120,7 +120,7 @@ def responsible_user(): @pytest.fixture def service_event_type(): - return EventType.objects.create(title="Service", can_grant_qualification=False) + return EventType.objects.create(title="Service") @pytest.fixture From a4884278687a3b5a3baa0191ad5f4cd5d4054537 Mon Sep 17 00:00:00 2001 From: Felix Rindt Date: Sat, 9 Oct 2021 17:00:42 +0200 Subject: [PATCH 04/36] refactor to participants.py --- ephios/core/forms/events.py | 4 +- ephios/core/models/events.py | 9 +- ephios/core/models/users.py | 2 +- ephios/core/services/notifications/types.py | 2 +- ephios/core/signup/__init__.py | 1 - ephios/core/signup/disposition.py | 2 +- ephios/core/signup/methods.py | 124 ++---------------- ephios/core/signup/participants.py | 113 ++++++++++++++++ ephios/core/templatetags/user_extras.py | 2 +- ephios/core/views/shift.py | 4 +- ephios/core/views/signup.py | 4 +- ephios/plugins/basesignup/signals.py | 2 +- ephios/plugins/basesignup/signup/common.py | 2 +- .../basesignup/signup/coupled_signup.py | 2 +- .../basesignup/signup/no_selfservice.py | 2 +- .../basesignup/signup/section_based.py | 4 +- .../plugins/eventautoqualification/models.py | 3 +- ephios/plugins/guests/models.py | 4 +- tests/core/test_event_form.py | 2 +- tests/core/test_signup_methods.py | 4 +- 20 files changed, 150 insertions(+), 142 deletions(-) create mode 100644 ephios/core/signup/participants.py diff --git a/ephios/core/forms/events.py b/ephios/core/forms/events.py index 1d1bd612a..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) diff --git a/ephios/core/models/events.py b/ephios/core/models/events.py index a0b6a056d..9beda94e7 100644 --- a/ephios/core/models/events.py +++ b/ephios/core/models/events.py @@ -33,7 +33,8 @@ if TYPE_CHECKING: from ephios.core.models import UserProfile - from ephios.core.signup import AbstractParticipant, SignupStats + from ephios.core.signup import SignupStats + from ephios.core.signup.participants import AbstractParticipant class ActiveManager(Manager): @@ -103,7 +104,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 @@ -228,7 +229,7 @@ class Meta: @property def signup_method(self): - from ephios.core.signup import signup_method_from_slug + from ephios.core.signup.methods import signup_method_from_slug try: return signup_method_from_slug(self.signup_method_slug, self) @@ -310,7 +311,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..797b33bcd 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, diff --git a/ephios/core/services/notifications/types.py b/ephios/core/services/notifications/types.py index 37894e7d3..c5552e828 100644 --- a/ephios/core/services/notifications/types.py +++ b/ephios/core/services/notifications/types.py @@ -13,7 +13,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(): 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..0def6daf5 100644 --- a/ephios/core/signup/disposition.py +++ b/ephios/core/signup/disposition.py @@ -148,7 +148,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"], diff --git a/ephios/core/signup/methods.py b/ephios/core/signup/methods.py index ee49ee164..32f20249c 100644 --- a/ephios/core/signup/methods.py +++ b/ephios/core/signup/methods.py @@ -3,7 +3,6 @@ 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 @@ -11,26 +10,23 @@ 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.urls import reverse from django.utils import formats, timezone from django.utils.functional import SimpleLazyObject, cached_property, classproperty -from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from django.views.generic import FormView -from ephios.core.models import AbstractParticipation, LocalParticipation, Qualification, Shift +from ephios.core.models import AbstractParticipation, Shift from ephios.extra.widgets import CustomSplitDateTimeWidget -from ..models.events import PlaceholderParticipation from ..signals import participant_from_request, register_signup_methods from .disposition import BaseDispositionParticipationForm +from .participants import AbstractParticipant logger = logging.getLogger(__name__) @@ -52,106 +48,6 @@ 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}" - - 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'' - ) - - class ParticipationError(ValidationError): pass @@ -166,7 +62,7 @@ def as_participant(self): request.user = ProtectedUser(lambda: original_user) -def get_nonlocal_participant_from_session(request): +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 @@ -240,15 +136,15 @@ def setup(self, request, *args, **kwargs): def form_valid(self, form): if (choice := self.request.POST.get("signup_choice")) is not None: if choice == "sign_up": - return self.signup_pressed(form) + return self.signup_pressed(**form.cleaned_data) if choice == "decline": - return self.decline_pressed(form) + return self.decline_pressed(**form.cleaned_data) return self.form_invalid(form) - def signup_pressed(self, form): + def signup_pressed(self, **signup_kwargs): try: with transaction.atomic(): - self.method.perform_signup(self.participant, **form.cleaned_data) + self.method.perform_signup(self.participant, **signup_kwargs) messages.success( self.request, self.method.signup_success_message.format(shift=self.shift), @@ -258,10 +154,10 @@ def signup_pressed(self, form): messages.error(self.request, self.method.signup_error_message.format(error=error)) return redirect(self.participant.reverse_event_detail(self.shift.event)) - def decline_pressed(self, form): + def decline_pressed(self, **signup_kwargs): try: with transaction.atomic(): - self.method.perform_decline(self.participant, **form.cleaned_data) + self.method.perform_decline(self.participant, **signup_kwargs) messages.info( self.request, self.method.decline_success_message.format(shift=self.shift) ) 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/templatetags/user_extras.py b/ephios/core/templatetags/user_extras.py index c60d53f76..5695c1e0f 100644 --- a/ephios/core/templatetags/user_extras.py +++ b/ephios/core/templatetags/user_extras.py @@ -6,7 +6,7 @@ from ephios.core.consequences import editable_consequences, pending_consequences from ephios.core.models import AbstractParticipation, Shift -from ephios.core.signup import get_conflicting_participations +from ephios.core.signup.methods import get_conflicting_participations register = template.Library() diff --git a/ephios/core/views/shift.py b/ephios/core/views/shift.py index 4f3e0f284..d1045252d 100644 --- a/ephios/core/views/shift.py +++ b/ephios/core/views/shift.py @@ -37,9 +37,9 @@ def get_context_data(self, **kwargs): def post(self, *args, **kwargs): form = self.get_shift_form() - try: - from ephios.core.signup import signup_method_from_slug + from ephios.core.signup.methods import signup_method_from_slug + try: signup_method = signup_method_from_slug( self.request.POST["signup_method_slug"], event=self.event ) diff --git a/ephios/core/views/signup.py b/ephios/core/views/signup.py index 4b40268c9..68acffc55 100644 --- a/ephios/core/views/signup.py +++ b/ephios/core/views/signup.py @@ -2,14 +2,14 @@ from django.views.generic.detail import SingleObjectMixin from ephios.core.models import Shift -from ephios.core.signup import get_nonlocal_participant_from_session +from ephios.core.signup.methods import get_nonlocal_participant_from_request from ephios.extra.mixins import CustomPermissionRequiredMixin def request_to_participant(request): if request.user.is_authenticated: return request.user.as_participant() - return get_nonlocal_participant_from_session(request) + return get_nonlocal_participant_from_request(request) class BaseShiftActionView(SingleObjectMixin, View): diff --git a/ephios/plugins/basesignup/signals.py b/ephios/plugins/basesignup/signals.py index a66996798..60229cbc0 100644 --- a/ephios/plugins/basesignup/signals.py +++ b/ephios/plugins/basesignup/signals.py @@ -1,6 +1,6 @@ from django.dispatch import receiver -from ephios.core.signup import register_signup_methods +from ephios.core.signup.methods import register_signup_methods from ephios.plugins.basesignup.signup.coupled_signup import CoupledSignupMethod from ephios.plugins.basesignup.signup.instant import InstantConfirmationSignupMethod from ephios.plugins.basesignup.signup.no_selfservice import NoSelfserviceSignupMethod diff --git a/ephios/plugins/basesignup/signup/common.py b/ephios/plugins/basesignup/signup/common.py index d3f6b4cee..1f01d0d75 100644 --- a/ephios/plugins/basesignup/signup/common.py +++ b/ephios/plugins/basesignup/signup/common.py @@ -9,7 +9,7 @@ from dynamic_preferences.registries import global_preferences_registry from ephios.core.models import AbstractParticipation, Qualification -from ephios.core.signup import BaseSignupMethod, ParticipationError +from ephios.core.signup.methods import BaseSignupMethod, ParticipationError _Base = BaseSignupMethod if typing.TYPE_CHECKING else object diff --git a/ephios/plugins/basesignup/signup/coupled_signup.py b/ephios/plugins/basesignup/signup/coupled_signup.py index b36d2b672..98bd9b444 100644 --- a/ephios/plugins/basesignup/signup/coupled_signup.py +++ b/ephios/plugins/basesignup/signup/coupled_signup.py @@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _ from ephios.core.models import AbstractParticipation, Shift -from ephios.core.signup import BaseSignupMethod, ParticipationError +from ephios.core.signup.methods import BaseSignupMethod, ParticipationError from ephios.plugins.basesignup.signup.common import render_basic_participation_pills_shift_state diff --git a/ephios/plugins/basesignup/signup/no_selfservice.py b/ephios/plugins/basesignup/signup/no_selfservice.py index c73f02c81..884c35239 100644 --- a/ephios/plugins/basesignup/signup/no_selfservice.py +++ b/ephios/plugins/basesignup/signup/no_selfservice.py @@ -2,7 +2,7 @@ from django.utils.translation import gettext_lazy as _ from ephios.core.models import AbstractParticipation -from ephios.core.signup import BaseSignupMethod, ParticipationError +from ephios.core.signup.methods import BaseSignupMethod, ParticipationError from ephios.plugins.basesignup.signup.common import render_basic_participation_pills_shift_state diff --git a/ephios/plugins/basesignup/signup/section_based.py b/ephios/plugins/basesignup/signup/section_based.py index 6c659b236..cf1cd855c 100644 --- a/ephios/plugins/basesignup/signup/section_based.py +++ b/ephios/plugins/basesignup/signup/section_based.py @@ -15,13 +15,13 @@ from dynamic_preferences.registries import global_preferences_registry from ephios.core.models import AbstractParticipation, Qualification -from ephios.core.signup import ( - AbstractParticipant, +from ephios.core.signup.methods import ( BaseDispositionParticipationForm, BaseSignupMethod, BaseSignupView, ParticipationError, ) +from ephios.core.signup.participants import AbstractParticipant NO_SECTION_UUID = "other" diff --git a/ephios/plugins/eventautoqualification/models.py b/ephios/plugins/eventautoqualification/models.py index 7ba6832a9..d05b8e420 100644 --- a/ephios/plugins/eventautoqualification/models.py +++ b/ephios/plugins/eventautoqualification/models.py @@ -1,9 +1,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ -from ephios.core.models import Event +from ephios.core.models import Event, Qualification from ephios.core.models.users import ExpirationDateField -from ephios.core.signup import Qualification from ephios.modellogging.log import ModelFieldsLogConfig, register_model_for_logging diff --git a/ephios/plugins/guests/models.py b/ephios/plugins/guests/models.py index c3a36b477..f1fb3f530 100644 --- a/ephios/plugins/guests/models.py +++ b/ephios/plugins/guests/models.py @@ -7,9 +7,9 @@ from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ -from ephios.core.models import AbstractParticipation, Event +from ephios.core.models import AbstractParticipation, Event, Qualification from ephios.core.models.events import PARTICIPATION_LOG_CONFIG -from ephios.core.signup import AbstractParticipant, Qualification +from ephios.core.signup.participants import AbstractParticipant from ephios.modellogging.log import register_model_for_logging diff --git a/tests/core/test_event_form.py b/tests/core/test_event_form.py index 3ca1735b8..9e7c26b92 100644 --- a/tests/core/test_event_form.py +++ b/tests/core/test_event_form.py @@ -3,7 +3,7 @@ from django.urls import reverse from guardian.shortcuts import get_users_with_perms -from ephios.core.signup import AbstractParticipation, LocalParticipation +from ephios.core.models import AbstractParticipation, LocalParticipation from ephios.extra.permissions import get_groups_with_perms diff --git a/tests/core/test_signup_methods.py b/tests/core/test_signup_methods.py index 8e18b1fe8..b0d88abe2 100644 --- a/tests/core/test_signup_methods.py +++ b/tests/core/test_signup_methods.py @@ -4,8 +4,8 @@ import pytest from django.utils.timezone import make_aware -from ephios.core.models import AbstractParticipation, Shift -from ephios.core.signup import LocalParticipation, SignupStats, get_conflicting_participations +from ephios.core.models import AbstractParticipation, LocalParticipation, Shift +from ephios.core.signup.methods import SignupStats, get_conflicting_participations from ephios.plugins.basesignup.signup.instant import InstantConfirmationSignupMethod From 9cfe343b389410dd57f17ec761f8300826e29058 Mon Sep 17 00:00:00 2001 From: Felix Rindt Date: Sun, 10 Oct 2021 16:23:10 +0200 Subject: [PATCH 05/36] let participants customize participation after signup --- ephios/core/signup/methods.py | 72 ++++++++++++++----- .../core/fragments/shift_box_big.html | 16 +++-- ephios/core/templatetags/event_extras.py | 5 ++ ephios/core/views/shift.py | 4 +- .../basesignup/test_requestconfirm_signup.py | 2 +- 5 files changed, 75 insertions(+), 24 deletions(-) diff --git a/ephios/core/signup/methods.py b/ephios/core/signup/methods.py index 32f20249c..6c6825eb1 100644 --- a/ephios/core/signup/methods.py +++ b/ephios/core/signup/methods.py @@ -83,25 +83,41 @@ class BaseSignupForm(forms.Form): def get_field_layout(self): return Layout(*(Field(name) for name in self.fields)) + def _get_main_submit_label(self): + if (p := self.participant.participation_for(self.method.shift)) is not None and p.state in ( + AbstractParticipation.States.REQUESTED, + AbstractParticipation.States.CONFIRMED, + ): + return _("Save") + return self.method.registration_button_text + + def _get_buttons(self): + buttons = [ + HTML( + f'' + ), + 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.participant: AbstractParticipant = kwargs.pop("participant") super().__init__(*args, **kwargs) self.helper = FormHelper() + self.helper.layout = Layout( self.get_field_layout(), - FormActions( - HTML( - f'' - ), - HTML( - f'{_("Cancel")}' - ), - HTML( - f'' - ), - ), + FormActions(*self._get_buttons()), ) @@ -175,10 +191,6 @@ def check_event_is_active(method, participant): 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.")) @@ -224,7 +236,7 @@ def get_conflicting_participations(shift, participant): def check_conflicting_shifts(method, participant): - if get_conflicting_participations(method.shift, participant).exists(): + if get_conflicting_participations(method.shift, participant).exists(): # TODO return ParticipationError(_("You are already confirmed for another shift at this time.")) @@ -315,10 +327,36 @@ def get_decline_errors(self, 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 not self.get_signup_errors(participant) + """ + Return whether the participant is allowed to perform signup. + Note that this should also return True for participants allowed to customize their participation. + """ + valid_state = (p := participant.participation_for(self.shift)) is None or p.state not in ( + AbstractParticipation.States.CONFIRMED, + AbstractParticipation.States.REQUESTED, + ) + return valid_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. + """ + # We check for decline as well in case the participation is already requested/confirmed. + declineable_state = ( + p := participant.participation_for(self.shift) + ) is not None and p.state in ( + AbstractParticipation.States.CONFIRMED, + AbstractParticipation.States.REQUESTED, + ) + return not self.get_signup_errors(participant) and ( + not declineable_state or self.can_decline(participant) + ) def get_participation_for(self, participant): return participant.participation_for(self.shift) or participant.new_participation( diff --git a/ephios/core/templates/core/fragments/shift_box_big.html b/ephios/core/templates/core/fragments/shift_box_big.html index b218b3f37..1132cd829 100644 --- a/ephios/core/templates/core/fragments/shift_box_big.html +++ b/ephios/core/templates/core/fragments/shift_box_big.html @@ -44,7 +44,7 @@ {% endif %} - {% if can_sign_up %} + {% if can_sign_up and can_customize_signup %}
- +
+ {% elif can_sign_up and not can_customize_signup %} + + {% elif not can_sign_up and can_customize_signup %} + {% translate "Customize" %} {% endif %} {% if can_decline %} diff --git a/ephios/core/templatetags/event_extras.py b/ephios/core/templatetags/event_extras.py index 7707b0311..9a42d4ea5 100644 --- a/ephios/core/templatetags/event_extras.py +++ b/ephios/core/templatetags/event_extras.py @@ -38,6 +38,11 @@ def can_sign_up(request, shift): return shift.signup_method.can_sign_up(request_to_participant(request)) +@register.filter(name="can_customize_signup") +def can_sign_up(request, shift): + return shift.signup_method.can_customize_signup(request_to_participant(request)) + + @register.filter(name="render_shift_state") def render_shift_state(request, shift): return shift.signup_method.render_shift_state(request) diff --git a/ephios/core/views/shift.py b/ephios/core/views/shift.py index d1045252d..9e386a759 100644 --- a/ephios/core/views/shift.py +++ b/ephios/core/views/shift.py @@ -132,9 +132,9 @@ def get_context_data(self, **kwargs): def post(self, *args, **kwargs): self.object = self.get_object() form = self.get_shift_form() - try: - from ephios.core.signup import signup_method_from_slug + from ephios.core.signup.methods import signup_method_from_slug + try: signup_method = signup_method_from_slug( self.request.POST["signup_method_slug"], shift=self.object ) diff --git a/tests/plugins/basesignup/test_requestconfirm_signup.py b/tests/plugins/basesignup/test_requestconfirm_signup.py index c83656b3f..70d057da0 100644 --- a/tests/plugins/basesignup/test_requestconfirm_signup.py +++ b/tests/plugins/basesignup/test_requestconfirm_signup.py @@ -16,7 +16,7 @@ def test_request_confirm_signup_flow(django_app, volunteer, planner, event): ) response = django_app.get(event.get_absolute_url(), user=volunteer) - assert "already requested" in response + assert "mannequin_requested" in response # confirm the participation as planner response = django_app.get( From e49939e9c37a47912c092320c9420468ab69363b Mon Sep 17 00:00:00 2001 From: Felix Rindt Date: Sun, 10 Oct 2021 16:29:41 +0200 Subject: [PATCH 06/36] add comment field on participations --- .../0015_abstractparticipation_comment.py | 18 ++++++++++++++++++ ephios/core/models/events.py | 3 +++ 2 files changed, 21 insertions(+) create mode 100644 ephios/core/migrations/0015_abstractparticipation_comment.py diff --git a/ephios/core/migrations/0015_abstractparticipation_comment.py b/ephios/core/migrations/0015_abstractparticipation_comment.py new file mode 100644 index 000000000..2317acff0 --- /dev/null +++ b/ephios/core/migrations/0015_abstractparticipation_comment.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.7 on 2021-10-10 14:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0014_remove_eventtype_can_grant_qualification"), + ] + + operations = [ + migrations.AddField( + model_name="abstractparticipation", + name="comment", + field=models.CharField(blank=True, max_length=255, verbose_name="Comment"), + ), + ] diff --git a/ephios/core/models/events.py b/ephios/core/models/events.py index 9beda94e7..9564ca326 100644 --- a/ephios/core/models/events.py +++ b/ephios/core/models/events.py @@ -176,6 +176,9 @@ def labels_dict(cls): individual_start_time = DateTimeField(_("start time"), null=True) individual_end_time = DateTimeField(_("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. From 03765110b8a0e3954783c219b8ca038b0b6c12b0 Mon Sep 17 00:00:00 2001 From: Felix Rindt Date: Sun, 10 Oct 2021 17:12:03 +0200 Subject: [PATCH 07/36] reduce queries on event list --- ephios/core/models/events.py | 20 ++++++++++++-------- ephios/core/signup/methods.py | 32 ++++++++++++++++++-------------- ephios/core/views/shift.py | 2 +- 3 files changed, 31 insertions(+), 23 deletions(-) diff --git a/ephios/core/models/events.py b/ephios/core/models/events.py index 9564ca326..f06886f6a 100644 --- a/ephios/core/models/events.py +++ b/ephios/core/models/events.py @@ -235,7 +235,7 @@ def signup_method(self): from ephios.core.signup.methods import signup_method_from_slug try: - return signup_method_from_slug(self.signup_method_slug, self) + return signup_method_from_slug(self.signup_method_slug, self, event=self.event) except ValueError: return None @@ -268,13 +268,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()) diff --git a/ephios/core/signup/methods.py b/ephios/core/signup/methods.py index 6c6825eb1..82645be0a 100644 --- a/ephios/core/signup/methods.py +++ b/ephios/core/signup/methods.py @@ -69,16 +69,19 @@ def get_nonlocal_participant_from_request(request): raise PermissionDenied -class BaseSignupForm(forms.Form): - start_time = forms.SplitDateTimeField( +class BaseSignupForm(forms.ModelForm): + individual_start_time = forms.SplitDateTimeField( label=_("Start time"), widget=CustomSplitDateTimeWidget, required=False ) - end_time = forms.SplitDateTimeField( + individual_end_time = forms.SplitDateTimeField( label=_("End time"), widget=CustomSplitDateTimeWidget, required=False, ) - comment = forms.CharField(label=_("Comment"), required=False) + + class Meta: + model = AbstractParticipation + fields = ["individual_start_time", "individual_end_time", "comment"] def get_field_layout(self): return Layout(*(Field(name) for name in self.fields)) @@ -112,9 +115,7 @@ def __init__(self, *args, **kwargs): self.method = kwargs.pop("method") 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()), @@ -135,13 +136,19 @@ class BaseSignupView(FormView): def get_initial(self): return { - "start_time": self.shift.start_time, - "end_time": self.shift.end_time, + "individual_start_time": self.shift.start_time, + "individual_end_time": self.shift.end_time, } def get_form_kwargs(self): kwargs = super().get_form_kwargs() - kwargs.update({"method": self.method, "participant": self.participant}) + kwargs.update( + { + "method": self.method, + "participant": self.participant, + "instance": self.method.get_participation_for(self.participant), + } + ) return kwargs def setup(self, request, *args, **kwargs): @@ -152,8 +159,10 @@ def setup(self, request, *args, **kwargs): def form_valid(self, form): if (choice := self.request.POST.get("signup_choice")) is not None: if choice == "sign_up": + form.save() return self.signup_pressed(**form.cleaned_data) if choice == "decline": + form.save() return self.decline_pressed(**form.cleaned_data) return self.form_invalid(form) @@ -373,11 +382,6 @@ def perform_signup(self, participant: AbstractParticipant, **kwargs) -> Abstract if errors := self.get_signup_errors(participant): raise ParticipationError(errors) participation = self.get_participation_for(participant) - - for attr in ("start_time", "end_time"): - if attr in kwargs: - setattr(participation, f"individual_{attr}", kwargs.pop(attr)) - participation = self._configure_participation(participation, **kwargs) participation.save() ResponsibleParticipationRequested.send(participation) diff --git a/ephios/core/views/shift.py b/ephios/core/views/shift.py index 9e386a759..f2591a53b 100644 --- a/ephios/core/views/shift.py +++ b/ephios/core/views/shift.py @@ -93,7 +93,7 @@ class ShiftConfigurationFormView(CustomPermissionRequiredMixin, SingleObjectMixi pk_url_kwarg = "event_id" def get(self, request, *args, **kwargs): - from ephios.core.signup import signup_method_from_slug + from ephios.core.signup.methods import signup_method_from_slug signup_method = signup_method_from_slug(self.kwargs.get("slug"), event=self.get_object()) return HttpResponse(signup_method.render_configuration_form()) From 2af49b68bc269b9843fff957ff48bb5e12606324 Mon Sep 17 00:00:00 2001 From: Felix Rindt Date: Sun, 10 Oct 2021 20:47:15 +0200 Subject: [PATCH 08/36] refactor form save --- ephios/core/models/events.py | 8 ++++++-- ephios/core/signup/methods.py | 19 +++++++++++-------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/ephios/core/models/events.py b/ephios/core/models/events.py index f06886f6a..eab5a1258 100644 --- a/ephios/core/models/events.py +++ b/ephios/core/models/events.py @@ -167,7 +167,7 @@ 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")) """ @@ -235,7 +235,11 @@ def signup_method(self): from ephios.core.signup.methods import signup_method_from_slug try: - return signup_method_from_slug(self.signup_method_slug, self, event=self.event) + 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 diff --git a/ephios/core/signup/methods.py b/ephios/core/signup/methods.py index 82645be0a..41b8f7a6e 100644 --- a/ephios/core/signup/methods.py +++ b/ephios/core/signup/methods.py @@ -79,6 +79,9 @@ class BaseSignupForm(forms.ModelForm): required=False, ) + # TODO: Logging + # TODO: validation (time subset of shift time etc?!) + class Meta: model = AbstractParticipation fields = ["individual_start_time", "individual_end_time", "comment"] @@ -159,17 +162,16 @@ def setup(self, request, *args, **kwargs): def form_valid(self, form): if (choice := self.request.POST.get("signup_choice")) is not None: if choice == "sign_up": - form.save() - return self.signup_pressed(**form.cleaned_data) + return self.signup_pressed(form) if choice == "decline": - form.save() - return self.decline_pressed(**form.cleaned_data) + return self.decline_pressed(form) return self.form_invalid(form) - def signup_pressed(self, **signup_kwargs): + def signup_pressed(self, form): try: with transaction.atomic(): - self.method.perform_signup(self.participant, **signup_kwargs) + form.save() + self.method.perform_signup(self.participant, **form.cleaned_data) messages.success( self.request, self.method.signup_success_message.format(shift=self.shift), @@ -179,10 +181,11 @@ def signup_pressed(self, **signup_kwargs): messages.error(self.request, self.method.signup_error_message.format(error=error)) return redirect(self.participant.reverse_event_detail(self.shift.event)) - def decline_pressed(self, **signup_kwargs): + def decline_pressed(self, form): try: with transaction.atomic(): - self.method.perform_decline(self.participant, **signup_kwargs) + form.save() + self.method.perform_decline(self.participant, **form.cleaned_data) messages.info( self.request, self.method.decline_success_message.format(shift=self.shift) ) From 58cfb8b7cc659e2f222384c9d92e62282a1b27d0 Mon Sep 17 00:00:00 2001 From: Felix Rindt Date: Sun, 10 Oct 2021 20:52:08 +0200 Subject: [PATCH 09/36] use icon to customize initial signup --- ephios/core/models/events.py | 4 ++-- ephios/core/signup/methods.py | 1 - .../core/fragments/shift_box_big.html | 19 +++++++------------ 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/ephios/core/models/events.py b/ephios/core/models/events.py index eab5a1258..65bdf8298 100644 --- a/ephios/core/models/events.py +++ b/ephios/core/models/events.py @@ -173,8 +173,8 @@ def labels_dict(cls): """ Overwrites shift time. Use `start_time` and `end_time` to get the applicable time (implemented with a custom manager). """ - individual_start_time = DateTimeField(_("start time"), null=True) - individual_end_time = DateTimeField(_("end time"), null=True) + 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) diff --git a/ephios/core/signup/methods.py b/ephios/core/signup/methods.py index 41b8f7a6e..cbf2abb5e 100644 --- a/ephios/core/signup/methods.py +++ b/ephios/core/signup/methods.py @@ -79,7 +79,6 @@ class BaseSignupForm(forms.ModelForm): required=False, ) - # TODO: Logging # TODO: validation (time subset of shift time etc?!) class Meta: diff --git a/ephios/core/templates/core/fragments/shift_box_big.html b/ephios/core/templates/core/fragments/shift_box_big.html index 1132cd829..5c2dbc7c9 100644 --- a/ephios/core/templates/core/fragments/shift_box_big.html +++ b/ephios/core/templates/core/fragments/shift_box_big.html @@ -1,5 +1,5 @@ -{% load static %} {% load event_extras %} +{% load static %} {% load i18n %} {% load humanize %} @@ -67,22 +67,17 @@
- - + + + {% translate "Customize" %} +
{% elif can_sign_up and not can_customize_signup %} {% elif not can_sign_up and can_customize_signup %} - {% translate "Customize" %} + {% translate "Customize" %} {% endif %} {% if can_decline %} From 734ba3382274dbcb94329acd4711725a1147e2ea Mon Sep 17 00:00:00 2001 From: Felix Rindt Date: Wed, 27 Oct 2021 13:22:15 +0200 Subject: [PATCH 10/36] refactor signup method configuration to form classes --- ephios/core/signup/methods.py | 120 +++++++++--------- ephios/core/views/shift.py | 2 +- ephios/extra/utils.py | 23 ++++ ephios/plugins/basesignup/signup/common.py | 80 ++++-------- .../basesignup/signup/coupled_signup.py | 29 +++-- .../basesignup/signup/no_selfservice.py | 20 +-- .../basesignup/signup/section_based.py | 44 +++---- 7 files changed, 155 insertions(+), 163 deletions(-) diff --git a/ephios/core/signup/methods.py b/ephios/core/signup/methods.py index cbf2abb5e..8fc64a55e 100644 --- a/ephios/core/signup/methods.py +++ b/ephios/core/signup/methods.py @@ -15,8 +15,7 @@ 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.utils import formats, timezone +from django.utils import timezone from django.utils.functional import SimpleLazyObject, cached_property, classproperty from django.utils.translation import gettext_lazy as _ from django.views.generic import FormView @@ -24,6 +23,7 @@ from ephios.core.models import AbstractParticipation, Shift from ephios.extra.widgets import CustomSplitDateTimeWidget +from ...extra.utils import format_anything from ..signals import participant_from_request, register_signup_methods from .disposition import BaseDispositionParticipationForm from .participants import AbstractParticipant @@ -71,15 +71,24 @@ def get_nonlocal_participant_from_request(request): class BaseSignupForm(forms.ModelForm): individual_start_time = forms.SplitDateTimeField( - label=_("Start time"), widget=CustomSplitDateTimeWidget, required=False + label=_("Individual start time"), widget=CustomSplitDateTimeWidget, required=False ) individual_end_time = forms.SplitDateTimeField( - label=_("End time"), + label=_("Individual end time"), widget=CustomSplitDateTimeWidget, required=False, ) - # TODO: validation (time subset of shift time etc?!) + # TODO: any validation (time subset of shift time etc?!) + def clean_individual_start_time(self): + if self.cleaned_data["individual_start_time"] == self.method.shift.start_time: + return None + return self.cleaned_data["individual_start_time"] + + def clean_individual_end_time(self): + if self.cleaned_data["individual_end_time"] == self.method.shift.end_time: + return None + return self.cleaned_data["individual_end_time"] class Meta: model = AbstractParticipation @@ -136,19 +145,19 @@ class BaseSignupView(FormView): form_class = BaseSignupForm template_name = "core/signup.html" - def get_initial(self): - return { - "individual_start_time": self.shift.start_time, - "individual_end_time": self.shift.end_time, - } - def get_form_kwargs(self): kwargs = super().get_form_kwargs() + instance = self.method.get_participation_for(self.participant) + 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, + } kwargs.update( { "method": self.method, "participant": self.participant, "instance": self.method.get_participation_for(self.participant), + "initial": initial, } ) return kwargs @@ -169,8 +178,8 @@ def form_valid(self, form): def signup_pressed(self, form): try: with transaction.atomic(): - form.save() - self.method.perform_signup(self.participant, **form.cleaned_data) + participation = form.save() + self.method.perform_signup(self.participant, participation, **form.cleaned_data) messages.success( self.request, self.method.signup_success_message.format(shift=self.shift), @@ -183,8 +192,8 @@ def signup_pressed(self, form): def decline_pressed(self, form): try: with transaction.atomic(): - form.save() - self.method.perform_decline(self.participant, **form.cleaned_data) + participation = form.save() + self.method.perform_decline(self.participant, participation, **form.cleaned_data) messages.info( self.request, self.method.decline_success_message.format(shift=self.shift) ) @@ -251,6 +260,23 @@ def check_conflicting_shifts(method, participant): 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"), + ) + + class BaseSignupMethod: # pylint: disable=too-many-public-methods @@ -270,9 +296,7 @@ def disposition_participation_form_class(self): """ return BaseDispositionParticipationForm - @property - def configuration_form_class(self): - return forms.Form + configuration_form_class = BaseSignupMethodConfigurationForm @property def signup_view_class(self): @@ -292,8 +316,12 @@ def signup_view_class(self): 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(): @@ -369,12 +397,14 @@ def can_customize_signup(self, participant): not declineable_state or self.can_decline(participant) ) - def get_participation_for(self, participant): + 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 @@ -383,17 +413,17 @@ def perform_signup(self, participant: AbstractParticipant, **kwargs) -> Abstract if errors := self.get_signup_errors(participant): raise ParticipationError(errors) - participation = self.get_participation_for(participant) + participation = participation or self.get_participation_for(participant) participation = self._configure_participation(participation, **kwargs) participation.save() ResponsibleParticipationRequested.send(participation) 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() return participation @@ -407,48 +437,16 @@ def _configure_participation( """ 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)) } ) @@ -503,8 +501,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): diff --git a/ephios/core/views/shift.py b/ephios/core/views/shift.py index f2591a53b..92763b97a 100644 --- a/ephios/core/views/shift.py +++ b/ephios/core/views/shift.py @@ -141,7 +141,7 @@ def post(self, *args, **kwargs): configuration_form = signup_method.get_configuration_form(self.request.POST) except ValueError as e: raise ValidationError(e) from e - if form.is_valid() and configuration_form.is_valid(): + if all([form.is_valid(), configuration_form.is_valid()]): shift = form.save(commit=False) shift.signup_configuration = configuration_form.cleaned_data shift.save() diff --git a/ephios/extra/utils.py b/ephios/extra/utils.py index 5e932f063..c8af7a08c 100644 --- a/ephios/extra/utils.py +++ b/ephios/extra/utils.py @@ -1,8 +1,31 @@ +import datetime import itertools +from django.db import models +from django.template.defaultfilters import yesno +from django.utils import formats +from django.utils.translation import gettext_lazy as _ + def pairwise(iterable): "s -> (s0,s1), (s1,s2), (s2, s3), ..." a, b = itertools.tee(iterable) next(b, None) return zip(a, b) + + +def format_anything(value): + """Return some inbuilt types in a human readable way.""" + if isinstance(value, bool): + return yesno(value) + if isinstance(value, datetime.datetime): + return formats.date_format(value, format="SHORT_DATETIME_FORMAT") + if isinstance(value, models.Model): + return str(value) + if isinstance(value, models.Model): + return str(value) + if isinstance(value, models.QuerySet): + return ", ".join(str(e) for e in value) + if value is None: + return _("None") + return str(value) diff --git a/ephios/plugins/basesignup/signup/common.py b/ephios/plugins/basesignup/signup/common.py index 1f01d0d75..a75352c0b 100644 --- a/ephios/plugins/basesignup/signup/common.py +++ b/ephios/plugins/basesignup/signup/common.py @@ -1,5 +1,4 @@ import typing -from collections import OrderedDict from django import forms from django.template.loader import get_template @@ -41,39 +40,17 @@ def get_participant_count_bounds(self): self.configuration.maximum_number_of_participants, ) - def get_configuration_fields(self): - return OrderedDict( - { - **super().get_configuration_fields(), - "minimum_number_of_participants": { - "formfield": forms.IntegerField(min_value=0, required=False), - "default": None, - }, - "maximum_number_of_participants": { - "formfield": forms.IntegerField(min_value=1, required=False), - "default": None, - }, - } - ) + @property + def configuration_form_class(self): + class ConfigurationForm(super().configuration_form_class): + minimum_number_of_participants = forms.IntegerField( + label=_("Minimum number of participants"), min_value=0, required=False + ) + maximum_number_of_participants = forms.IntegerField( + label=_("Maximum number of participants"), min_value=1, required=False + ) - def get_participation_number_info(self): - min_count = self.configuration.minimum_number_of_participants - max_count = self.configuration.maximum_number_of_participants - if min_count is None and max_count is None: - return None - if min_count == max_count: - return str(min_count) - if min_count is not None and max_count is not None: - return _("{min} to {max}").format(min=min_count, max=max_count) - if min_count is not None: - return _("at least {min}").format(min=min_count) - return _("at most {max}").format(max=max_count) - - def get_signup_info(self): - infos = super().get_signup_info() - if number_info := self.get_participation_number_info(): - infos[_("Required number of participants")] = number_info - return infos + return ConfigurationForm class QualificationsRequiredSignupMixin(_Base): @@ -93,25 +70,24 @@ def check_qualification(method, participant): if not participant.has_qualifications(method.configuration.required_qualifications): return ParticipationError(_("You are not qualified.")) - def get_configuration_fields(self): - return OrderedDict( - { - **super().get_configuration_fields(), - "required_qualification_ids": { - "formfield": forms.ModelMultipleChoiceField( - label=_("Required Qualifications"), - queryset=Qualification.objects.all(), - widget=Select2MultipleWidget, - required=False, - ), - "default": [], - "publish_with_label": _("Required Qualification"), - "format": lambda ids: ", ".join( - Qualification.objects.filter(id__in=ids).values_list("title", flat=True) - ), - }, - } - ) + @property + def configuration_form_class(self): + class ConfigurationForm(super().configuration_form_class): + required_qualification_ids = forms.ModelMultipleChoiceField( + label=_("Required Qualifications"), + queryset=Qualification.objects.all(), + widget=Select2MultipleWidget, + required=False, + initial=[], + ) + + @staticmethod + def format_required_qualification_ids(ids): + return ", ".join( + Qualification.objects.filter(id__in=ids).values_list("title", flat=True) + ) + + return ConfigurationForm def render_basic_participation_pills_shift_state(method, request, additional_context=None): diff --git a/ephios/plugins/basesignup/signup/coupled_signup.py b/ephios/plugins/basesignup/signup/coupled_signup.py index 98bd9b444..a7e84237b 100644 --- a/ephios/plugins/basesignup/signup/coupled_signup.py +++ b/ephios/plugins/basesignup/signup/coupled_signup.py @@ -17,20 +17,23 @@ class CoupledSignupMethod(BaseSignupMethod): description = _("""This method mirrors signup from another shift.""") uses_requested_state = True - def get_configuration_fields(self): - return { - "leader_shift_id": { - "formfield": forms.ModelChoiceField( - label=_("shift to mirror participation from"), - required=True, - queryset=self.event.shifts.exclude( - Q(pk__in=[self.shift.id] if self.shift else []) - | Q(signup_method_slug=self.slug) # no chaining/no cycles! - ), + @property + def configuration_form_class(self): + class ConfigurationForm(super().configuration_form_class): + leader_shift_id = forms.ModelChoiceField( + label=_("shift to mirror participation from"), + required=True, + queryset=self.event.shifts.exclude( + Q(pk__in=[self.shift.id] if self.shift else []) + | Q(signup_method_slug=self.slug) # no chaining/no cycles! ), - "default": None, - }, - } + ) + + @staticmethod + def format_leader_shift_id(value): + return str(Shift.objects.get(id=value) if isinstance(value, int) else value) + + return ConfigurationForm def _configure_participation( self, participation: AbstractParticipation, **kwargs diff --git a/ephios/plugins/basesignup/signup/no_selfservice.py b/ephios/plugins/basesignup/signup/no_selfservice.py index 884c35239..a937f7665 100644 --- a/ephios/plugins/basesignup/signup/no_selfservice.py +++ b/ephios/plugins/basesignup/signup/no_selfservice.py @@ -12,16 +12,16 @@ class NoSelfserviceSignupMethod(BaseSignupMethod): description = _("""This method allows no signup by users.""") uses_requested_state = False - def get_configuration_fields(self): - return { - "no_selfservice_explanation": { - "formfield": forms.CharField( - label=_("Explanation"), - required=False, - ), - "default": "", - }, - } + @property + def configuration_form_class(self): + class ConfigurationForm(super().configuration_form_class): + no_selfservice_explanation = forms.CharField( + label=_("Explanation"), + required=False, + initial="", + ) + + return ConfigurationForm def _configure_participation( self, participation: AbstractParticipation, **kwargs diff --git a/ephios/plugins/basesignup/signup/section_based.py b/ephios/plugins/basesignup/signup/section_based.py index cf1cd855c..7f9110152 100644 --- a/ephios/plugins/basesignup/signup/section_based.py +++ b/ephios/plugins/basesignup/signup/section_based.py @@ -118,7 +118,20 @@ def clean_uuid(self): ) -class SectionBasedConfigurationForm(forms.Form): +class SectionBasedConfigurationForm(BaseSignupMethod.configuration_form_class): + choose_preferred_section = forms.BooleanField( + label=_("Ask participants for a preferred section"), + help_text=_("This only makes sense if you configure multiple sections."), + widget=forms.CheckboxInput, + required=False, + initial=False, + ) + sections = forms.Field( + label=_("Structure"), + widget=forms.HiddenInput, + required=False, + ) + def __init__(self, data=None, **kwargs): super().__init__(data, **kwargs) self.sections_formset = SectionsFormset( @@ -141,6 +154,10 @@ def clean_sections(self): ] return sections + @staticmethod + def format_sections(value): + return ", ".join(section["title"] for section in value) + class SectionSignupForm(forms.Form): section = forms.ChoiceField( @@ -207,33 +224,10 @@ class SectionBasedSignupMethod(BaseSignupMethod): registration_button_text = _("Request") signup_success_message = _("You have successfully requested a participation for {shift}.") signup_error_message = _("Requesting a participation failed: {error}") - - configuration_form_class = SectionBasedConfigurationForm signup_view_class = SectionBasedSignupView - disposition_participation_form_class = SectionBasedDispositionParticipationForm - def get_configuration_fields(self): - return { - **super().get_configuration_fields(), - "choose_preferred_section": { - "formfield": forms.BooleanField( - label=_("Ask participants for a preferred section"), - help_text=_("This only makes sense if you configure multiple sections."), - widget=forms.CheckboxInput, - required=False, - ), - "default": False, - }, - "sections": { - "formfield": forms.Field( - label=_("Structure"), - widget=forms.HiddenInput, - required=False, - ), - "default": [], - }, - } + configuration_form_class = SectionBasedConfigurationForm def _get_signup_stats_per_section(self, participations=None): from ephios.core.signup.methods import SignupStats From 0869cdab4be320a98732641e83a7c52de6f52b8d Mon Sep 17 00:00:00 2001 From: Felix Rindt Date: Wed, 27 Oct 2021 17:45:14 +0200 Subject: [PATCH 11/36] fix shift conflict detection --- ephios/core/signup/methods.py | 11 ++++++++--- tests/core/test_signup_methods.py | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/ephios/core/signup/methods.py b/ephios/core/signup/methods.py index 8fc64a55e..2727fc8f0 100644 --- a/ephios/core/signup/methods.py +++ b/ephios/core/signup/methods.py @@ -247,16 +247,21 @@ def check_participant_age(method, participant): ) -def get_conflicting_participations(shift, participant): +def get_conflicting_participations(shift, participant: AbstractParticipant): + self_start_time = shift.start_time + self_end_time = shift.end_time + if self_participation := participant.participation_for(shift): + self_start_time = self_participation.start_time + self_end_time = self_participation.end_time return 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=self_end_time, end_time__gt=self_start_time) ) def check_conflicting_shifts(method, participant): - if get_conflicting_participations(method.shift, participant).exists(): # TODO + if get_conflicting_participations(method.shift, participant).exists(): # TODO tests return ParticipationError(_("You are already confirmed for another shift at this time.")) diff --git a/tests/core/test_signup_methods.py b/tests/core/test_signup_methods.py index b0d88abe2..bf999d564 100644 --- a/tests/core/test_signup_methods.py +++ b/tests/core/test_signup_methods.py @@ -78,3 +78,17 @@ def test_get_conflicting_shifts(tz, a_times, b_times, conflict_expected, event, ) expected = {a_participation} if conflict_expected else set() assert set(get_conflicting_participations(b, volunteer.as_participant())) == expected + + +def test_get_conflicting_shift_with_individual_time(tz, volunteer, multi_shift_event): + shift_a, shift_b = multi_shift_event.shifts.order_by("start_time") + a_participation = LocalParticipation.objects.create( + shift=shift_a, + user=volunteer, + state=AbstractParticipation.States.CONFIRMED, + individual_start_time=shift_a.start_time, + individual_end_time=shift_b.start_time + timedelta(minutes=30), + ) + assert set(get_conflicting_participations(shift_b, volunteer.as_participant())) == { + a_participation + } From 5bf7fac223679c3251cfae0767397a80d446bccc Mon Sep 17 00:00:00 2001 From: Felix Rindt Date: Wed, 27 Oct 2021 18:06:25 +0200 Subject: [PATCH 12/36] refactor Participation Forms --- ephios/core/signup/disposition.py | 67 ++++++++++--------- ephios/core/signup/methods.py | 14 ++-- .../disposition/fragment_participation.html | 2 + .../basesignup/signup/section_based.py | 9 +-- 4 files changed, 47 insertions(+), 45 deletions(-) diff --git a/ephios/core/signup/disposition.py b/ephios/core/signup/disposition.py index 0def6daf5..c2e725fc6 100644 --- a/ephios/core/signup/disposition.py +++ b/ephios/core/signup/disposition.py @@ -8,11 +8,18 @@ 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.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): @@ -23,9 +30,8 @@ def __init__(self, **kwargs): 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"})) @@ -188,35 +194,30 @@ def get_formset(self): return formset def post(self, request, *args, **kwargs): + from ephios.core.services.notifications.types import ( + ParticipationConfirmedNotification, + ParticipationRejectedNotification, + ) + 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 - ): - 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) - - 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) + if not formset.is_valid(): + return self.get(request, *args, **kwargs, formset=formset) + + formset.save() + for participation, changed_fields in formset.changed_objects: + if "state" in changed_fields and ( + not participation.get_real_instance_class() == LocalParticipation + or participation.user != request.user + ): + if participation.state == AbstractParticipation.States.CONFIRMED: + ParticipationConfirmedNotification.send(participation) + elif participation.state == AbstractParticipation.States.RESPONSIBLE_REJECTED: + ParticipationRejectedNotification.send(participation) + + 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 2727fc8f0..fb4071a2c 100644 --- a/ephios/core/signup/methods.py +++ b/ephios/core/signup/methods.py @@ -25,7 +25,6 @@ from ...extra.utils import format_anything from ..signals import participant_from_request, register_signup_methods -from .disposition import BaseDispositionParticipationForm from .participants import AbstractParticipant logger = logging.getLogger(__name__) @@ -69,7 +68,7 @@ def get_nonlocal_participant_from_request(request): raise PermissionDenied -class BaseSignupForm(forms.ModelForm): +class BaseParticipationForm(forms.ModelForm): individual_start_time = forms.SplitDateTimeField( label=_("Individual start time"), widget=CustomSplitDateTimeWidget, required=False ) @@ -81,12 +80,12 @@ class BaseSignupForm(forms.ModelForm): # TODO: any validation (time subset of shift time etc?!) def clean_individual_start_time(self): - if self.cleaned_data["individual_start_time"] == self.method.shift.start_time: + if self.cleaned_data["individual_start_time"] == self.shift.start_time: return None return self.cleaned_data["individual_start_time"] def clean_individual_end_time(self): - if self.cleaned_data["individual_end_time"] == self.method.shift.end_time: + if self.cleaned_data["individual_end_time"] == self.shift.end_time: return None return self.cleaned_data["individual_end_time"] @@ -94,6 +93,8 @@ class Meta: model = AbstractParticipation fields = ["individual_start_time", "individual_end_time", "comment"] + +class BaseSignupForm(BaseParticipationForm): def get_field_layout(self): return Layout(*(Field(name) for name in self.fields)) @@ -124,6 +125,7 @@ def _get_buttons(self): def __init__(self, *args, **kwargs): self.method = kwargs.pop("method") + self.shift = self.method.shift self.participant: AbstractParticipant = kwargs.pop("participant") super().__init__(*args, **kwargs) self.helper = FormHelper() @@ -261,7 +263,7 @@ def get_conflicting_participations(shift, participant: AbstractParticipant): def check_conflicting_shifts(method, participant): - if get_conflicting_participations(method.shift, participant).exists(): # TODO tests + if get_conflicting_participations(method.shift, participant).exists(): return ParticipationError(_("You are already confirmed for another shift at this time.")) @@ -299,6 +301,8 @@ 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 configuration_form_class = BaseSignupMethodConfigurationForm diff --git a/ephios/core/templates/core/disposition/fragment_participation.html b/ephios/core/templates/core/disposition/fragment_participation.html index 75c3a44fa..5859c8337 100644 --- a/ephios/core/templates/core/disposition/fragment_participation.html +++ b/ephios/core/templates/core/disposition/fragment_participation.html @@ -8,6 +8,8 @@
{{ form.instance.participant.icon }} {{ form.instance.participant }} + {{ form.individual_start_time }} + {{ form.individual_end_time }} {% with form.instance.participant|conflicting_shifts:shift as conflicting_shifts %} {% if conflicting_shifts %} Date: Wed, 27 Oct 2021 20:34:40 +0200 Subject: [PATCH 13/36] add form to disposition view --- ephios/core/models/events.py | 3 +++ ephios/core/signup/disposition.py | 2 +- ephios/core/signup/methods.py | 16 +++++++----- .../core/disposition/disposition.html | 2 ++ .../disposition/fragment_participation.html | 26 ++++++++++++++++--- .../basesignup/participation_card_inline.html | 2 +- ephios/static/ephios/js/main.js | 4 ++- .../plugins/basesignup/js/disposition.js | 2 ++ 8 files changed, 44 insertions(+), 13 deletions(-) diff --git a/ephios/core/models/events.py b/ephios/core/models/events.py index 65bdf8298..902adce62 100644 --- a/ephios/core/models/events.py +++ b/ephios/core/models/events.py @@ -187,6 +187,9 @@ def labels_dict(cls): objects = ParticipationManager() + def has_customized_signup(self): + return bool(self.individual_start_time or self.individual_end_time or self.comment) + @property def hours_value(self): td = self.shift.end_time - self.shift.start_time diff --git a/ephios/core/signup/disposition.py b/ephios/core/signup/disposition.py index c2e725fc6..e533e2598 100644 --- a/ephios/core/signup/disposition.py +++ b/ephios/core/signup/disposition.py @@ -208,7 +208,7 @@ def post(self, request, *args, **kwargs): if "state" in changed_fields and ( not participation.get_real_instance_class() == LocalParticipation or participation.user != request.user - ): + ): # TODO also trigger for changed individual times if participation.state == AbstractParticipation.States.CONFIRMED: ParticipationConfirmedNotification.send(participation) elif participation.state == AbstractParticipation.States.RESPONSIBLE_REJECTED: diff --git a/ephios/core/signup/methods.py b/ephios/core/signup/methods.py index fb4071a2c..9104af2a6 100644 --- a/ephios/core/signup/methods.py +++ b/ephios/core/signup/methods.py @@ -93,6 +93,16 @@ class Meta: model = AbstractParticipation fields = ["individual_start_time", "individual_end_time", "comment"] + def __init__(self, *args, **kwargs): + instance = kwargs["instance"] + shift = getattr(self, "shift", instance.shift) + kwargs["initial"] = { + **kwargs.get("initial", {}), + "individual_start_time": instance.individual_start_time or shift.start_time, + "individual_end_time": instance.individual_end_time or shift.end_time, + } + super().__init__(*args, **kwargs) + class BaseSignupForm(BaseParticipationForm): def get_field_layout(self): @@ -149,17 +159,11 @@ class BaseSignupView(FormView): def get_form_kwargs(self): kwargs = super().get_form_kwargs() - instance = self.method.get_participation_for(self.participant) - 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, - } kwargs.update( { "method": self.method, "participant": self.participant, "instance": self.method.get_participation_for(self.participant), - "initial": initial, } ) return kwargs 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 5859c8337..e9521cb25 100644 --- a/ephios/core/templates/core/disposition/fragment_participation.html +++ b/ephios/core/templates/core/disposition/fragment_participation.html @@ -1,15 +1,15 @@ {% extends "core/disposition/base_participation.html" %} +{% load crispy_forms_filters %} {% load user_extras %} {% load i18n %} {% block body %} +
-
-
+
+
{{ form.instance.participant.icon }} {{ form.instance.participant }} - {{ form.individual_start_time }} - {{ form.individual_end_time }} {% with form.instance.participant|conflicting_shifts:shift as conflicting_shifts %} {% if conflicting_shifts %} {% for qualification in form.instance.participant.qualifications|get_relevant_qualifications %} {{ qualification }} {% endfor %} +
+
+ {{ form.individual_start_time|as_crispy_field }} + {{ form.individual_end_time|as_crispy_field }} + {{ form.comment|as_crispy_field }} +
+
+ {% if form.can_delete %}
+
+ {% block participation_controls %} + {% endblock %} + + {% if form.can_delete %} + - {% if form.can_delete %} - - {% endif %} -
+ {% endif %} +
+
+
+
+ {{ form.individual_start_time|as_crispy_field }} + {{ form.individual_end_time|as_crispy_field }} + {{ form.comment|as_crispy_field }} + {% block participation_form %} + {% endblock %} +
-
+
{% endblock %} \ No newline at end of file diff --git a/ephios/extra/utils.py b/ephios/extra/utils.py index c8af7a08c..8ff7e322e 100644 --- a/ephios/extra/utils.py +++ b/ephios/extra/utils.py @@ -6,6 +6,8 @@ from django.utils import formats from django.utils.translation import gettext_lazy as _ +# This file includes some of the itertools recipes from https://docs.python.org/3/library/itertools.html + def pairwise(iterable): "s -> (s0,s1), (s1,s2), (s2, s3), ..." @@ -14,6 +16,13 @@ def pairwise(iterable): return zip(a, b) +def partition(pred, iterable): + "Use a predicate to partition entries into false entries and true entries" + # partition(is_odd, range(10)) --> 0 2 4 6 8 and 1 3 5 7 9 + t1, t2 = itertools.tee(iterable) + return itertools.filterfalse(pred, t1), filter(pred, t2) + + def format_anything(value): """Return some inbuilt types in a human readable way.""" if isinstance(value, bool): diff --git a/ephios/plugins/basesignup/signup/section_based.py b/ephios/plugins/basesignup/signup/section_based.py index bb1804359..5f9792391 100644 --- a/ephios/plugins/basesignup/signup/section_based.py +++ b/ephios/plugins/basesignup/signup/section_based.py @@ -5,9 +5,7 @@ from operator import itemgetter from django import forms -from django.contrib import messages from django.core.exceptions import ValidationError -from django.shortcuts import redirect from django.template.loader import get_template from django.urls import reverse from django.utils.translation import gettext_lazy as _ @@ -15,7 +13,13 @@ from dynamic_preferences.registries import global_preferences_registry from ephios.core.models import AbstractParticipation, Qualification -from ephios.core.signup.methods import BaseSignupMethod, BaseSignupView, ParticipationError +from ephios.core.signup.disposition import BaseDispositionParticipationForm +from ephios.core.signup.methods import ( + BaseSignupForm, + BaseSignupMethod, + BaseSignupView, + ParticipationError, +) from ephios.core.signup.participants import AbstractParticipant NO_SECTION_UUID = "other" @@ -30,8 +34,8 @@ def sections_participant_qualifies_for(sections, participant: AbstractParticipan ] -class SectionBasedDispositionParticipationForm(forms.ModelForm): - disposition_participation_template = "basesignup/section_based/fragment_participant.html" +class SectionBasedDispositionParticipationForm(BaseDispositionParticipationForm): + disposition_participation_template = "basesignup/section_based/fragment_participation.html" section = forms.ChoiceField( label=_("Section"), @@ -115,7 +119,7 @@ def clean_uuid(self): class SectionBasedConfigurationForm(BaseSignupMethod.configuration_form_class): choose_preferred_section = forms.BooleanField( - label=_("Ask participants for a preferred section"), + label=_("Participants must provide a preferred section"), help_text=_("This only makes sense if you configure multiple sections."), widget=forms.CheckboxInput, required=False, @@ -154,17 +158,36 @@ def format_sections(value): return ", ".join(section["title"] for section in value) -class SectionSignupForm(forms.Form): - section = forms.ChoiceField( +class SectionSignupForm(BaseSignupForm): + preferred_section_uuid = forms.ChoiceField( label=_("Preferred Section"), widget=forms.RadioSelect, required=False, - # choices are set as (uuid, title) of section + # choices are later set as (uuid, title) of section ) - -class SectionBasedSignupView(BaseSignupView): - template_name = "basesignup/section_based/signup.html" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["preferred_section_uuid"].initial = self.instance.data.get( + "preferred_section_uuid" + ) + self.fields["preferred_section_uuid"].required = ( + self.data.get("signup_choice") == "sign_up" + and self.method.configuration.choose_preferred_section + ) + self.fields["preferred_section_uuid"].choices = [ + (section["uuid"], section["title"]) + for section in self.sections_participant_qualifies_for + ] + unqualified = [ + section + for section in self.method.configuration.sections + if section not in self.sections_participant_qualifies_for + ] + if unqualified: + self.fields["preferred_section_uuid"].help_text = _( + "You don't qualify for {qualifications}." + ).format(qualifications=", ".join(str(section["title"]) for section in unqualified)) @cached_property def sections_participant_qualifies_for(self): @@ -172,41 +195,9 @@ def sections_participant_qualifies_for(self): self.method.configuration.sections, self.participant ) - def get_form(self, form_class=None): - form = SectionSignupForm(self.request.POST) - form.fields["section"].choices = [ - (section["uuid"], section["title"]) - for section in self.sections_participant_qualifies_for - ] - return form - - def get_context_data(self, **kwargs): - kwargs.setdefault("shift", self.shift) - kwargs.setdefault( - "unqualified_sections", - [ - section["title"] - for section in self.method.configuration.sections - if section not in self.sections_participant_qualifies_for - ], - ) - return super().get_context_data(**kwargs) - def form_valid(self, form): - return super().signup_pressed(preferred_section_uuid=form.cleaned_data.get("section")) - - def signup_pressed(self, **kwargs): - if not self.method.configuration.choose_preferred_section: - # do straight signup if choosing is not enabled - return super().signup_pressed(**kwargs) - - if not self.method.can_sign_up(self.participant): - # redirect a misled request - messages.warning(self.request, _("You can not sign up for this shift.")) - return redirect(self.participant.reverse_event_detail(self.shift.event)) - - # all good, redirect to the form - return redirect(self.participant.reverse_signup_action(self.shift)) +class SectionBasedSignupView(BaseSignupView): + form_class = SectionSignupForm class SectionBasedSignupMethod(BaseSignupMethod): @@ -295,8 +286,9 @@ def _signup_checkers(self): # pylint: disable=arguments-differ def _configure_participation( - self, participation: AbstractParticipation, preferred_section_uuid=None, **kwargs + self, participation: AbstractParticipation, **kwargs ) -> AbstractParticipation: + preferred_section_uuid = kwargs.pop("preferred_section_uuid") participation.data["preferred_section_uuid"] = preferred_section_uuid if preferred_section_uuid: # reset dispatch decision, as that would have overwritten the preferred choice @@ -305,6 +297,7 @@ def _configure_participation( return participation def render_configuration_form(self, *args, form=None, **kwargs): + """We overwrite the template to render the formset.""" form = form or self.get_configuration_form(*args, **kwargs) template = get_template("basesignup/section_based/configuration_form.html").render( {"form": form} diff --git a/ephios/plugins/basesignup/templates/basesignup/section_based/fragment_participant.html b/ephios/plugins/basesignup/templates/basesignup/section_based/fragment_participant.html deleted file mode 100644 index e8340d39d..000000000 --- a/ephios/plugins/basesignup/templates/basesignup/section_based/fragment_participant.html +++ /dev/null @@ -1,45 +0,0 @@ -{% extends "core/disposition/base_participation.html" %} -{% load user_extras %} -{% load ephios_crispy %} -{% load i18n %} -{% block body %} -
-
-
-
- {{ form.instance.participant.icon }} - {{ form.instance.participant }} - {% if form.preferred_section %} - -
{% translate "prefers" %} - {{ form.preferred_section.title }} -
- {% endif %} -
-
- {% with form.instance.participant|conflicting_shifts:shift as conflicting_shifts %} - {% if conflicting_shifts %} - - {% endif %} - {% endwith %} - {% for qualification in form.instance.participant.qualifications|get_relevant_qualifications %} - {{ qualification }} - {% endfor %} -
-
-
- {% crispy_field form.section label_class="d-none" wrapper_class="d-inline-block mb-0" %} - - {% if form.can_delete %} - - {% endif %} -
-
-
-{% endblock %} \ No newline at end of file diff --git a/ephios/plugins/basesignup/templates/basesignup/section_based/fragment_participation.html b/ephios/plugins/basesignup/templates/basesignup/section_based/fragment_participation.html new file mode 100644 index 000000000..feb6ba8dd --- /dev/null +++ b/ephios/plugins/basesignup/templates/basesignup/section_based/fragment_participation.html @@ -0,0 +1,19 @@ +{% extends "core/disposition/fragment_participation.html" %} +{% load ephios_crispy %} +{% load user_extras %} +{% load i18n %} + +{% block participation_controls %} +
+ {% crispy_field form.section label_class="d-none" wrapper_class="d-inline-block me-3" %} +
+{% endblock %} + +{% block participation_detail %} + {% if form.preferred_section %} + +
{% translate "prefers" %} + {{ form.preferred_section.title }} +
+ {% endif %} +{% endblock %} diff --git a/ephios/plugins/basesignup/templates/basesignup/section_based/signup.html b/ephios/plugins/basesignup/templates/basesignup/section_based/signup.html deleted file mode 100644 index 77b95c43f..000000000 --- a/ephios/plugins/basesignup/templates/basesignup/section_based/signup.html +++ /dev/null @@ -1,27 +0,0 @@ -{% extends "base.html" %} -{% load crispy_forms_filters %} -{% load i18n %} -{% load static %} - -{% block title %} - {% translate "Signup" %} -{% endblock %} - -{% block content %} - - -
- {% csrf_token %} - {{ form|crispy }} - {% if unqualified_sections %} -

- {% translate "You don't qualify for" %} - {{ unqualified_sections|join:", "}}. -

- {% endif %} - -
- -{% endblock %} diff --git a/ephios/static/ephios/scss/ephios_bootstrap_variables.scss b/ephios/static/ephios/scss/ephios_bootstrap_variables.scss index a97fd5ebb..04dcd6fac 100644 --- a/ephios/static/ephios/scss/ephios_bootstrap_variables.scss +++ b/ephios/static/ephios/scss/ephios_bootstrap_variables.scss @@ -54,6 +54,7 @@ $card-cap-bg: $gray-100; $text-muted: $gray-550; +$enable-negative-margins: true; // Shadow (navbar) $box-shadow: 0 4px 4px rgba(#000, .10); diff --git a/tests/plugins/basesignup/test_sectionbased_signup.py b/tests/plugins/basesignup/test_sectionbased_signup.py index 8860085a8..e3d893c65 100644 --- a/tests/plugins/basesignup/test_sectionbased_signup.py +++ b/tests/plugins/basesignup/test_sectionbased_signup.py @@ -94,25 +94,13 @@ def test_signup_flow(django_app, qualified_volunteer, planner, event, sectioned_ ) .forms[1] .submit(name="signup_choice", value="sign_up") - .follow() ) - response.form["section"] = KTW_UUID - response.form.submit() + response.form["preferred_section_uuid"] = KTW_UUID + response.form.submit(name="signup_choice", value="sign_up").follow() assert ( LocalParticipation.objects.get(user=qualified_volunteer, shift=sectioned_shift).state == AbstractParticipation.States.REQUESTED ) - # test we can't signup again - assert ( - "You have already requested a participation." - in django_app.get( - event.get_absolute_url(), - user=qualified_volunteer, - ) - .forms[1] - .submit(name="signup_choice", value="sign_up") - .follow() - ) # confirm the participation as planner response = django_app.get( From b04eac217de31aca213ece39ef0d161e9b367d78 Mon Sep 17 00:00:00 2001 From: Felix Rindt Date: Thu, 28 Oct 2021 00:22:13 +0200 Subject: [PATCH 17/36] fix no-selfservice --- ephios/core/signup/methods.py | 16 +++++++++------- tests/plugins/basesignup/test_no_selfservice.py | 10 ++++++++-- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/ephios/core/signup/methods.py b/ephios/core/signup/methods.py index 47e268d8a..e3b0965e4 100644 --- a/ephios/core/signup/methods.py +++ b/ephios/core/signup/methods.py @@ -189,13 +189,14 @@ def signup_pressed(self, form): with transaction.atomic(): participation = form.save() self.method.perform_signup(self.participant, participation, **form.cleaned_data) - messages.success( - self.request, - self.method.signup_success_message.format(shift=self.shift), - ) 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, form): @@ -203,12 +204,13 @@ def decline_pressed(self, form): with transaction.atomic(): participation = form.save() self.method.perform_decline(self.participant, participation, **form.cleaned_data) - messages.info( - self.request, self.method.decline_success_message.format(shift=self.shift) - ) 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)) diff --git a/tests/plugins/basesignup/test_no_selfservice.py b/tests/plugins/basesignup/test_no_selfservice.py index 1595fd4ee..72fb63e2c 100644 --- a/tests/plugins/basesignup/test_no_selfservice.py +++ b/tests/plugins/basesignup/test_no_selfservice.py @@ -17,5 +17,11 @@ def test_no_selfservice_renders(django_app, volunteer, event, no_selfservice_shi def test_no_selfservice_does_not_allow_signup(django_app, volunteer, event, no_selfservice_shift): - response = django_app.get(event.get_absolute_url(), user=volunteer) - response.form.submit(name="signup_choice", value="sign_up", status=405) # method not allowed + event_detail = django_app.get(event.get_absolute_url(), user=volunteer) + # django webtest would ignore a `signup_choice` on the event_detail view, so we just get the default sign up view + signup_view = event_detail.form.submit() + # from there though, signup will not be possible + assert ( + "Signup for this shift is disabled" + in signup_view.form.submit(name="signup_choice", value="sign_up").follow() + ) From 258a1c6274457bedc7a053d89c54baa21b0d0944 Mon Sep 17 00:00:00 2001 From: Felix Rindt Date: Thu, 28 Oct 2021 01:13:19 +0200 Subject: [PATCH 18/36] refactor customization button --- ephios/core/signup/methods.py | 51 ++++++++++++++++-------- ephios/core/templatetags/event_extras.py | 2 +- ephios/core/views/shift.py | 2 +- 3 files changed, 37 insertions(+), 18 deletions(-) diff --git a/ephios/core/signup/methods.py b/ephios/core/signup/methods.py index e3b0965e4..aa2f66423 100644 --- a/ephios/core/signup/methods.py +++ b/ephios/core/signup/methods.py @@ -108,23 +108,27 @@ class BaseSignupForm(BaseParticipationForm): def get_field_layout(self): return Layout(*(Field(name) for name in self.fields)) - def _get_main_submit_label(self): + def _get_buttons(self): if (p := self.participant.participation_for(self.method.shift)) is not None and p.state in ( AbstractParticipation.States.REQUESTED, AbstractParticipation.States.CONFIRMED, ): - return _("Save") - return self.method.registration_button_text - - def _get_buttons(self): - buttons = [ - HTML( - f'' - ), + buttons = [ + HTML( + f'' + ) + ] + else: + buttons = [ + HTML( + f'' + ) + ] + buttons.append( HTML( f'{_("Cancel")}' - ), - ] + ) + ) if self.method.can_decline(self.participant): buttons.append( HTML( @@ -166,11 +170,15 @@ def get_form_kwargs(self): { "method": self.method, "participant": self.participant, - "instance": self.method.get_participation_for(self.participant), + "instance": self.participation, } ) 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"] @@ -178,12 +186,23 @@ def setup(self, request, *args, **kwargs): def form_valid(self, form): if (choice := self.request.POST.get("signup_choice")) is not None: - if choice == "sign_up": + 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(form) return self.form_invalid(form) + def customize_pressed(self, form): + form.save() + # TODO: + # If saving this results in the participation being so different that it should be reconfirmed: + # * set the state to REQUESTED + # * send out a yet-to-be-created ConfirmedParticipantDeclinedNotification + messages.success(self.request, _("Your participation was saved.")) + return redirect(self.participant.reverse_event_detail(self.shift.event)) + def signup_pressed(self, form): try: with transaction.atomic(): @@ -339,7 +358,6 @@ def signup_view_class(self): def __init__(self, shift, event=None): self.shift = shift self.event = getattr(shift, "event", event) - self.configuration = Namespace( **{ name: field.initial @@ -430,7 +448,7 @@ def perform_signup( ) -> 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 @@ -456,7 +474,8 @@ 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 diff --git a/ephios/core/templatetags/event_extras.py b/ephios/core/templatetags/event_extras.py index 9a42d4ea5..8580661bb 100644 --- a/ephios/core/templatetags/event_extras.py +++ b/ephios/core/templatetags/event_extras.py @@ -39,7 +39,7 @@ def can_sign_up(request, shift): @register.filter(name="can_customize_signup") -def can_sign_up(request, shift): +def can_customize_signup(request, shift): return shift.signup_method.can_customize_signup(request_to_participant(request)) diff --git a/ephios/core/views/shift.py b/ephios/core/views/shift.py index 92763b97a..0f0aa19ac 100644 --- a/ephios/core/views/shift.py +++ b/ephios/core/views/shift.py @@ -12,6 +12,7 @@ from ephios.core import signup from ephios.core.forms.events import ShiftForm from ephios.core.models import Event, Shift +from ephios.core.signup.methods import signup_method_from_slug from ephios.extra.mixins import CustomPermissionRequiredMixin @@ -132,7 +133,6 @@ def get_context_data(self, **kwargs): def post(self, *args, **kwargs): self.object = self.get_object() form = self.get_shift_form() - from ephios.core.signup.methods import signup_method_from_slug try: signup_method = signup_method_from_slug( From 5c23fae38998731347f24919baaa42298e8ec89c Mon Sep 17 00:00:00 2001 From: Felix Rindt Date: Thu, 28 Oct 2021 11:26:20 +0200 Subject: [PATCH 19/36] rebase migrations --- .../migrations/0013_auto_20211003_1734.py | 32 ------------- .../migrations/0014_auto_20211028_1125.py | 47 +++++++++++++++++++ ...emove_eventtype_can_grant_qualification.py | 17 ------- .../0015_abstractparticipation_comment.py | 18 ------- ephios/core/signup/methods.py | 2 + .../basesignup/signup/section_based.py | 1 + .../plugins/basesignup/test_no_selfservice.py | 2 +- 7 files changed, 51 insertions(+), 68 deletions(-) delete mode 100644 ephios/core/migrations/0013_auto_20211003_1734.py create mode 100644 ephios/core/migrations/0014_auto_20211028_1125.py delete mode 100644 ephios/core/migrations/0014_remove_eventtype_can_grant_qualification.py delete mode 100644 ephios/core/migrations/0015_abstractparticipation_comment.py diff --git a/ephios/core/migrations/0013_auto_20211003_1734.py b/ephios/core/migrations/0013_auto_20211003_1734.py deleted file mode 100644 index 4a22584ee..000000000 --- a/ephios/core/migrations/0013_auto_20211003_1734.py +++ /dev/null @@ -1,32 +0,0 @@ -# Generated by Django 3.2.7 on 2021-10-03 15:34 - -from django.db import migrations, models - -import ephios.core.models.users - - -class Migration(migrations.Migration): - - dependencies = [ - ("core", "0012_rename_included_qualifications_qualification_includes"), - ] - - operations = [ - migrations.AddField( - model_name="abstractparticipation", - name="individual_end_time", - field=models.DateTimeField(null=True, verbose_name="end time"), - ), - migrations.AddField( - model_name="abstractparticipation", - name="individual_start_time", - field=models.DateTimeField(null=True, verbose_name="start time"), - ), - migrations.AlterField( - model_name="qualificationgrant", - name="expires", - field=ephios.core.models.users.ExpirationDateField( - blank=True, null=True, verbose_name="expiration date" - ), - ), - ] diff --git a/ephios/core/migrations/0014_auto_20211028_1125.py b/ephios/core/migrations/0014_auto_20211028_1125.py new file mode 100644 index 000000000..fb0adbdfd --- /dev/null +++ b/ephios/core/migrations/0014_auto_20211028_1125.py @@ -0,0 +1,47 @@ +# Generated by Django 3.2.8 on 2021-10-28 09:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0013_auto_20211027_2203"), + ] + + operations = [ + 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.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", + ), + ), + ] diff --git a/ephios/core/migrations/0014_remove_eventtype_can_grant_qualification.py b/ephios/core/migrations/0014_remove_eventtype_can_grant_qualification.py deleted file mode 100644 index fe4d0ed71..000000000 --- a/ephios/core/migrations/0014_remove_eventtype_can_grant_qualification.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 3.2.7 on 2021-10-05 10:34 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("core", "0013_auto_20211003_1734"), - ] - - operations = [ - migrations.RemoveField( - model_name="eventtype", - name="can_grant_qualification", - ), - ] diff --git a/ephios/core/migrations/0015_abstractparticipation_comment.py b/ephios/core/migrations/0015_abstractparticipation_comment.py deleted file mode 100644 index 2317acff0..000000000 --- a/ephios/core/migrations/0015_abstractparticipation_comment.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.2.7 on 2021-10-10 14:29 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("core", "0014_remove_eventtype_can_grant_qualification"), - ] - - operations = [ - migrations.AddField( - model_name="abstractparticipation", - name="comment", - field=models.CharField(blank=True, max_length=255, verbose_name="Comment"), - ), - ] diff --git a/ephios/core/signup/methods.py b/ephios/core/signup/methods.py index aa2f66423..2db2f8c16 100644 --- a/ephios/core/signup/methods.py +++ b/ephios/core/signup/methods.py @@ -192,6 +192,8 @@ def form_valid(self, form): return self.customize_pressed(form) if choice == "decline": return self.decline_pressed(form) + messages.error(self.request, _("This action is not allowed.")) + return redirect(self.participant.reverse_event_detail(self.shift.event)) return self.form_invalid(form) def customize_pressed(self, form): diff --git a/ephios/plugins/basesignup/signup/section_based.py b/ephios/plugins/basesignup/signup/section_based.py index 5f9792391..5d2880d6c 100644 --- a/ephios/plugins/basesignup/signup/section_based.py +++ b/ephios/plugins/basesignup/signup/section_based.py @@ -289,6 +289,7 @@ def _configure_participation( self, participation: AbstractParticipation, **kwargs ) -> AbstractParticipation: preferred_section_uuid = kwargs.pop("preferred_section_uuid") + # TODO move this to configure participation.data["preferred_section_uuid"] = preferred_section_uuid if preferred_section_uuid: # reset dispatch decision, as that would have overwritten the preferred choice diff --git a/tests/plugins/basesignup/test_no_selfservice.py b/tests/plugins/basesignup/test_no_selfservice.py index 72fb63e2c..85509eb3e 100644 --- a/tests/plugins/basesignup/test_no_selfservice.py +++ b/tests/plugins/basesignup/test_no_selfservice.py @@ -22,6 +22,6 @@ def test_no_selfservice_does_not_allow_signup(django_app, volunteer, event, no_s signup_view = event_detail.form.submit() # from there though, signup will not be possible assert ( - "Signup for this shift is disabled" + "This action is not allowed" in signup_view.form.submit(name="signup_choice", value="sign_up").follow() ) From 6e61f8ae6f04f945eb34343258dc04bb453588cc Mon Sep 17 00:00:00 2001 From: Felix Rindt Date: Fri, 29 Oct 2021 21:29:21 +0200 Subject: [PATCH 20/36] update ical view --- ephios/core/ical.py | 9 +++++++++ tests/core/test_ical.py | 6 ++++++ 2 files changed, 15 insertions(+) diff --git a/ephios/core/ical.py b/ephios/core/ical.py index 7f1e3362e..70ef665c8 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,17 @@ 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] + ).prefetch_related( + Prefetch("participations", queryset=self.user.localparticipation_set.all()) ) diff --git a/tests/core/test_ical.py b/tests/core/test_ical.py index 89cbf2e3a..cfa15e1ba 100644 --- a/tests/core/test_ical.py +++ b/tests/core/test_ical.py @@ -1,4 +1,7 @@ +from datetime import timedelta + from django.urls import reverse +from django.utils.datetime_safe import strftime from ephios.core.models import AbstractParticipation @@ -18,6 +21,8 @@ def test_user_event_feed(django_app, qualified_volunteer, event): # only after getting confirmed the feed should contain the event participation = AbstractParticipation.objects.last() participation.state = AbstractParticipation.States.CONFIRMED + individual_start_time = event.shifts.first().start_time + timedelta(minutes=53) + participation.individual_start_time = individual_start_time participation.save() response = django_app.get( reverse( @@ -25,3 +30,4 @@ def test_user_event_feed(django_app, qualified_volunteer, event): ) ) assert response and event.title in response + assert strftime(individual_start_time, "%M") in response From 233c42cb9237a5ad96857bc2e46fde99bb4d61d2 Mon Sep 17 00:00:00 2001 From: Felix Rindt Date: Fri, 29 Oct 2021 22:26:20 +0200 Subject: [PATCH 21/36] show comment and individual times in event detail --- ephios/core/models/events.py | 4 +++ .../basesignup/participation_card_inline.html | 35 +++++++++++++++---- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/ephios/core/models/events.py b/ephios/core/models/events.py index 902adce62..bbb8ef59a 100644 --- a/ephios/core/models/events.py +++ b/ephios/core/models/events.py @@ -208,6 +208,10 @@ def __str__(self): except NotImplementedError: return super().__str__() + def get_start_end_time_display(self): + tz = pytz.timezone(settings.TIME_ZONE) + return f"{formats.time_format(self.start_time.astimezone(tz))} - {formats.time_format(self.end_time.astimezone(tz))}" + PARTICIPATION_LOG_CONFIG = ModelFieldsLogConfig( unlogged_fields=["id", "data", "abstractparticipation_ptr"], diff --git a/ephios/plugins/basesignup/templates/basesignup/participation_card_inline.html b/ephios/plugins/basesignup/templates/basesignup/participation_card_inline.html index 9ee360f0c..19e95fd77 100644 --- a/ephios/plugins/basesignup/templates/basesignup/participation_card_inline.html +++ b/ephios/plugins/basesignup/templates/basesignup/participation_card_inline.html @@ -1,17 +1,36 @@ +{% load guardian_tags %} {% load user_extras %} {% load i18n %} +{% if request.user.is_authenticated %} + {% get_obj_perms request.user for event as "event_perms" %} +{% endif %} +
{{ participation.participant.icon }} {{ participation.participant }}
- {% for qualification in participation.participant.qualifications|get_relevant_qualifications %} - {{ qualification }} - {% empty %} - {% translate "none" %} - {% endfor %} + {% if participation.individual_start_time or participation.individual_end_time %} +
+ + {{ participation.get_start_end_time_display }} +
+ {% endif %} + {% if participation.comment %} +
+ + {{ participation.comment }} +
+ {% endif %} +
+ {% for qualification in participation.participant.qualifications|get_relevant_qualifications %} + {{ qualification }} + {% empty %} + {% translate "none" %} + {% endfor %} +
{% if participation.state == participation.States.CONFIRMED %} - + {% if participation.has_customized_signup %} + + {% else %} + + {% endif %} {% else %} {% endif %} From 145e9ccf9e8d3d60611e65004694af3a7fe2e06b Mon Sep 17 00:00:00 2001 From: Felix Rindt Date: Sat, 30 Oct 2021 00:30:21 +0200 Subject: [PATCH 22/36] highlight individual times in shift box --- ephios/core/models/events.py | 3 + ephios/core/signup/methods.py | 12 ++-- .../core/fragments/shift_box_big.html | 55 +++++++++++++++---- ephios/core/templatetags/event_extras.py | 25 +++++++-- 4 files changed, 71 insertions(+), 24 deletions(-) diff --git a/ephios/core/models/events.py b/ephios/core/models/events.py index bbb8ef59a..fca7a3796 100644 --- a/ephios/core/models/events.py +++ b/ephios/core/models/events.py @@ -212,6 +212,9 @@ def get_start_end_time_display(self): tz = pytz.timezone(settings.TIME_ZONE) return f"{formats.time_format(self.start_time.astimezone(tz))} - {formats.time_format(self.end_time.astimezone(tz))}" + 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"], diff --git a/ephios/core/signup/methods.py b/ephios/core/signup/methods.py index 2db2f8c16..135535579 100644 --- a/ephios/core/signup/methods.py +++ b/ephios/core/signup/methods.py @@ -109,10 +109,9 @@ def get_field_layout(self): return Layout(*(Field(name) for name in self.fields)) def _get_buttons(self): - if (p := self.participant.participation_for(self.method.shift)) is not None and p.state in ( - AbstractParticipation.States.REQUESTED, - AbstractParticipation.States.CONFIRMED, - ): + if ( + p := self.participant.participation_for(self.method.shift) + ) is not None and p.is_in_positive_state(): buttons = [ HTML( f'' @@ -432,10 +431,7 @@ def can_customize_signup(self, participant): # We check for decline as well in case the participation is already requested/confirmed. declineable_state = ( p := participant.participation_for(self.shift) - ) is not None and p.state in ( - AbstractParticipation.States.CONFIRMED, - AbstractParticipation.States.REQUESTED, - ) + ) is not None and p.is_in_positive_state() return not self.get_signup_errors(participant) and ( not declineable_state or self.can_decline(participant) ) diff --git a/ephios/core/templates/core/fragments/shift_box_big.html b/ephios/core/templates/core/fragments/shift_box_big.html index 5c2dbc7c9..1a254ee78 100644 --- a/ephios/core/templates/core/fragments/shift_box_big.html +++ b/ephios/core/templates/core/fragments/shift_box_big.html @@ -5,20 +5,49 @@
- {% if not without_controls %} - {% with mannequin=request|shift_mannequin:shift %} -
- {{ mannequin }} mannequin icon + + {% with participation=request|participation_from_request:shift %} + {% with mannequin=participation|participation_mannequin_style style=participation|participation_css_style %} + {% if not without_controls %} +
+ {{ mannequin }} mannequin icon +
+ {% endif %} +
+ {{ shift.start_time|date:"l" }}, {{ shift.start_time|date:"SHORT_DATE_FORMAT" }} +
+ + {% 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 %} + + + {% if participation.is_in_positive_state %} + {% if participation.individual_start_time or participation.individual_end_time %} + + {% blocktranslate trimmed with start_time=shift.start_time|time end_time=shift.end_time|time %} + originally {{ start_time }} - {{ end_time }} + {% endblocktranslate %}, + + {% endif %} + {% endif %} + + {{ shift.meeting_time|time }} {% translate "Meetup" %} + +
{% 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" %} -
+ {% endwith %} + {% if not without_controls and "change_event" in event_perms %}
+
{% if not without_controls %} {{ request|render_shift_state:shift }} {% endif %}
+