diff --git a/ephios/event_management/models.py b/ephios/event_management/models.py index f420d47c7..5070b30aa 100644 --- a/ephios/event_management/models.py +++ b/ephios/event_management/models.py @@ -17,6 +17,7 @@ ) from django.utils import formats from django.utils.translation import gettext_lazy as _ +from guardian.shortcuts import assign_perm from polymorphic.models import PolymorphicModel from ephios import settings @@ -24,6 +25,7 @@ if TYPE_CHECKING: from ephios.event_management.signup import AbstractParticipant + from ephios.user_management.models import UserProfile class ActiveManager(Manager): @@ -93,9 +95,10 @@ class States(models.IntegerChoices): CONFIRMED = 1, _("confirmed") USER_DECLINED = 2, _("declined by user") RESPONSIBLE_REJECTED = 3, _("rejected by responsible") + RESPONSIBLE_ADDED = 4, _("added by responsible") shift = ForeignKey("Shift", on_delete=models.CASCADE, verbose_name=_("shift")) - state = IntegerField(_("state"), choices=States.choices, default=States.REQUESTED) + state = IntegerField(_("state"), choices=States.choices) data = models.JSONField(default=dict) @property @@ -160,7 +163,20 @@ def __str__(self): class LocalParticipation(AbstractParticipation): - user = ForeignKey(get_user_model(), on_delete=models.CASCADE) + user: "UserProfile" = ForeignKey(get_user_model(), on_delete=models.CASCADE) + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + if ( + not self.user.has_perm("event_management.view_event", obj=self.shift.event) + and self.state != self.States.RESPONSIBLE_ADDED + ): + # If dispatched by a responsible, the user should be able to view + # the event, if not already permitted through its group. + # Currently, this permission does not get removed automatically. + assign_perm( + "event_management.view_event", user_or_group=self.user, obj=self.shift.event + ) @property def participant(self): diff --git a/ephios/event_management/signup.py b/ephios/event_management/signup.py index 22e8e7103..13c9db756 100644 --- a/ephios/event_management/signup.py +++ b/ephios/event_management/signup.py @@ -21,6 +21,7 @@ from ephios.event_management.models import AbstractParticipation, LocalParticipation, Shift from ephios.extra.widgets import CustomSplitDateTimeWidget +from ephios.plugins.basesignup.signup.disposition import BaseDispositionParticipationForm from ephios.user_management.models import Qualification register_signup_methods = django.dispatch.Signal() @@ -217,6 +218,13 @@ def verbose_name(self): decline_success_message = _("You have successfully declined {shift}.") decline_error_message = _("Declining failed: {error}") + """ + This form will be used for participations in disposition. + Set to None if you don't want to support the default disposition. + """ + disposition_participation_form_class = BaseDispositionParticipationForm + disposition_show_requested_state = True + def __init__(self, shift): self.shift = shift self.configuration = Namespace( diff --git a/ephios/plugins/basesignup/signals.py b/ephios/plugins/basesignup/signals.py index 238923ab5..c9e100d4f 100644 --- a/ephios/plugins/basesignup/signals.py +++ b/ephios/plugins/basesignup/signals.py @@ -1,11 +1,9 @@ from django.dispatch import receiver from ephios.event_management.signup import register_signup_methods +from ephios.plugins.basesignup.signup.instant import InstantConfirmationSignupMethod +from ephios.plugins.basesignup.signup.request_confirm import RequestConfirmSignupMethod from ephios.plugins.basesignup.signup.section_based import SectionBasedSignupMethod -from ephios.plugins.basesignup.signup.simple import ( - InstantConfirmationSignupMethod, - RequestConfirmSignupMethod, -) @receiver( diff --git a/ephios/plugins/basesignup/signup/__init__.py b/ephios/plugins/basesignup/signup/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ephios/plugins/basesignup/signup/common.py b/ephios/plugins/basesignup/signup/common.py new file mode 100644 index 000000000..288052988 --- /dev/null +++ b/ephios/plugins/basesignup/signup/common.py @@ -0,0 +1,43 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ +from django_select2.forms import Select2MultipleWidget + +from ephios.event_management.signup import BaseSignupMethod, ParticipationError +from ephios.user_management.models import Qualification + + +class SimpleQualificationsRequiredSignupMethod(BaseSignupMethod): + # pylint: disable=abstract-method + def __init__(self, shift): + super().__init__(shift) + if shift is not None: + self.configuration.required_qualifications = Qualification.objects.filter( + pk__in=self.configuration.required_qualification_ids + ) + + @property + def signup_checkers(self): + return super().signup_checkers + [self.check_qualification] + + @staticmethod + 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 { + **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) + ), + }, + } diff --git a/ephios/plugins/basesignup/signup/disposition.py b/ephios/plugins/basesignup/signup/disposition.py new file mode 100644 index 000000000..377cfe2fb --- /dev/null +++ b/ephios/plugins/basesignup/signup/disposition.py @@ -0,0 +1,150 @@ +from django import forms +from django.http import Http404 +from django.shortcuts import redirect +from django.utils.translation import gettext_lazy as _ +from django.views import View +from django.views.generic import TemplateView +from django.views.generic.base import TemplateResponseMixin +from django.views.generic.detail import SingleObjectMixin +from django_select2.forms import ModelSelect2Widget + +from ephios.event_management.models import AbstractParticipation, Shift +from ephios.extra.permissions import CustomPermissionRequiredMixin +from ephios.user_management.models import UserProfile + + +class BaseDispositionParticipationForm(forms.ModelForm): + disposition_participation_template = "basesignup/common/fragment_participant.html" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + try: + self.shift = self.instance.shift + except AttributeError as e: + raise ValueError(f"{type(self)} must be initialized with an instance.") from e + + class Meta: + model = AbstractParticipation + fields = ["state"] + widgets = dict(state=forms.HiddenInput(attrs={"class": "state-input"})) + + +def get_disposition_formset(form): + return forms.modelformset_factory( + model=AbstractParticipation, + form=form, + extra=0, + can_order=False, + can_delete=True, + ) + + +def addable_users(shift): + """ + Return queryset of user objects that can be added to the shift. + This also includes users that already have a participation, as that might have gotten removed in JS. + + This also includes users that can normally not see the event. The permission will be added accordingly. + If needed, this method could be moved to signup methods. + """ + return UserProfile.objects.all() + + +class AddUserForm(forms.Form): + user = forms.ModelChoiceField( + widget=ModelSelect2Widget( + model=UserProfile, + search_fields=["first_name__icontains", "last_name__icontains"], + attrs={"form": "add-user-form"}, + ), + queryset=UserProfile.objects.none(), # set using __init__ + ) + + def __init__(self, user_queryset, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["user"].queryset = user_queryset + + +class DispositionBaseViewMixin(CustomPermissionRequiredMixin, SingleObjectMixin): + permission_required = "event_management.change_event" + model = Shift + + def setup(self, request, *args, **kwargs): + super().setup(request, *args, **kwargs) + self.object: Shift = self.get_object() + + def dispatch(self, request, *args, **kwargs): + if self.object.signup_method.disposition_participation_form_class is None: + raise Http404(_("This signup method does not support disposition.")) + return super().dispatch(request, *args, **kwargs) + + def get_permission_object(self): + return self.object.event + + +class AddUserView(DispositionBaseViewMixin, TemplateResponseMixin, View): + def get_template_names(self): + return [ + self.object.signup_method.disposition_participation_form_class.disposition_participation_template + ] + + def post(self, request, *args, **kwargs): + shift = self.object + form = AddUserForm( + user_queryset=addable_users(shift), + data=request.POST, + ) + if form.is_valid(): + user: UserProfile = form.cleaned_data["user"] + instance = shift.signup_method.get_participation_for(user.as_participant()) + instance.state = AbstractParticipation.States.RESPONSIBLE_ADDED + instance.save() + + DispositionParticipationFormset = get_disposition_formset( + self.object.signup_method.disposition_participation_form_class + ) + formset = DispositionParticipationFormset( + queryset=self.object.participations, + prefix="participations", + ) + form = next(filter(lambda form: form.instance.id == instance.id, formset)) + return self.render_to_response({"form": form}) + raise Http404("User does not exist") + + +class DispositionView(DispositionBaseViewMixin, TemplateView): + template_name = "basesignup/common/disposition.html" + + def get_formset(self): + DispositionParticipationFormset = get_disposition_formset( + self.object.signup_method.disposition_participation_form_class + ) + formset = DispositionParticipationFormset( + self.request.POST or None, + queryset=self.object.participations, + prefix="participations", + ) + return formset + + def post(self, request, *args, **kwargs): + formset = self.get_formset() + if formset.is_valid(): + formset.save() + self.object.participations.filter( + state=AbstractParticipation.States.RESPONSIBLE_ADDED + ).delete() + return redirect(self.object.event.get_absolute_url()) + return self.get(request, *args, **kwargs, formset=formset) + + def get_context_data(self, **kwargs): + kwargs.setdefault("formset", self.get_formset()) + kwargs.setdefault("states", AbstractParticipation.States) + kwargs.setdefault( + "participant_template", + self.object.signup_method.disposition_participation_form_class.disposition_participation_template, + ) + kwargs.setdefault( + "add_user_form", + AddUserForm(user_queryset=addable_users(self.object)), + ) + return super().get_context_data(**kwargs) diff --git a/ephios/plugins/basesignup/signup/instant.py b/ephios/plugins/basesignup/signup/instant.py new file mode 100644 index 000000000..609974071 --- /dev/null +++ b/ephios/plugins/basesignup/signup/instant.py @@ -0,0 +1,56 @@ +from django import forms +from django.template.loader import get_template +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + +from ephios.event_management.models import AbstractParticipation +from ephios.event_management.signup import ParticipationError +from ephios.plugins.basesignup.signup.common import SimpleQualificationsRequiredSignupMethod + + +class InstantConfirmationSignupMethod(SimpleQualificationsRequiredSignupMethod): + slug = "instant_confirmation" + verbose_name = _("Instant Confirmation") + description = _("""This method instantly confirms every signup after it was requested.""") + disposition_show_requested_state = False + + @property + def signup_checkers(self): + return super().signup_checkers + [self.check_maximum_number_of_participants] + + @staticmethod + def check_maximum_number_of_participants(method, participant): + if method.configuration.maximum_number_of_participants is not None: + current_count = AbstractParticipation.objects.filter( + shift=method.shift, state=AbstractParticipation.States.CONFIRMED + ).count() + if current_count >= method.configuration.maximum_number_of_participants: + return ParticipationError(_("The maximum number of participants is reached.")) + + def get_configuration_fields(self): + return { + **super().get_configuration_fields(), + "maximum_number_of_participants": { + "formfield": forms.IntegerField(min_value=1, required=False), + "default": None, + "publish_with_label": _("Maximum number of participants"), + }, + } + + def render_shift_state(self, request): + return get_template("basesignup/instant/fragment_state.html").render( + { + "shift": self.shift, + "disposition_url": ( + reverse("basesignup:shift_disposition", kwargs=dict(pk=self.shift.pk)) + if request.user.has_perm("event_management.change_event", obj=self.shift.event) + else None + ), + } + ) + + def perform_signup(self, participant, **kwargs): + participation = super().perform_signup(participant, **kwargs) + participation.state = AbstractParticipation.States.CONFIRMED + participation.save() + return participation diff --git a/ephios/plugins/basesignup/signup/request_confirm.py b/ephios/plugins/basesignup/signup/request_confirm.py new file mode 100644 index 000000000..3e2859b19 --- /dev/null +++ b/ephios/plugins/basesignup/signup/request_confirm.py @@ -0,0 +1,100 @@ +from django import forms +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 _ +from django.views.generic import TemplateView +from django.views.generic.detail import SingleObjectMixin + +from ephios.event_management.models import AbstractParticipation, Shift +from ephios.extra.permissions import CustomPermissionRequiredMixin +from ephios.plugins.basesignup.signup.common import SimpleQualificationsRequiredSignupMethod + +DispositionParticipationFormset = forms.modelformset_factory( + model=AbstractParticipation, + fields=["state"], + extra=0, + can_order=False, + can_delete=False, + widgets={ + "state": forms.HiddenInput(attrs={"class": "state-input"}), + }, +) + + +class RequestConfirmDispositionView(CustomPermissionRequiredMixin, SingleObjectMixin, TemplateView): + model = Shift + permission_required = "event_management.change_event" + template_name = "basesignup/disposition.html" + + def get_permission_object(self): + self.object: Shift = self.get_object() + return self.object.event + + def get_formset(self): + return DispositionParticipationFormset( + self.request.POST or None, queryset=self.object.participations + ) + + def get(self, request, *args, **kwargs): + self.object: Shift = self.get_object() + return super().get(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + self.object: Shift = self.get_object() + formset = self.get_formset() + if formset.is_valid(): + formset.save() + return redirect(self.object.event.get_absolute_url()) + return self.get(request, *args, **kwargs, formset=formset) + + def get_context_data(self, **kwargs): + kwargs.setdefault("formset", self.get_formset()) + kwargs.setdefault("states", AbstractParticipation.States) + kwargs.setdefault( + "participant_template", "basesignup/request_confirm/fragment_participant.html" + ) + return super().get_context_data(**kwargs) + + +class RequestConfirmSignupMethod(SimpleQualificationsRequiredSignupMethod): + slug = "request_confirm" + verbose_name = _("Request and confirm") + description = _( + """This method lets people request participation. Responsibles can then confirm the participation.""" + ) + registration_button_text = _("Request") + signup_success_message = _("You have successfully requested a participation for {shift}.") + signup_error_message = _("Requesting a participation failed: {error}") + + def render_shift_state(self, request): + participations = self.shift.participations.filter( + state__in={ + AbstractParticipation.States.REQUESTED, + AbstractParticipation.States.CONFIRMED, + } + ) + return get_template("basesignup/request_confirm/fragment_state.html").render( + { + "shift": self.shift, + "requested_participants": ( + p.participant + for p in participations.filter(state=AbstractParticipation.States.REQUESTED) + ), + "confirmed_participants": ( + p.participant + for p in participations.filter(state=AbstractParticipation.States.CONFIRMED) + ), + "disposition_url": ( + reverse("basesignup:shift_disposition", kwargs=dict(pk=self.shift.pk)) + if request.user.has_perm("event_management.change_event", obj=self.shift.event) + else None + ), + } + ) + + def perform_signup(self, participant, **kwargs): + participation = super().perform_signup(participant, **kwargs) + participation.state = AbstractParticipation.States.REQUESTED + participation.save() + return participation diff --git a/ephios/plugins/basesignup/signup/section_based.py b/ephios/plugins/basesignup/signup/section_based.py index ce269bb4b..54d191414 100644 --- a/ephios/plugins/basesignup/signup/section_based.py +++ b/ephios/plugins/basesignup/signup/section_based.py @@ -10,18 +10,17 @@ 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, TemplateView -from django.views.generic.detail import SingleObjectMixin +from django.views.generic import FormView from django_select2.forms import Select2MultipleWidget -from ephios.event_management.models import AbstractParticipation, Shift +from ephios.event_management.models import AbstractParticipation from ephios.event_management.signup import ( AbstractParticipant, BaseSignupMethod, BaseSignupView, ParticipationError, ) -from ephios.extra.permissions import CustomPermissionRequiredMixin +from ephios.plugins.basesignup.signup.disposition import BaseDispositionParticipationForm from ephios.user_management.models import Qualification @@ -34,25 +33,44 @@ def sections_participant_qualifies_for(sections, participant: AbstractParticipan ] -class DispositionParticipationForm(forms.ModelForm): +class SectionBasedDispositionParticipationForm(BaseDispositionParticipationForm): + disposition_participation_template = "basesignup/section_based/fragment_participant.html" + section = forms.ChoiceField( - label=_("Section"), required=False # only required if participation is confirmed + label=_("Section"), + required=False, # only required if participation is confirmed + widget=forms.Select( + attrs={"data-show-for-state": str(AbstractParticipation.States.CONFIRMED)} + ), ) - class Meta: - model = AbstractParticipation - fields = ["state"] - def __init__(self, **kwargs): super().__init__(**kwargs) - sections = self.instance.shift.signup_method.configuration.sections - self.fields["section"].choices = [("", "---")] + [ - (section["uuid"], section["title"]) - for section in sections_participant_qualifies_for( + sections = self.shift.signup_method.configuration.sections + qualified_sections = list( + sections_participant_qualifies_for( sections, self.instance.participant, ) + ) + unqualified_sections = [ + section for section in sections if section not in qualified_sections ] + self.fields["section"].choices = [("", "---")] + if qualified_sections: + self.fields["section"].choices += [ + ( + _("qualified"), + [(section["uuid"], section["title"]) for section in qualified_sections], + ) + ] + if unqualified_sections: + self.fields["section"].choices += [ + ( + _("unqualified"), + [(section["uuid"], section["title"]) for section in unqualified_sections], + ) + ] if preferred_section_uuid := self.instance.data.get("preferred_section_uuid"): self.fields["section"].initial = preferred_section_uuid self.preferred_section = next( @@ -77,53 +95,6 @@ def save(self, commit=True): super().save(commit) -DispositionParticipationFormset = forms.modelformset_factory( - model=AbstractParticipation, - form=DispositionParticipationForm, - extra=0, - can_order=False, - can_delete=False, - widgets={ - "state": forms.HiddenInput(attrs={"class": "state-input"}), - }, -) - - -class SectionBasedDispositionView(CustomPermissionRequiredMixin, SingleObjectMixin, TemplateView): - model = Shift - permission_required = "event_management.change_event" - template_name = "basesignup/disposition.html" - - def setup(self, request, *args, **kwargs): - super().setup(request, *args, **kwargs) - self.object: Shift = self.get_object() - - def get_permission_object(self): - return self.object.event - - def get_formset(self): - formset = DispositionParticipationFormset( - self.request.POST or None, queryset=self.object.participations, prefix="participations" - ) - return formset - - def post(self, request, *args, **kwargs): - formset = self.get_formset() - if formset.is_valid(): - formset.save() - return redirect(self.object.event.get_absolute_url()) - return self.get(request, *args, **kwargs, formset=formset) - - def get_context_data(self, **kwargs): - kwargs.setdefault("formset", self.get_formset()) - kwargs.setdefault("states", AbstractParticipation.States) - kwargs.setdefault("sections", self.object.signup_method.configuration.sections) - kwargs.setdefault( - "participant_template", "basesignup/section_based/fragment_participant.html" - ) - return super().get_context_data(**kwargs) - - class SectionForm(forms.Form): title = forms.CharField(label=_("Title"), required=True) qualifications = forms.ModelMultipleChoiceField( @@ -208,10 +179,10 @@ def get_context_data(self, **kwargs): def form_valid(self, form): return super().signup_pressed(preferred_section_uuid=form.cleaned_data.get("section")) - def signup_pressed(self): + 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() + return super().signup_pressed(**kwargs) if not self.method.can_sign_up(self.request.user.as_participant()): # redirect a misled request @@ -234,9 +205,12 @@ 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(), @@ -268,7 +242,10 @@ def check_qualification(method, participant): def signup_checkers(self): return super().signup_checkers + [self.check_qualification] - def perform_signup(self, participant: AbstractParticipant, preferred_section_uuid=None): + # pylint: disable=arguments-differ + def perform_signup( + self, participant: AbstractParticipant, preferred_section_uuid=None, **kwargs + ): participation = super().perform_signup(participant) participation.data["preferred_section_uuid"] = preferred_section_uuid if preferred_section_uuid: @@ -316,7 +293,7 @@ def render_shift_state(self, request): "confirmed_sections_with_users": confirmed_sections_with_users, "disposition_url": ( reverse( - "basesignup:shift_disposition_section_based", + "basesignup:shift_disposition", kwargs=dict(pk=self.shift.pk), ) if request.user.has_perm("event_management.change_event", obj=self.shift.event) diff --git a/ephios/plugins/basesignup/signup/simple.py b/ephios/plugins/basesignup/signup/simple.py deleted file mode 100644 index 51638bbdf..000000000 --- a/ephios/plugins/basesignup/signup/simple.py +++ /dev/null @@ -1,180 +0,0 @@ -from django import forms -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 _ -from django.views.generic import TemplateView -from django.views.generic.detail import SingleObjectMixin -from django_select2.forms import Select2MultipleWidget - -from ephios.event_management.models import AbstractParticipation, Shift -from ephios.event_management.signup import BaseSignupMethod, ParticipationError -from ephios.extra.permissions import CustomPermissionRequiredMixin -from ephios.user_management.models import Qualification - - -class SimpleQualificationsRequiredSignupMethod(BaseSignupMethod): - # pylint: disable=abstract-method - def __init__(self, shift): - super().__init__(shift) - if shift is not None: - self.configuration.required_qualifications = Qualification.objects.filter( - pk__in=self.configuration.required_qualification_ids - ) - - @property - def signup_checkers(self): - return super().signup_checkers + [self.check_qualification] - - @staticmethod - 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 { - **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) - ), - }, - } - - -class InstantConfirmationSignupMethod(SimpleQualificationsRequiredSignupMethod): - slug = "instant_confirmation" - verbose_name = _("Instant Confirmation") - description = _("""This method instantly confirms every signup after it was requested.""") - - @property - def signup_checkers(self): - return super().signup_checkers + [self.check_maximum_number_of_participants] - - @staticmethod - def check_maximum_number_of_participants(method, participant): - if method.configuration.maximum_number_of_participants is not None: - current_count = AbstractParticipation.objects.filter( - shift=method.shift, state=AbstractParticipation.States.CONFIRMED - ).count() - if current_count >= method.configuration.maximum_number_of_participants: - return ParticipationError(_("The maximum number of participants is reached.")) - - def get_configuration_fields(self): - return { - **super().get_configuration_fields(), - "maximum_number_of_participants": { - "formfield": forms.IntegerField(min_value=1, required=False), - "default": None, - "publish_with_label": _("Maximum number of participants"), - }, - } - - def render_shift_state(self, request): - return get_template("basesignup/signup_instant_state.html").render({"shift": self.shift}) - - def perform_signup(self, participant, **kwargs): - participation = super().perform_signup(participant, **kwargs) - participation.state = AbstractParticipation.States.CONFIRMED - participation.save() - return participation - - -DispositionParticipationFormset = forms.modelformset_factory( - model=AbstractParticipation, - fields=["state"], - extra=0, - can_order=False, - can_delete=False, - widgets={ - "state": forms.HiddenInput(attrs={"class": "state-input"}), - }, -) - - -class RequestConfirmDispositionView(CustomPermissionRequiredMixin, SingleObjectMixin, TemplateView): - model = Shift - permission_required = "event_management.change_event" - template_name = "basesignup/disposition.html" - - def get_permission_object(self): - self.object: Shift = self.get_object() - return self.object.event - - def get_formset(self): - return DispositionParticipationFormset( - self.request.POST or None, queryset=self.object.participations - ) - - def get(self, request, *args, **kwargs): - self.object: Shift = self.get_object() - return super().get(request, *args, **kwargs) - - def post(self, request, *args, **kwargs): - self.object: Shift = self.get_object() - formset = self.get_formset() - if formset.is_valid(): - formset.save() - return redirect(self.object.event.get_absolute_url()) - return self.get(request, *args, **kwargs, formset=formset) - - def get_context_data(self, **kwargs): - kwargs.setdefault("formset", self.get_formset()) - kwargs.setdefault("states", AbstractParticipation.States) - kwargs.setdefault( - "participant_template", "basesignup/requestconfirm/fragment_participant.html" - ) - return super().get_context_data(**kwargs) - - -class RequestConfirmSignupMethod(SimpleQualificationsRequiredSignupMethod): - slug = "request_confirm" - verbose_name = _("Request and confirm") - description = _( - """This method lets people request participation. Responsibles can then confirm the participation.""" - ) - registration_button_text = _("Request") - signup_success_message = _("You have successfully requested a participation for {shift}.") - signup_error_message = _("Requesting a participation failed: {error}") - - def render_shift_state(self, request): - participations = self.shift.participations.filter( - state__in={ - AbstractParticipation.States.REQUESTED, - AbstractParticipation.States.CONFIRMED, - } - ) - return get_template("basesignup/requestconfirm/fragment_state.html").render( - { - "shift": self.shift, - "requested_participants": ( - p.participant - for p in participations.filter(state=AbstractParticipation.States.REQUESTED) - ), - "confirmed_participants": ( - p.participant - for p in participations.filter(state=AbstractParticipation.States.CONFIRMED) - ), - "disposition_url": ( - reverse( - "basesignup:shift_disposition_requestconfirm", kwargs=dict(pk=self.shift.pk) - ) - if request.user.has_perm("event_management.change_event", obj=self.shift.event) - else None - ), - } - ) - - def perform_signup(self, participant, **kwargs): - participation = super().perform_signup(participant, **kwargs) - participation.state = AbstractParticipation.States.REQUESTED - participation.save() - return participation diff --git a/ephios/plugins/basesignup/templates/basesignup/common/disposition.html b/ephios/plugins/basesignup/templates/basesignup/common/disposition.html new file mode 100644 index 000000000..df7b3fba2 --- /dev/null +++ b/ephios/plugins/basesignup/templates/basesignup/common/disposition.html @@ -0,0 +1,115 @@ +{% extends "base.html" %} +{% load i18n %} +{% load bootstrap4 %} +{% load static %} +{% load formset_tags %} + +{% block title %} + {% translate "Disposition" %} +{% endblock %} + +{% block javascript %} + + +{% endblock %} + +{% block content %} + + +
{% csrf_token %}
+ +
+ {% csrf_token %} + {{ formset.management_form }} + +
+
+
+ +
+
+
{% trans "Add others" %}
+ {% bootstrap_form add_user_form layout="inline" %} +
+ {% for form in formset %} + {% if form.instance.state == states.RESPONSIBLE_ADDED %} + {% include participant_template %} + {% endif %} + {% endfor %} +
+
+
+ +
+
+
{{ states.CONFIRMED.label|capfirst }}
+
+ {% for form in formset %} + {% if form.instance.state == states.CONFIRMED %} + {% include participant_template %} + {% endif %} + {% endfor %} +
+
+
+ +
+
+ + {% if shift.signup_method.disposition_show_requested_state %} +
+
+
{{ states.REQUESTED.label|capfirst }}
+
+ {% for form in formset %} + {% if form.instance.state == states.REQUESTED %} + {% include participant_template %} + {% endif %} + {% endfor %} +
+
+
+ {% endif %} + +
+
+
{{ states.RESPONSIBLE_REJECTED.label|capfirst }}
+
+ {% for form in formset %} + {% if form.instance.state == states.RESPONSIBLE_REJECTED %} + {% include participant_template %} + {% endif %} + {% endfor %} +
+
+
+ +
+
+
{{ states.USER_DECLINED.label|capfirst }}
+
+ {% for form in formset %} + {% if form.instance.state == states.USER_DECLINED %} + {% include participant_template %} + {% endif %} + {% endfor %} +
+
+
+ +
+
+ + +
+
+ +{% endblock %} diff --git a/ephios/plugins/basesignup/templates/basesignup/common/fragment_participant.html b/ephios/plugins/basesignup/templates/basesignup/common/fragment_participant.html new file mode 100644 index 000000000..1930e827f --- /dev/null +++ b/ephios/plugins/basesignup/templates/basesignup/common/fragment_participant.html @@ -0,0 +1,20 @@ +{% load bootstrap4 %} +{% load i18n %} +
+
+
+
+ + {{ form.instance.participant }} +
+
+ +
+
+
+
+ {% bootstrap_form form layout="inline" %} +
+
\ No newline at end of file diff --git a/ephios/plugins/basesignup/templates/basesignup/disposition.html b/ephios/plugins/basesignup/templates/basesignup/disposition.html deleted file mode 100644 index 0e748341d..000000000 --- a/ephios/plugins/basesignup/templates/basesignup/disposition.html +++ /dev/null @@ -1,86 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} -{% load bootstrap4 %} -{% load static %} - -{% block title %} - {% translate "Disposition" %} -{% endblock %} - -{% block javascript %} - -{% endblock %} - -{% block content %} - - -
- {% csrf_token %} - {{ formset.management_form }} - -
-
-
-
-
{{ states.REQUESTED.label|capfirst }}
-
    - {% for form in formset %} - {% if form.instance.state == states.REQUESTED %} - {% include participant_template %} - {% endif %} - {% endfor %} -
-
-
-
-
-
-
-
-
-
{{ states.RESPONSIBLE_REJECTED.label|capfirst }}
-
    - {% for form in formset %} - {% if form.instance.state == states.RESPONSIBLE_REJECTED %} - {% include participant_template %} - {% endif %} - {% endfor %} -
-
-
-
-
-
{{ states.USER_DECLINED.label|capfirst }}
-
    - - {% for form in formset %} - {% if form.instance.state == states.USER_DECLINED %} - {% include participant_template %} - {% endif %} - {% endfor %} -
-
-
-
-
-
-
-
{{ states.CONFIRMED.label|capfirst }}
-
    - {% for form in formset %} - {% if form.instance.state == states.CONFIRMED %} - {% include participant_template %} - {% endif %} - {% endfor %} -
-
-
-
-
- - -
- -{% endblock %} diff --git a/ephios/plugins/basesignup/templates/basesignup/signup_instant_state.html b/ephios/plugins/basesignup/templates/basesignup/instant/fragment_state.html similarity index 53% rename from ephios/plugins/basesignup/templates/basesignup/signup_instant_state.html rename to ephios/plugins/basesignup/templates/basesignup/instant/fragment_state.html index fea9c3b3c..0f0feec92 100644 --- a/ephios/plugins/basesignup/templates/basesignup/signup_instant_state.html +++ b/ephios/plugins/basesignup/templates/basesignup/instant/fragment_state.html @@ -11,3 +11,9 @@ + +{% if disposition_url %} +
+ {% translate "Disposition" %} +
+{% endif %} \ No newline at end of file diff --git a/ephios/plugins/basesignup/templates/basesignup/requestconfirm/fragment_state.html b/ephios/plugins/basesignup/templates/basesignup/request_confirm/fragment_state.html similarity index 100% rename from ephios/plugins/basesignup/templates/basesignup/requestconfirm/fragment_state.html rename to ephios/plugins/basesignup/templates/basesignup/request_confirm/fragment_state.html diff --git a/ephios/plugins/basesignup/templates/basesignup/requestconfirm/fragment_participant.html b/ephios/plugins/basesignup/templates/basesignup/requestconfirm/fragment_participant.html deleted file mode 100644 index 085af516f..000000000 --- a/ephios/plugins/basesignup/templates/basesignup/requestconfirm/fragment_participant.html +++ /dev/null @@ -1,10 +0,0 @@ -{% load bootstrap4 %} -
  • -
    - - {{ form.instance.participant }} -
    -
    - {{ form }} -
    -
  • \ No newline at end of file diff --git a/ephios/plugins/basesignup/templates/basesignup/section_based/fragment_participant.html b/ephios/plugins/basesignup/templates/basesignup/section_based/fragment_participant.html index 770d6cfd6..cabb59360 100644 --- a/ephios/plugins/basesignup/templates/basesignup/section_based/fragment_participant.html +++ b/ephios/plugins/basesignup/templates/basesignup/section_based/fragment_participant.html @@ -1,9 +1,9 @@ {% load bootstrap4 %} {% load i18n %} -
  • -
    -
    -
    +
    +
    +
    +
    {{ form.instance.participant }} {% if form.preferred_section %} @@ -13,13 +13,16 @@
    {% endif %}
    -
    - {% bootstrap_field form.section show_label='sr-only' form_group_class="form-group mb-0" %} +
    + {% bootstrap_field form.section show_label='sr-only' form_group_class="form-group d-inline-block mb-0" %} + +
    - {{ form.state }} - {{ form.id }} + {% bootstrap_form form exclude="section" layout="inline" %}
    -
  • \ No newline at end of file + \ No newline at end of file diff --git a/ephios/plugins/basesignup/urls.py b/ephios/plugins/basesignup/urls.py index 9153ba1d0..53a58e1a6 100644 --- a/ephios/plugins/basesignup/urls.py +++ b/ephios/plugins/basesignup/urls.py @@ -1,18 +1,17 @@ from django.urls import path -from ephios.plugins.basesignup.signup.section_based import SectionBasedDispositionView -from ephios.plugins.basesignup.signup.simple import RequestConfirmDispositionView +from ephios.plugins.basesignup.signup.disposition import AddUserView, DispositionView app_name = "basesignup" urlpatterns = [ path( - "shifts//disposition/requestconfirm", - RequestConfirmDispositionView.as_view(), - name="shift_disposition_requestconfirm", + "shifts//disposition/", + DispositionView.as_view(), + name="shift_disposition", ), path( - "shifts//disposition/section-based", - SectionBasedDispositionView.as_view(), - name="shift_disposition_section_based", + "shifts//disposition/add-user/", + AddUserView.as_view(), + name="shift_disposition_add_user", ), ] diff --git a/ephios/static/ephios/js/formset/LICENSE b/ephios/static/ephios/js/formset/LICENSE new file mode 100644 index 000000000..a26b87db9 --- /dev/null +++ b/ephios/static/ephios/js/formset/LICENSE @@ -0,0 +1,23 @@ +Copyright (c) 2013, Ionata Web Solutions +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/ephios/static/ephios/js/formset/formset.js b/ephios/static/ephios/js/formset/formset.js new file mode 100644 index 000000000..e5f582413 --- /dev/null +++ b/ephios/static/ephios/js/formset/formset.js @@ -0,0 +1,379 @@ +/** + * Django formset helper, adapted from django-formset-js-improved + */ +(function ($) { + "use strict"; + + var pluginName = 'formset'; + + /** + * Wraps up a formset, allowing adding, and removing forms + */ + var Formset = function (el, options) { + var _this = this; + + //Defaults: + this.opts = $.extend({}, Formset.defaults, options); + + this.$formset = $(el); + this.$emptyForm = this.$formset.find(this.opts.emptyForm); + this.$body = this.$formset.find(this.opts.body); + this.$add = this.$formset.find(this.opts.add); + + this.formsetPrefix = $(el).data('formset-prefix'); + + // Bind to the `Add form` button + this.addForm = $.proxy(this, 'addForm'); + this.$add.click(this.addForm); + + // Bind receiver to `formAdded` and `formDeleted` events + this.$formset.on('formAdded formDeleted', this.opts.form, $.proxy(this, 'checkMaxForms')); + + // Set up the existing forms + this.$forms().each(function (i, form) { + var $form = $(form); + _this.bindForm($(this), i); + }); + + // Fill "ORDER" fields with the current order + this.prefillOrder(); + + // Store a reference to this in the formset element + this.$formset.data(pluginName, this); + + var extras = ['animateForms']; + $.each(extras, function (i, extra) { + if ((extra in _this.opts) && (_this.opts[extra])) { + _this[extra](); + } + }); + }; + + Formset.defaults = { + form: '[data-formset-form]', + emptyForm: 'script[type=form-template][data-formset-empty-form]', + body: '[data-formset-body]', + add: '[data-formset-add]', + deleteButton: '[data-formset-delete-button]', + moveUpButton: '[data-formset-move-up-button]', + moveDownButton: '[data-formset-move-down-button]', + hasMaxFormsClass: 'has-max-forms', + animateForms: false, + reorderMode: 'none', + empty_prefix: '__prefix__' + }; + + Formset.prototype.addForm = function () { + // Don't proceed if the number of maximum forms has been reached + if (this.hasMaxForms()) { + throw new Error("MAX_NUM_FORMS reached"); + } + + var newIndex = this.totalFormCount(); + this.$managementForm('TOTAL_FORMS').val(newIndex + 1); + + var newFormHtml = this.$emptyForm.html() + .replace(new RegExp(this.opts.empty_prefix, 'g'), newIndex) + .replace(new RegExp('<\\\\/script>', 'g'), ''); + + var $newFormFragment = $($.parseHTML(newFormHtml, this.$body.document, true)); + this.$body.append($newFormFragment); + + var $newForm = $newFormFragment.filter(this.opts.form); + this.bindForm($newForm, newIndex); + + var prefix = this.formsetPrefix + '-' + newIndex; + $newForm.find('[name=' + prefix + '-ORDER]').val(newIndex); + $newForm.attr("data-formset-created-at-runtime", "true"); + + return $newForm; + }; + + Formset.prototype.extractPrefix = function ($form) { + const indexRegex = new RegExp("(" + this.formsetPrefix + "-\\d+)-id"); + let toReturn = null; + $form.find(":input").each((idx, element) => { + const name = $(element).attr("name"); + if (name && name.match(indexRegex)) { + toReturn = name.replace(indexRegex, "$1"); + return false; + } + }); + return toReturn; + } + + /** + * Attach any events needed to a new form + */ + Formset.prototype.bindForm = function ($form, index) { + var _this = this; + + // try to find prefix, otherwise guess it from the index + const extractedPrefix = this.extractPrefix($form); + var prefix = extractedPrefix || (this.formsetPrefix + '-' + index); + $form.data(pluginName + '__formPrefix', prefix); + + var $delete = $form.find('[name=' + prefix + '-DELETE]'); + var $order = $form.find('[name=' + prefix + '-ORDER]'); + + var onChangeDelete = function () { + if ($delete.is(':checked')) { + $form.attr('data-formset-form-deleted', ''); + // Remove required property and pattern attribute to allow submit, back it up to data field + $form.find(':required').data(pluginName + '-required-field', true).prop('required', false); + $form.find('input[pattern]').each(function () { + var pattern = $(this).attr('pattern'); + $(this).data(pluginName + '-field-pattern', pattern).removeAttr('pattern'); + }); + $form.trigger('formDeleted'); + } else { + $form.removeAttr('data-formset-form-deleted'); + // Restore required property and pattern attributes from data field + $form.find('*').filter(function () { + return $(this).data(pluginName + '-required-field') === true; + }).prop('required', true); + $form.find('input').each(function () { + var pattern = $(this).data(pluginName + '-field-pattern'); + if (pattern) { + $(this).attr('pattern', pattern); + } + }); + $form.trigger('formAdded'); + } + } + + // Trigger `formAdded` / `formDeleted` events when delete checkbox value changes + $delete.change(onChangeDelete); + + // This will trigger `formAdded` for newly created forms. + // It will also trigger `formAdded` or `formDeleted` for all forms when + // the Formset is first created. + // setTimeout so the caller can register events before the events are + // triggered, during initialisation. + window.setTimeout(onChangeDelete); + + // Delete the form if the delete button is pressed + var $deleteButton = $form.find(this.opts.deleteButton); + $deleteButton.bind('click', function () { + $delete.attr('checked', true).change(); + }); + + $order.change(function (event) { + _this.reorderForms(); + }); + + var $moveUpButton = $form.find(this.opts.moveUpButton); + + $moveUpButton.bind('click', function () { + // Find the closest form with an ORDER value lower + // than ours + var current = $order.val(); + var $nextOrder = null; + _this.$activeForms().each(function (i, form) { + var $o = $(form).find('[name*=ORDER]'); + var order = parseInt($o.val()); + if (order < current && ($nextOrder == null || order > parseInt($nextOrder.val()))) { + $nextOrder = $o; + } + }); + + // Swap the order values + if ($nextOrder != null) { + // Swap the order values + $order.val($nextOrder.val()); + $nextOrder.val(current); + } + + _this.reorderForms(); + }); + + var $moveDownButton = $form.find(this.opts.moveDownButton); + + $moveDownButton.bind('click', function () { + // Find the closest form with an ORDER value higher + // than ours + var current = $order.val(); + var $nextOrder = null; + _this.$activeForms().each(function (i, form) { + var $o = $(form).find('[name*=ORDER]'); + var order = parseInt($o.val()); + if (order > current && ($nextOrder == null || order < parseInt($nextOrder.val()))) { + $nextOrder = $o; + } + }); + + // Swap the order values + if ($nextOrder != null) { + $order.val($nextOrder.val()); + $nextOrder.val(current); + } + + _this.reorderForms(); + }); + }; + + /** + * Enumerate the forms and fill numbers into their ORDER input + * fields, if present. + */ + Formset.prototype.prefillOrder = function () { + var _this = this; + this.$forms().each(function (i, form) { + var prefix = _this.formsetPrefix + '-' + i; + var $order = $(form).find('[name=' + prefix + '-ORDER]'); + $order.val(i); + }); + } + + /** + * Enumerate the forms and fill numbers into their ORDER input + * fields, if present. + */ + Formset.prototype.reorderForms = function () { + var _this = this; + + var compareForms = function (form_a, form_b) { + /** + * Compare two forms based on their ORDER input value. + */ + var a = parseInt($(form_a).find('[name*=-ORDER]').val()); + var b = parseInt($(form_b).find('[name*=-ORDER]').val()); + return (a < b ? -1 : (a > b ? 1 : 0)); + } + var $forms = this.$activeForms().sort(compareForms); + + if (this.opts.reorderMode == 'dom') { + $forms.reverse().each(function (i, form) { + // Move the forms to the top of $body, one by one + _this.$body.prepend($(form)); + }); + } else if (this.opts.reorderMode == 'animate') { + var accumulatedHeight = 0; + + // Setup the CSS + if (this.$body.css("position") != "relative") { + this.$body.css("height", this.$body.outerHeight(true) + "px"); + this.$body.css("position", "relative"); + this.$activeForms().each(function (i, form) { + $(form).css("position", "absolute"); + $(form).css("top", accumulatedHeight + "px"); + accumulatedHeight += $(form).outerHeight(true); + }); + accumulatedHeight = 0; + } + + // Do the animation + $forms.each(function (i, form) { + $(form).stop().animate({ + "top": accumulatedHeight + "px" + }, 1000); + accumulatedHeight += $(form).outerHeight(true); + }); + this.$body.css("height", accumulatedHeight + "px"); + + // Reset the CSS + window.setTimeout(function () { + $forms.reverse().each(function (i, form) { + $(form).css("position", "static"); + // Move the forms to the top of $body, one by one + _this.$body.prepend($(form)); + }); + _this.$body.css("position", "static"); + _this.$body.css("height", "auto"); + }, 1000); + } + } + + Formset.prototype.$forms = function () { + return this.$body.find(this.opts.form); + }; + + Formset.prototype.$activeForms = function () { + return this.$body.find(this.opts.form).not("[data-formset-form-deleted]"); + }; + + Formset.prototype.$managementForm = function (name) { + return this.$formset.find('[name=' + this.formsetPrefix + '-' + name + ']'); + }; + + Formset.prototype.totalFormCount = function () { + return this.$forms().length; + }; + + Formset.prototype.deletedFormCount = function () { + return this.$forms().filter('[data-formset-form-deleted]').length; + }; + + Formset.prototype.activeFormCount = function () { + return this.totalFormCount() - this.deletedFormCount(); + }; + + Formset.prototype.hasMaxForms = function () { + var maxForms = parseInt(this.$managementForm('MAX_NUM_FORMS').val(), 10) || 1000; + return this.activeFormCount() >= maxForms; + }; + + Formset.prototype.checkMaxForms = function () { + if (this.hasMaxForms()) { + this.$formset.addClass(this.opts.hasMaxFormsClass); + this.$add.attr('disabled', 'disabled'); + } else { + this.$formset.removeClass(this.opts.hasMaxFormsClass); + this.$add.removeAttr('disabled'); + } + return false; + }; + + Formset.prototype.animateForms = function () { + this.$formset.on('formAdded', this.opts.form, function () { + var $form = $(this); + if ($form.attr("data-formset-created-at-runtime") == "true") { + $form.slideUp(0); + $form.slideDown(); + } + return false; + }).on('formDeleted', this.opts.form, function () { + var $form = $(this); + $form.slideUp(); + return false; + }); + this.$forms().filter('[data-formset-form-deleted]').slideUp(0); + }; + + Formset.getOrCreate = function (el, options) { + var rev = $(el).data(pluginName); + if (!rev) { + rev = new Formset(el, options); + } + + return rev; + }; + + $.fn[pluginName] = function () { + var options, fn, args; + // Create a new Formset for each element + if (arguments.length === 0 || (arguments.length === 1 && $.type(arguments[0]) != 'string')) { + options = arguments[0]; + return this.each(function () { + return Formset.getOrCreate(this, options); + }); + } + + // Call a function on each Formset in the selector + fn = arguments[0]; + args = $.makeArray(arguments).slice(1); + + if (fn in Formset) { + // Call the Formset class method if it exists + args.unshift(this); + return Formset[fn].apply(Formset, args); + } else { + throw new Error("Unknown function call " + fn + " for $.fn.formset"); + } + }; + + // Enable the array function 'reverse' for collections of jQuery + // elements by including the shortest jQuery plugin ever. + $.fn.reverse = [].reverse; + +})(jQuery); diff --git a/ephios/static/ephios/js/main.js b/ephios/static/ephios/js/main.js index 9a3e1cd3b..87c023aa5 100644 --- a/ephios/static/ephios/js/main.js +++ b/ephios/static/ephios/js/main.js @@ -15,23 +15,6 @@ $(document).ready(function () { // Configure all prerendered Forms handleForms($(document)); - // Used for disposition TODO: move to plugin specific JS! - $("[data-drop-to-state]").each(function (index, elem) { - Sortable.create(elem, { - group: "participations", - sort: true, - draggable: ".draggable", - emptyInsertThreshold: 50, - fallbackTolerance: 5, - animation: 150, - easing: "cubic-bezier(1, 0, 0, 1)", - onAdd: function (event) { - const newState = $(event.target).data("drop-to-state"); - $(event.item).find(".state-input").val(newState); - }, - }); - }); - var recurrenceFields = document.querySelectorAll('.recurrence-widget'); Array.prototype.forEach.call(recurrenceFields, function (field, index) { new recurrence.widget.Widget(field.id, {}); diff --git a/ephios/static/plugins/basesignup/js/disposition.js b/ephios/static/plugins/basesignup/js/disposition.js new file mode 100644 index 000000000..d3a7da36a --- /dev/null +++ b/ephios/static/plugins/basesignup/js/disposition.js @@ -0,0 +1,98 @@ +$(document).ready(function () { + function handleDispositionForm($form, state, instant) { + $form.find("[data-show-for-state]").each((index, el) => { + el = $(el); + if (el.attr("data-show-for-state").split(",").includes(state.toString())) { + el.slideDown(); + } else if (!instant) { + el.slideUp(); + } else { + el.fadeOut(0); + } + }); + } + + $("[data-drop-to-state]").each(function (index, elem) { + const newState = $(elem).data("drop-to-state"); + Sortable.create(elem, { + group: "participations", + sort: true, + draggable: ".draggable", + emptyInsertThreshold: 20, + fallbackTolerance: 5, + animation: 150, + easing: "cubic-bezier(1, 0, 0, 1)", + // scrolling + scrollSensitivity: 150, + scrollSpeed: 15, + // set state + onAdd: (event) => { + $(event.item).find(".state-input").val(newState); + handleDispositionForm($(event.item), newState, false); + }, + }); + handleDispositionForm($(elem), newState, true); + }); + + $("select#id_user[form='add-user-form']").on('select2:close', function () { + // clear select2 + const userSelect = $(this); + setTimeout(() => { + userSelect.val(null).change() + }); + + const spawn = $("[data-formset-spawn]"); + const formset = $('#participations-form').formset('getOrCreate'); + // look for existing form with that participation + const userId = $(this).val(); + if (!userId) { + return; + } + + const participation = $('[data-participant-id=' + userId + ']'); + if (participation.length) { + // we already have that card + const prefix = formset.extractPrefix(participation); + const deleteCheckbox = participation.find('[name=' + prefix + '-DELETE]'); + if (deleteCheckbox.attr("checked")) { + // was marked for deletion, so revert that. + participation.attr("data-formset-created-at-runtime", true); // so formset.js decides to slideDown() it + deleteCheckbox.attr("checked", false).change(); + } + // now visible. Move it to here. + $([document.documentElement, document.body]).animate({ + scrollTop: participation.offset().top - 200 + }, 1000); + participation.focus(); + participation.addClass("list-group-item-info"); + setTimeout(() => { + participation.removeClass("list-group-item-info") + }, 2000); + } else { + // get the new form from the server + const addUserForm = $("#" + $(this)[0].form.id); + $.ajax({ + url: addUserForm.attr("action"), + type: 'post', + dataType: 'html', + data: addUserForm.serialize(), + success: function (data) { + // adapted from addForm from formset.js + // update management form + const newIndex = formset.totalFormCount() + 1; + formset.$managementForm('TOTAL_FORMS').val(newIndex); + formset.$managementForm('INITIAL_FORMS').val(newIndex); + + // insert html + const $newFormFragment = $($.parseHTML(data)); + spawn.append($newFormFragment); + + var $newForm = $newFormFragment.filter(formset.opts.form); + formset.bindForm($newForm, newIndex); + $newForm.attr("data-formset-created-at-runtime", "true"); + } + }); + } + }); + +}); \ No newline at end of file diff --git a/ephios/static/sortablejs/Sortable.min.js b/ephios/static/sortablejs/Sortable.min.js index eba061497..4fe7f0c36 100644 --- a/ephios/static/sortablejs/Sortable.min.js +++ b/ephios/static/sortablejs/Sortable.min.js @@ -1,2 +1,2 @@ -/*! Sortable 1.10.2 - MIT | git://github.com/SortableJS/Sortable.git */ -!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function o(t){return(o="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function a(){return(a=Object.assign||function(t){for(var e=1;e"===e[0]&&(e=e.substring(1)),t)try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return!1}return!1}}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"===e[0]?t.parentNode===n&&h(t,e):h(t,e))||o&&t===n)return t;if(t===n)break}while(t=(i=t).host&&i!==document&&i.host.nodeType?i.host:i.parentNode)}var i;return null}var f,p=/\s+/g;function k(t,e,n){if(t&&e)if(t.classList)t.classList[n?"add":"remove"](e);else{var o=(" "+t.className+" ").replace(p," ").replace(" "+e+" "," ");t.className=(o+(n?" "+e:"")).replace(p," ")}}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];e in o||-1!==e.indexOf("webkit")||(e="-webkit-"+e),o[e]=n+("string"==typeof n?"":"px")}}function v(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform");o&&"none"!==o&&(n=o+" "+n)}while(!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function g(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i=e.left-n&&r<=e.right+n,i=a>=e.top-n&&a<=e.bottom+n;return n&&o&&i?l=t:void 0}}),l}((t=t.touches?t.touches[0]:t).clientX,t.clientY);if(e){var n={};for(var o in t)t.hasOwnProperty(o)&&(n[o]=t[o]);n.target=n.rootEl=e,n.preventDefault=void 0,n.stopPropagation=void 0,e[j]._onDragOver(n)}}}function kt(t){z&&z.parentNode[j]._isOutsideThisEl(t.target)}function Rt(t,e){if(!t||!t.nodeType||1!==t.nodeType)throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(t));this.el=t,this.options=e=a({},e),t[j]=this;var n={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(t.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return Ot(t,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,e){t.setData("Text",e.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:!1!==Rt.supportPointer&&"PointerEvent"in window,emptyInsertThreshold:5};for(var o in O.initializePlugins(this,t,n),n)o in e||(e[o]=n[o]);for(var i in At(e),this)"_"===i.charAt(0)&&"function"==typeof this[i]&&(this[i]=this[i].bind(this));this.nativeDraggable=!e.forceFallback&&xt,this.nativeDraggable&&(this.options.touchStartThreshold=1),e.supportPointer?u(t,"pointerdown",this._onTapStart):(u(t,"mousedown",this._onTapStart),u(t,"touchstart",this._onTapStart)),this.nativeDraggable&&(u(t,"dragover",this),u(t,"dragenter",this)),bt.push(this.el),e.store&&e.store.get&&this.sort(e.store.get(this)||[]),a(this,T())}function Xt(t,e,n,o,i,r,a,l){var s,c,u=t[j],d=u.options.onMove;return!window.CustomEvent||w||E?(s=document.createEvent("Event")).initEvent("move",!0,!0):s=new CustomEvent("move",{bubbles:!0,cancelable:!0}),s.to=e,s.from=t,s.dragged=n,s.draggedRect=o,s.related=i||e,s.relatedRect=r||X(e),s.willInsertAfter=l,s.originalEvent=a,t.dispatchEvent(s),d&&(c=d.call(u,s,a)),c}function Yt(t){t.draggable=!1}function Bt(){Dt=!1}function Ft(t){for(var e=t.tagName+t.className+t.src+t.href+t.textContent,n=e.length,o=0;n--;)o+=e.charCodeAt(n);return o.toString(36)}function Ht(t){return setTimeout(t,0)}function Lt(t){return clearTimeout(t)}Rt.prototype={constructor:Rt,_isOutsideThisEl:function(t){this.el.contains(t)||t===this.el||(ht=null)},_getDirection:function(t,e){return"function"==typeof this.options.direction?this.options.direction.call(this,t,e,z):this.options.direction},_onTapStart:function(e){if(e.cancelable){var n=this,o=this.el,t=this.options,i=t.preventOnFilter,r=e.type,a=e.touches&&e.touches[0]||e.pointerType&&"touch"===e.pointerType&&e,l=(a||e).target,s=e.target.shadowRoot&&(e.path&&e.path[0]||e.composedPath&&e.composedPath()[0])||l,c=t.filter;if(function(t){St.length=0;var e=t.getElementsByTagName("input"),n=e.length;for(;n--;){var o=e[n];o.checked&&St.push(o)}}(o),!z&&!(/mousedown|pointerdown/.test(r)&&0!==e.button||t.disabled||s.isContentEditable||(l=P(l,t.draggable,o,!1))&&l.animated||Z===l)){if(J=F(l),et=F(l,t.draggable),"function"==typeof c){if(c.call(this,e,l,this))return W({sortable:n,rootEl:s,name:"filter",targetEl:l,toEl:o,fromEl:o}),K("filter",n,{evt:e}),void(i&&e.cancelable&&e.preventDefault())}else if(c&&(c=c.split(",").some(function(t){if(t=P(s,t.trim(),o,!1))return W({sortable:n,rootEl:t,name:"filter",targetEl:l,fromEl:o,toEl:o}),K("filter",n,{evt:e}),!0})))return void(i&&e.cancelable&&e.preventDefault());t.handle&&!P(s,t.handle,o,!1)||this._prepareDragStart(e,a,l)}}},_prepareDragStart:function(t,e,n){var o,i=this,r=i.el,a=i.options,l=r.ownerDocument;if(n&&!z&&n.parentNode===r){var s=X(n);if(q=r,G=(z=n).parentNode,V=z.nextSibling,Z=n,ot=a.group,rt={target:Rt.dragged=z,clientX:(e||t).clientX,clientY:(e||t).clientY},ct=rt.clientX-s.left,ut=rt.clientY-s.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,z.style["will-change"]="all",o=function(){K("delayEnded",i,{evt:t}),Rt.eventCanceled?i._onDrop():(i._disableDelayedDragEvents(),!c&&i.nativeDraggable&&(z.draggable=!0),i._triggerDragStart(t,e),W({sortable:i,name:"choose",originalEvent:t}),k(z,a.chosenClass,!0))},a.ignore.split(",").forEach(function(t){g(z,t.trim(),Yt)}),u(l,"dragover",Pt),u(l,"mousemove",Pt),u(l,"touchmove",Pt),u(l,"mouseup",i._onDrop),u(l,"touchend",i._onDrop),u(l,"touchcancel",i._onDrop),c&&this.nativeDraggable&&(this.options.touchStartThreshold=4,z.draggable=!0),K("delayStart",this,{evt:t}),!a.delay||a.delayOnTouchOnly&&!e||this.nativeDraggable&&(E||w))o();else{if(Rt.eventCanceled)return void this._onDrop();u(l,"mouseup",i._disableDelayedDrag),u(l,"touchend",i._disableDelayedDrag),u(l,"touchcancel",i._disableDelayedDrag),u(l,"mousemove",i._delayedDragTouchMoveHandler),u(l,"touchmove",i._delayedDragTouchMoveHandler),a.supportPointer&&u(l,"pointermove",i._delayedDragTouchMoveHandler),i._dragStartTimer=setTimeout(o,a.delay)}}},_delayedDragTouchMoveHandler:function(t){var e=t.touches?t.touches[0]:t;Math.max(Math.abs(e.clientX-this._lastX),Math.abs(e.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){z&&Yt(z),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;d(t,"mouseup",this._disableDelayedDrag),d(t,"touchend",this._disableDelayedDrag),d(t,"touchcancel",this._disableDelayedDrag),d(t,"mousemove",this._delayedDragTouchMoveHandler),d(t,"touchmove",this._delayedDragTouchMoveHandler),d(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||"touch"==t.pointerType&&t,!this.nativeDraggable||e?this.options.supportPointer?u(document,"pointermove",this._onTouchMove):u(document,e?"touchmove":"mousemove",this._onTouchMove):(u(z,"dragend",this),u(q,"dragstart",this._onDragStart));try{document.selection?Ht(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch(t){}},_dragStarted:function(t,e){if(vt=!1,q&&z){K("dragStarted",this,{evt:e}),this.nativeDraggable&&u(document,"dragover",kt);var n=this.options;t||k(z,n.dragClass,!1),k(z,n.ghostClass,!0),Rt.active=this,t&&this._appendGhost(),W({sortable:this,name:"start",originalEvent:e})}else this._nulling()},_emulateDragOver:function(){if(at){this._lastX=at.clientX,this._lastY=at.clientY,Nt();for(var t=document.elementFromPoint(at.clientX,at.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(at.clientX,at.clientY))!==e;)e=t;if(z.parentNode[j]._isOutsideThisEl(t),e)do{if(e[j]){if(e[j]._onDragOver({clientX:at.clientX,clientY:at.clientY,target:t,rootEl:e})&&!this.options.dragoverBubble)break}t=e}while(e=e.parentNode);It()}},_onTouchMove:function(t){if(rt){var e=this.options,n=e.fallbackTolerance,o=e.fallbackOffset,i=t.touches?t.touches[0]:t,r=U&&v(U,!0),a=U&&r&&r.a,l=U&&r&&r.d,s=Ct&>&&b(gt),c=(i.clientX-rt.clientX+o.x)/(a||1)+(s?s[0]-Et[0]:0)/(a||1),u=(i.clientY-rt.clientY+o.y)/(l||1)+(s?s[1]-Et[1]:0)/(l||1);if(!Rt.active&&!vt){if(n&&Math.max(Math.abs(i.clientX-this._lastX),Math.abs(i.clientY-this._lastY))o.right+10||t.clientX<=o.right&&t.clientY>o.bottom&&t.clientX>=o.left:t.clientX>o.right&&t.clientY>o.top||t.clientX<=o.right&&t.clientY>o.bottom+10}(n,a,this)&&!g.animated){if(g===z)return A(!1);if(g&&l===n.target&&(s=g),s&&(i=X(s)),!1!==Xt(q,l,z,o,s,i,n,!!s))return O(),l.appendChild(z),G=l,N(),A(!0)}else if(s.parentNode===l){i=X(s);var v,m,b,y=z.parentNode!==l,w=!function(t,e,n){var o=n?t.left:t.top,i=n?t.right:t.bottom,r=n?t.width:t.height,a=n?e.left:e.top,l=n?e.right:e.bottom,s=n?e.width:e.height;return o===a||i===l||o+r/2===a+s/2}(z.animated&&z.toRect||o,s.animated&&s.toRect||i,a),E=a?"top":"left",D=Y(s,"top","top")||Y(z,"top","top"),S=D?D.scrollTop:void 0;if(ht!==s&&(m=i[E],yt=!1,wt=!w&&e.invertSwap||y),0!==(v=function(t,e,n,o,i,r,a,l){var s=o?t.clientY:t.clientX,c=o?n.height:n.width,u=o?n.top:n.left,d=o?n.bottom:n.right,h=!1;if(!a)if(l&&pt"===e[0]&&(e=e.substring(1)),t)try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return!1}return!1}}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"===e[0]?t.parentNode===n&&h(t,e):h(t,e))||o&&t===n)return t;if(t===n)break}while(t=(i=t).host&&i!==document&&i.host.nodeType?i.host:i.parentNode)}var i;return null}var f,p=/\s+/g;function k(t,e,n){if(t&&e)if(t.classList)t.classList[n?"add":"remove"](e);else{var o=(" "+t.className+" ").replace(p," ").replace(" "+e+" "," ");t.className=(o+(n?" "+e:"")).replace(p," ")}}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];e in o||-1!==e.indexOf("webkit")||(e="-webkit-"+e),o[e]=n+("string"==typeof n?"":"px")}}function v(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform");o&&"none"!==o&&(n=o+" "+n)}while(!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function g(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i=e.left-n&&r<=e.right+n,i=a>=e.top-n&&a<=e.bottom+n;return n&&o&&i?l=t:void 0}}),l}((t=t.touches?t.touches[0]:t).clientX,t.clientY);if(e){var n={};for(var o in t)t.hasOwnProperty(o)&&(n[o]=t[o]);n.target=n.rootEl=e,n.preventDefault=void 0,n.stopPropagation=void 0,e[j]._onDragOver(n)}}}function kt(t){z&&z.parentNode[j]._isOutsideThisEl(t.target)}function Rt(t,e){if(!t||!t.nodeType||1!==t.nodeType)throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(t));this.el=t,this.options=e=a({},e),t[j]=this;var n={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(t.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return Ot(t,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,e){t.setData("Text",e.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:!1!==Rt.supportPointer&&"PointerEvent"in window&&!u,emptyInsertThreshold:5};for(var o in O.initializePlugins(this,t,n),n)o in e||(e[o]=n[o]);for(var i in Nt(e),this)"_"===i.charAt(0)&&"function"==typeof this[i]&&(this[i]=this[i].bind(this));this.nativeDraggable=!e.forceFallback&&xt,this.nativeDraggable&&(this.options.touchStartThreshold=1),e.supportPointer?d(t,"pointerdown",this._onTapStart):(d(t,"mousedown",this._onTapStart),d(t,"touchstart",this._onTapStart)),this.nativeDraggable&&(d(t,"dragover",this),d(t,"dragenter",this)),bt.push(this.el),e.store&&e.store.get&&this.sort(e.store.get(this)||[]),a(this,T())}function Xt(t,e,n,o,i,r,a,l){var s,c,u=t[j],d=u.options.onMove;return!window.CustomEvent||w||E?(s=document.createEvent("Event")).initEvent("move",!0,!0):s=new CustomEvent("move",{bubbles:!0,cancelable:!0}),s.to=e,s.from=t,s.dragged=n,s.draggedRect=o,s.related=i||e,s.relatedRect=r||X(e),s.willInsertAfter=l,s.originalEvent=a,t.dispatchEvent(s),d&&(c=d.call(u,s,a)),c}function Yt(t){t.draggable=!1}function Bt(){Dt=!1}function Ft(t){for(var e=t.tagName+t.className+t.src+t.href+t.textContent,n=e.length,o=0;n--;)o+=e.charCodeAt(n);return o.toString(36)}function Ht(t){return setTimeout(t,0)}function Lt(t){return clearTimeout(t)}Rt.prototype={constructor:Rt,_isOutsideThisEl:function(t){this.el.contains(t)||t===this.el||(ht=null)},_getDirection:function(t,e){return"function"==typeof this.options.direction?this.options.direction.call(this,t,e,z):this.options.direction},_onTapStart:function(e){if(e.cancelable){var n=this,o=this.el,t=this.options,i=t.preventOnFilter,r=e.type,a=e.touches&&e.touches[0]||e.pointerType&&"touch"===e.pointerType&&e,l=(a||e).target,s=e.target.shadowRoot&&(e.path&&e.path[0]||e.composedPath&&e.composedPath()[0])||l,c=t.filter;if(function(t){St.length=0;var e=t.getElementsByTagName("input"),n=e.length;for(;n--;){var o=e[n];o.checked&&St.push(o)}}(o),!z&&!(/mousedown|pointerdown/.test(r)&&0!==e.button||t.disabled)&&!s.isContentEditable&&(this.nativeDraggable||!u||!l||"SELECT"!==l.tagName.toUpperCase())&&!((l=P(l,t.draggable,o,!1))&&l.animated||Z===l)){if(J=F(l),et=F(l,t.draggable),"function"==typeof c){if(c.call(this,e,l,this))return W({sortable:n,rootEl:s,name:"filter",targetEl:l,toEl:o,fromEl:o}),K("filter",n,{evt:e}),void(i&&e.cancelable&&e.preventDefault())}else if(c&&(c=c.split(",").some(function(t){if(t=P(s,t.trim(),o,!1))return W({sortable:n,rootEl:t,name:"filter",targetEl:l,fromEl:o,toEl:o}),K("filter",n,{evt:e}),!0})))return void(i&&e.cancelable&&e.preventDefault());t.handle&&!P(s,t.handle,o,!1)||this._prepareDragStart(e,a,l)}}},_prepareDragStart:function(t,e,n){var o,i=this,r=i.el,a=i.options,l=r.ownerDocument;if(n&&!z&&n.parentNode===r){var s=X(n);if(q=r,G=(z=n).parentNode,V=z.nextSibling,Z=n,ot=a.group,rt={target:Rt.dragged=z,clientX:(e||t).clientX,clientY:(e||t).clientY},ct=rt.clientX-s.left,ut=rt.clientY-s.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,z.style["will-change"]="all",o=function(){K("delayEnded",i,{evt:t}),Rt.eventCanceled?i._onDrop():(i._disableDelayedDragEvents(),!c&&i.nativeDraggable&&(z.draggable=!0),i._triggerDragStart(t,e),W({sortable:i,name:"choose",originalEvent:t}),k(z,a.chosenClass,!0))},a.ignore.split(",").forEach(function(t){g(z,t.trim(),Yt)}),d(l,"dragover",Pt),d(l,"mousemove",Pt),d(l,"touchmove",Pt),d(l,"mouseup",i._onDrop),d(l,"touchend",i._onDrop),d(l,"touchcancel",i._onDrop),c&&this.nativeDraggable&&(this.options.touchStartThreshold=4,z.draggable=!0),K("delayStart",this,{evt:t}),!a.delay||a.delayOnTouchOnly&&!e||this.nativeDraggable&&(E||w))o();else{if(Rt.eventCanceled)return void this._onDrop();d(l,"mouseup",i._disableDelayedDrag),d(l,"touchend",i._disableDelayedDrag),d(l,"touchcancel",i._disableDelayedDrag),d(l,"mousemove",i._delayedDragTouchMoveHandler),d(l,"touchmove",i._delayedDragTouchMoveHandler),a.supportPointer&&d(l,"pointermove",i._delayedDragTouchMoveHandler),i._dragStartTimer=setTimeout(o,a.delay)}}},_delayedDragTouchMoveHandler:function(t){var e=t.touches?t.touches[0]:t;Math.max(Math.abs(e.clientX-this._lastX),Math.abs(e.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){z&&Yt(z),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;s(t,"mouseup",this._disableDelayedDrag),s(t,"touchend",this._disableDelayedDrag),s(t,"touchcancel",this._disableDelayedDrag),s(t,"mousemove",this._delayedDragTouchMoveHandler),s(t,"touchmove",this._delayedDragTouchMoveHandler),s(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||"touch"==t.pointerType&&t,!this.nativeDraggable||e?this.options.supportPointer?d(document,"pointermove",this._onTouchMove):d(document,e?"touchmove":"mousemove",this._onTouchMove):(d(z,"dragend",this),d(q,"dragstart",this._onDragStart));try{document.selection?Ht(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch(t){}},_dragStarted:function(t,e){if(vt=!1,q&&z){K("dragStarted",this,{evt:e}),this.nativeDraggable&&d(document,"dragover",kt);var n=this.options;t||k(z,n.dragClass,!1),k(z,n.ghostClass,!0),Rt.active=this,t&&this._appendGhost(),W({sortable:this,name:"start",originalEvent:e})}else this._nulling()},_emulateDragOver:function(){if(at){this._lastX=at.clientX,this._lastY=at.clientY,At();for(var t=document.elementFromPoint(at.clientX,at.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(at.clientX,at.clientY))!==e;)e=t;if(z.parentNode[j]._isOutsideThisEl(t),e)do{if(e[j]){if(e[j]._onDragOver({clientX:at.clientX,clientY:at.clientY,target:t,rootEl:e})&&!this.options.dragoverBubble)break}t=e}while(e=e.parentNode);It()}},_onTouchMove:function(t){if(rt){var e=this.options,n=e.fallbackTolerance,o=e.fallbackOffset,i=t.touches?t.touches[0]:t,r=U&&v(U,!0),a=U&&r&&r.a,l=U&&r&&r.d,s=Ct&>&&b(gt),c=(i.clientX-rt.clientX+o.x)/(a||1)+(s?s[0]-Et[0]:0)/(a||1),u=(i.clientY-rt.clientY+o.y)/(l||1)+(s?s[1]-Et[1]:0)/(l||1);if(!Rt.active&&!vt){if(n&&Math.max(Math.abs(i.clientX-this._lastX),Math.abs(i.clientY-this._lastY))o.right+10||t.clientX<=o.right&&t.clientY>o.bottom&&t.clientX>=o.left:t.clientX>o.right&&t.clientY>o.top||t.clientX<=o.right&&t.clientY>o.bottom+10}(n,a,this)&&!g.animated){if(g===z)return N(!1);if(g&&l===n.target&&(s=g),s&&(i=X(s)),!1!==Xt(q,l,z,o,s,i,n,!!s))return O(),l.appendChild(z),G=l,A(),N(!0)}else if(s.parentNode===l){i=X(s);var v,m,b,y=z.parentNode!==l,w=!function(t,e,n){var o=n?t.left:t.top,i=n?t.right:t.bottom,r=n?t.width:t.height,a=n?e.left:e.top,l=n?e.right:e.bottom,s=n?e.width:e.height;return o===a||i===l||o+r/2===a+s/2}(z.animated&&z.toRect||o,s.animated&&s.toRect||i,a),E=a?"top":"left",D=Y(s,"top","top")||Y(z,"top","top"),S=D?D.scrollTop:void 0;if(ht!==s&&(m=i[E],yt=!1,wt=!w&&e.invertSwap||y),0!==(v=function(t,e,n,o,i,r,a,l){var s=o?t.clientY:t.clientX,c=o?n.height:n.width,u=o?n.top:n.left,d=o?n.bottom:n.right,h=!1;if(!a)if(l&&pt - + {% block javascript %}{% endblock %} diff --git a/ephios/user_management/models.py b/ephios/user_management/models.py index b30a2aa21..1a0d77961 100644 --- a/ephios/user_management/models.py +++ b/ephios/user_management/models.py @@ -95,6 +95,9 @@ class Meta: def get_full_name(self): return self.first_name + " " + self.last_name + def __str__(self): + return self.get_full_name() + def get_short_name(self): return self.first_name diff --git a/ephios/user_management/views/accounts.py b/ephios/user_management/views/accounts.py index b168eff3a..9b1cf6849 100644 --- a/ephios/user_management/views/accounts.py +++ b/ephios/user_management/views/accounts.py @@ -54,8 +54,8 @@ def post(self, request, *args, **kwargs): qualification_formset.save() messages.success( self.request, - _("User {name} ({user}) added successfully.").format( - name=userprofile.get_full_name(), user=userprofile + _("User {name} ({email}) added successfully.").format( + name=userprofile.get_full_name(), email=userprofile.email ), ) if userprofile.is_active: diff --git a/tests/conftest.py b/tests/conftest.py index 79411a729..ecbe6744d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,7 +8,7 @@ from guardian.shortcuts import assign_perm from ephios.event_management.models import Event, EventType, Shift -from ephios.plugins.basesignup.signup.simple import RequestConfirmSignupMethod +from ephios.plugins.basesignup.signup.request_confirm import RequestConfirmSignupMethod from ephios.user_management.consequences import ( QualificationConsequenceHandler, WorkingHoursConsequenceHandler, diff --git a/tests/plugins/basesignup/test_requestconfirm_signup.py b/tests/plugins/basesignup/test_requestconfirm_signup.py index 5e187d7d6..c9b42fc3a 100644 --- a/tests/plugins/basesignup/test_requestconfirm_signup.py +++ b/tests/plugins/basesignup/test_requestconfirm_signup.py @@ -26,11 +26,12 @@ def test_request_confirm_signup_flow(django_app, volunteer, planner, event): # confirm the participation as planner response = django_app.get( - reverse("basesignup:shift_disposition_requestconfirm", kwargs=dict(pk=shift.pk)), + reverse("basesignup:shift_disposition", kwargs=dict(pk=shift.pk)), user=planner, ) - response.form["form-0-state"] = AbstractParticipation.States.CONFIRMED.value - response.form.submit() + form = response.forms["participations-form"] + form["participations-0-state"] = AbstractParticipation.States.CONFIRMED.value + form.submit() assert ( LocalParticipation.objects.get(user=volunteer, shift=shift).state == AbstractParticipation.States.CONFIRMED diff --git a/tests/plugins/basesignup/test_sectionbased_signup.py b/tests/plugins/basesignup/test_sectionbased_signup.py index 7f03474c9..149b44b8d 100644 --- a/tests/plugins/basesignup/test_sectionbased_signup.py +++ b/tests/plugins/basesignup/test_sectionbased_signup.py @@ -119,11 +119,12 @@ def test_signup_flow(django_app, qualified_volunteer, planner, event, sectioned_ # confirm the participation as planner response = django_app.get( - reverse("basesignup:shift_disposition_section_based", kwargs=dict(pk=sectioned_shift.pk)), + reverse("basesignup:shift_disposition", kwargs=dict(pk=sectioned_shift.pk)), user=planner, ) - response.form["participations-0-state"] = AbstractParticipation.States.CONFIRMED.value - response.form.submit() + form = response.forms["participations-form"] + form["participations-0-state"] = AbstractParticipation.States.CONFIRMED.value + form.submit() participation = LocalParticipation.objects.get(user=qualified_volunteer, shift=sectioned_shift) assert participation.state == AbstractParticipation.States.CONFIRMED assert participation.data.get("preferred_section_uuid") == KTW_UUID