diff --git a/src/shiftings/shifts/forms/excuse.py b/src/shiftings/shifts/forms/excuse.py new file mode 100644 index 0000000..b1374eb --- /dev/null +++ b/src/shiftings/shifts/forms/excuse.py @@ -0,0 +1,20 @@ +from typing import Any + +from django import forms +from django.forms import ModelChoiceField +from django.utils.translation import gettext_lazy as _ + +from shiftings.accounts.models import User +from shiftings.shifts.models import Shift + + +class ExcuseOtherForm(forms.Form): + user = ModelChoiceField(queryset=User.objects.none(), label=_('User to excuse')) + + shift: Shift + + def __init__(self, shift: Shift, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.shift = shift + excused_ids = list(shift.excused_users.values_list('pk', flat=True)) + self.fields['user'].queryset = shift.organization.users.exclude(pk__in=excused_ids) diff --git a/src/shiftings/shifts/forms/shift.py b/src/shiftings/shifts/forms/shift.py index 147e851..fc9780f 100644 --- a/src/shiftings/shifts/forms/shift.py +++ b/src/shiftings/shifts/forms/shift.py @@ -19,7 +19,8 @@ class ShiftForm(ModelForm): class Meta: model = Shift fields = ['name', 'place', 'organization', 'event', 'shift_type', 'start', 'end', 'required_users', - 'max_users', 'additional_infos', 'locked'] + 'max_users', 'additional_infos', 'locked', + 'point_weight_override', 'is_mandatory_override'] def __init__(self, *args: Any, instance: Optional[Shift], **kwargs) -> None: super().__init__(*args, instance=instance, **kwargs) @@ -30,6 +31,10 @@ def __init__(self, *args: Any, instance: Optional[Shift], **kwargs) -> None: organization = instance.organization if instance else self.initial['organization'] self.fields['shift_type'].queryset = ShiftType.objects.organization(organization, include_system=include_system) + if not getattr(organization.summary_settings, 'attendance_points_enabled', False): + self.fields.pop('point_weight_override', None) + self.fields.pop('is_mandatory_override', None) + def clean(self) -> Dict[str, Any]: # super.clean ensures that field-level validation is done first cleaned_data = super().clean() @@ -37,7 +42,7 @@ def clean(self) -> Dict[str, Any]: end = cleaned_data.get('end') if start and end and start > end: raise ValidationError(_('End time must be after start time')) - + ## TODO: raise form error if not valid, but first implement proper error display in template max_length = timedelta(minutes=settings.MAX_SHIFT_LENGTH_MINUTES) if end - start > max_length: diff --git a/src/shiftings/shifts/forms/summary.py b/src/shiftings/shifts/forms/summary.py index 11056ae..3c9444f 100644 --- a/src/shiftings/shifts/forms/summary.py +++ b/src/shiftings/shifts/forms/summary.py @@ -7,7 +7,8 @@ class OrganizationShiftSummaryForm(ModelForm): class Meta: model = OrganizationSummarySettings - fields = ['default_time_range_type', 'other_shifts_group_name'] + fields = ['default_time_range_type', 'other_shifts_group_name', + 'attendance_points_enabled', 'no_response_penalty'] class SelectSummaryTimeRangeForm(Form): diff --git a/src/shiftings/shifts/forms/type.py b/src/shiftings/shifts/forms/type.py index 7e0823d..f4df4f0 100644 --- a/src/shiftings/shifts/forms/type.py +++ b/src/shiftings/shifts/forms/type.py @@ -10,7 +10,7 @@ class ShiftTypeForm(forms.ModelForm): class Meta: model = ShiftType - fields = ['organization', 'name', 'color'] + fields = ['organization', 'name', 'color', 'is_mandatory', 'point_weight'] def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) diff --git a/src/shiftings/shifts/migrations/0007_shifttype_attendance_points.py b/src/shiftings/shifts/migrations/0007_shifttype_attendance_points.py new file mode 100644 index 0000000..0617fb3 --- /dev/null +++ b/src/shiftings/shifts/migrations/0007_shifttype_attendance_points.py @@ -0,0 +1,35 @@ +# Generated by Django 6.0.2 on 2026-06-23 12:17 + +from decimal import Decimal + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("shifts", "0006_recurringshift_auto_create_days"), + ] + + operations = [ + migrations.AddField( + model_name="shifttype", + name="is_mandatory", + field=models.BooleanField( + default=False, + help_text="When set, members who do not respond to shifts of this type incur the organisation-wide no-response penalty. Only takes effect when the organisation has Session Points enabled.", + verbose_name="Mandatory Shift Type", + ), + ), + migrations.AddField( + model_name="shifttype", + name="point_weight", + field=models.DecimalField( + decimal_places=2, + default=Decimal("1.00"), + help_text="Points awarded when attending a shift of this type. Only takes effect when the organisation has Session Points enabled.", + max_digits=4, + verbose_name="Session Points Weight", + ), + ), + ] diff --git a/src/shiftings/shifts/migrations/0008_shift_attendance_points.py b/src/shiftings/shifts/migrations/0008_shift_attendance_points.py new file mode 100644 index 0000000..4dd7c61 --- /dev/null +++ b/src/shiftings/shifts/migrations/0008_shift_attendance_points.py @@ -0,0 +1,46 @@ +# Generated by Django 6.0.2 on 2026-06-23 12:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0001_initial"), + ("shifts", "0007_shifttype_attendance_points"), + ] + + operations = [ + migrations.AddField( + model_name="shift", + name="excused_users", + field=models.ManyToManyField( + blank=True, + related_name="excused_from_shifts", + to="accounts.baseuser", + verbose_name="Excused Users", + ), + ), + migrations.AddField( + model_name="shift", + name="is_mandatory_override", + field=models.BooleanField( + blank=True, + help_text="If set, overrides the shift type mandatory flag just for this shift. Leave empty to inherit from the shift type.", + null=True, + verbose_name="Mandatory Override", + ), + ), + migrations.AddField( + model_name="shift", + name="point_weight_override", + field=models.DecimalField( + blank=True, + decimal_places=2, + help_text="If set, overrides the shift type weight just for this shift. Leave empty to inherit from the shift type.", + max_digits=4, + null=True, + verbose_name="Session Points Weight Override", + ), + ), + ] diff --git a/src/shiftings/shifts/migrations/0009_summarysettings_attendance_points.py b/src/shiftings/shifts/migrations/0009_summarysettings_attendance_points.py new file mode 100644 index 0000000..7385926 --- /dev/null +++ b/src/shiftings/shifts/migrations/0009_summarysettings_attendance_points.py @@ -0,0 +1,35 @@ +# Generated by Django 6.0.2 on 2026-06-23 12:20 + +from decimal import Decimal + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("shifts", "0008_shift_attendance_points"), + ] + + operations = [ + migrations.AddField( + model_name="organizationsummarysettings", + name="attendance_points_enabled", + field=models.BooleanField( + default=False, + help_text="Enable per-member Session Points tracking on the shift summary and the shift detail page.", + verbose_name="Session Points enabled", + ), + ), + migrations.AddField( + model_name="organizationsummarysettings", + name="no_response_penalty", + field=models.DecimalField( + decimal_places=2, + default=Decimal("0.33"), + help_text="Points deducted when a member does not respond to a mandatory shift. Applies organisation-wide.", + max_digits=4, + verbose_name="No-Response Penalty", + ), + ), + ] diff --git a/src/shiftings/shifts/models/shift.py b/src/shiftings/shifts/models/shift.py index d9aabd3..7ba5570 100644 --- a/src/shiftings/shifts/models/shift.py +++ b/src/shiftings/shifts/models/shift.py @@ -31,6 +31,17 @@ class Shift(ShiftBase): warnings = models.TextField(max_length=500, verbose_name=_('Warning'), blank=True, null=True, help_text=_('A maximum of {amount} characters is allowed').format(amount=500)) + excused_users = models.ManyToManyField('accounts.BaseUser', verbose_name=_('Excused Users'), blank=True, + related_name='excused_from_shifts') + point_weight_override = models.DecimalField(verbose_name=_('Session Points Weight Override'), + max_digits=4, decimal_places=2, blank=True, null=True, + help_text=_('If set, overrides the shift type weight just for this ' + 'shift. Leave empty to inherit from the shift type.')) + is_mandatory_override = models.BooleanField(verbose_name=_('Mandatory Override'), blank=True, null=True, + help_text=_('If set, overrides the shift type mandatory flag just ' + 'for this shift. Leave empty to inherit from the shift ' + 'type.')) + based_on = models.ForeignKey('RecurringShift', on_delete=models.SET_NULL, related_name='created_shifts', verbose_name=_('Created by Recurring Shift'), blank=True, null=True) diff --git a/src/shiftings/shifts/models/summary.py b/src/shiftings/shifts/models/summary.py index 11ffdb1..5312c22 100644 --- a/src/shiftings/shifts/models/summary.py +++ b/src/shiftings/shifts/models/summary.py @@ -1,3 +1,5 @@ +from decimal import Decimal + from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ @@ -13,6 +15,13 @@ class OrganizationSummarySettings(models.Model): default_time_range_type = models.PositiveSmallIntegerField(choices=TimeRangeType.choices, verbose_name=_('Default time range for summary'), default=TimeRangeType.HalfYear) + attendance_points_enabled = models.BooleanField(verbose_name=_('Session Points enabled'), default=False, + help_text=_('Enable per-member Session Points tracking on the ' + 'shift summary and the shift detail page.')) + no_response_penalty = models.DecimalField(verbose_name=_('No-Response Penalty'), max_digits=4, decimal_places=2, + default=Decimal('0.33'), + help_text=_('Points deducted when a member does not respond to a ' + 'mandatory shift. Applies organisation-wide.')) class Meta: default_permissions = () diff --git a/src/shiftings/shifts/models/type.py b/src/shiftings/shifts/models/type.py index 2b22287..a887fa8 100644 --- a/src/shiftings/shifts/models/type.py +++ b/src/shiftings/shifts/models/type.py @@ -1,5 +1,6 @@ from __future__ import annotations +from decimal import Decimal from typing import Any, TYPE_CHECKING from colorfield.fields import ColorField @@ -40,6 +41,15 @@ class ShiftType(models.Model): name = models.CharField(max_length=100, verbose_name=_('Name')) color = ColorField(default='#FD7E14', format='hex', samples=settings.SHIFT_COLOR_PALETTE) + is_mandatory = models.BooleanField(verbose_name=_('Mandatory Shift Type'), default=False, + help_text=_('When set, members who do not respond to shifts of this type ' + 'incur the organisation-wide no-response penalty. Only takes ' + 'effect when the organisation has Session Points enabled.')) + point_weight = models.DecimalField(verbose_name=_('Session Points Weight'), max_digits=4, decimal_places=2, + default=Decimal('1.00'), + help_text=_('Points awarded when attending a shift of this type. Only takes ' + 'effect when the organisation has Session Points enabled.')) + objects = ShiftTypeManager() class Meta: diff --git a/src/shiftings/shifts/templates/shifts/create_shift.html b/src/shiftings/shifts/templates/shifts/create_shift.html index 9b5b425..a9a6a90 100644 --- a/src/shiftings/shifts/templates/shifts/create_shift.html +++ b/src/shiftings/shifts/templates/shifts/create_shift.html @@ -66,6 +66,18 @@

Update Shift "{{ name }} for {{ organization }}"

{% bootstrap_field form.additional_infos %} + {% if form.point_weight_override %} +
+
{% trans "Session Points (Override)" %}
+
+
+ {% bootstrap_field form.point_weight_override %} +
+
+ {% bootstrap_field form.is_mandatory_override %} +
+
+ {% endif %} @@ -87,4 +99,4 @@

{% trans "Create from Template" %}

{% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/src/shiftings/shifts/templates/shifts/shift.html b/src/shiftings/shifts/templates/shifts/shift.html index 0a87453..bf97705 100644 --- a/src/shiftings/shifts/templates/shifts/shift.html +++ b/src/shiftings/shifts/templates/shifts/shift.html @@ -58,23 +58,47 @@

{{ time }} ago {% endblocktrans %} + {% shift_attendance_info shift %} {% if can_see_participants %}
-
- {% trans "Shift Participants" %}: - {% if not shift.is_full %} - {% if org_perms.add_non_members_to_shifts or org_perms.add_members_to_shifts %} - {% if shift.start.date >= current_date or org_perms.add_to_past_shift %} - - {% trans "Add participant" %} - +
+
+
+ {% trans "Shift Participants" %}: + {% if not shift.is_full %} + {% if org_perms.add_non_members_to_shifts or org_perms.add_members_to_shifts %} + {% if shift.start.date >= current_date or org_perms.add_to_past_shift %} + + + + {% endif %} + {% endif %} {% endif %} - {% endif %} +
+ {% include "shifts/shift_participants.html" with shift=shift %} +
+ {% if shift.organization.summary_settings.attendance_points_enabled %} +
+
+ {% trans "Excused" %}: + {% if org_perms.add_members_to_shifts %} + {% if shift.start.date >= current_date or org_perms.add_to_past_shift %} + + + + {% endif %} + {% endif %} +
+ {% include "shifts/shift_excused.html" with shift=shift %} +
{% endif %}
- {% include "shifts/shift_participants.html" with shift=shift %}
{% trans "Additional Infos" %}:
@@ -92,4 +116,4 @@

-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/src/shiftings/shifts/templates/shifts/shift_excused.html b/src/shiftings/shifts/templates/shifts/shift_excused.html new file mode 100644 index 0000000..58e6663 --- /dev/null +++ b/src/shiftings/shifts/templates/shifts/shift_excused.html @@ -0,0 +1,33 @@ +{% load i18n %} +
+ {% for excused_user in shift.excused_users.all %} + {% include "shifts/template/excused_display.html" with excused_user=excused_user shift=shift %} + {% endfor %} + {% if shift.start.date >= current_date and not shift.locked %} + {% url 'excuse_self_from_shift' shift.pk as excuse_url %} + {% url 'remove_excused' shift.pk request.user.pk as withdraw_url %} + {% is_request_user_excused shift as user_is_excused %} + {% if user_is_participant %} +
+ {% csrf_token %} + +
+ {% elif user_is_excused %} +
+ {% csrf_token %} + +
+ {% else %} +
+ {% csrf_token %} + +
+ {% endif %} + {% endif %} +
diff --git a/src/shiftings/shifts/templates/shifts/shift_participants.html b/src/shiftings/shifts/templates/shifts/shift_participants.html index 478b155..67abcfb 100644 --- a/src/shiftings/shifts/templates/shifts/shift_participants.html +++ b/src/shiftings/shifts/templates/shifts/shift_participants.html @@ -17,12 +17,16 @@ {% endfor %} {% if shift.max_users == 0 %} {% if shift.start.date >= current_date or org_perms.add_to_past_shift %} -
- -
+ {% is_request_user_excused shift as user_is_excused %} + {% if not user_is_participant %} +
+ +
+ {% endif %} {% endif %} {% endif %} -
\ No newline at end of file + diff --git a/src/shiftings/shifts/templates/shifts/template/excused_display.html b/src/shiftings/shifts/templates/shifts/template/excused_display.html new file mode 100644 index 0000000..7696421 --- /dev/null +++ b/src/shiftings/shifts/templates/shifts/template/excused_display.html @@ -0,0 +1,20 @@ +
+
+ {% if request.user.pk == excused_user.pk or org_perms.remove_others_from_shifts %} + {% if org_perms.admin or shift.end.date >= current_date %} + + {% else %} + {{ excused_user.display }} + {% endif %} + {% else %} + {{ excused_user.display }} + {% endif %} +
+
diff --git a/src/shiftings/shifts/templates/shifts/template/member_shift_summary.html b/src/shiftings/shifts/templates/shifts/template/member_shift_summary.html index a2ef581..829ac1a 100644 --- a/src/shiftings/shifts/templates/shifts/template/member_shift_summary.html +++ b/src/shiftings/shifts/templates/shifts/template/member_shift_summary.html @@ -1,3 +1,4 @@ +{% load i18n %} @@ -8,6 +9,12 @@ {% endif %} + {% if attendance_points_enabled %} + + + + + {% endif %} {% for member in members %} @@ -20,7 +27,13 @@ {% endif %} + {% if attendance_points_enabled %} + + + + + {% endif %} {% endfor %} -
{% trans 'Member' %}{{ organization.summary_settings.other_shifts_group_name }}{% trans 'Total' %}{% trans 'Attended' %}{% trans 'Excused' %}{% trans 'Missed' %}{% trans 'Points' %}
{{ member.other }}{{ member.total }}{{ member.attended }}{{ member.excused }}{{ member.missed }}{{ member.points }}
\ No newline at end of file + diff --git a/src/shiftings/shifts/templates/shifts/template/shift_attendance_info.html b/src/shiftings/shifts/templates/shifts/template/shift_attendance_info.html new file mode 100644 index 0000000..1f65b2c --- /dev/null +++ b/src/shiftings/shifts/templates/shifts/template/shift_attendance_info.html @@ -0,0 +1,5 @@ +{% load i18n %} +{% if enabled %} +
{% trans "Session Points" %}
+
{{ point_weight|floatformat:"-2" }}
+{% endif %} diff --git a/src/shiftings/shifts/templatetags/shifts.py b/src/shiftings/shifts/templatetags/shifts.py index 823758d..d4fd11d 100644 --- a/src/shiftings/shifts/templatetags/shifts.py +++ b/src/shiftings/shifts/templatetags/shifts.py @@ -11,6 +11,7 @@ from shiftings.shifts.forms.participant import AddSelfParticipantForm from shiftings.shifts.forms.shift import SelectOrgForm from shiftings.shifts.models import Shift +from shiftings.shifts.utils.scoring import effective_point_weight from shiftings.utils.time.timerange import TimeRangeType register = template.Library() @@ -76,6 +77,30 @@ def get_int(name: str, default: int) -> int: 'total': sum(group_amounts) + others_amount }) members.sort(key=lambda member: -member['total']) + + attendance_enabled = bool(getattr(org.summary_settings, 'attendance_points_enabled', False)) + context['attendance_points_enabled'] = attendance_enabled + if attendance_enabled: + from django.utils import timezone as _tz + from shiftings.shifts.utils.scoring import member_score_data + + scoring_shifts = list( + org.shifts.filter(time_filter, start__lte=_tz.now()) + .select_related('shift_type') + .prefetch_related('participants__user', 'excused_users') + ) + penalty = org.summary_settings.no_response_penalty + + for member in members: + scoring_user = BaseUser.objects.get(pk=member['pk']) + data = member_score_data(scoring_shifts, scoring_user, penalty) + member['attended'] = data['attended'] + member['excused'] = data['excused'] + member['missed'] = data['missed'] + member['points'] = data['score'] + + members.sort(key=lambda m: -m['points']) + context['members'] = members return context @@ -136,3 +161,20 @@ def can_participate(self) -> bool: @register.simple_tag(takes_context=True) def shift_permissions(context, shift: Shift) -> ShiftPermissionHolder: return ShiftPermissionHolder(shift, context.request.user) + + +@register.inclusion_tag('shifts/template/shift_attendance_info.html', takes_context=True) +def shift_attendance_info(context, shift: Shift) -> dict[str, Any]: + settings = shift.organization.summary_settings + return { + 'enabled': settings.attendance_points_enabled, + 'point_weight': effective_point_weight(shift), + } + + +@register.simple_tag(takes_context=True) +def is_request_user_excused(context, shift: Shift) -> bool: + user = context['request'].user + if not user.is_authenticated: + return False + return shift.excused_users.filter(pk=user.pk).exists() diff --git a/src/shiftings/shifts/urls/participant.py b/src/shiftings/shifts/urls/participant.py index 206e64e..db16507 100644 --- a/src/shiftings/shifts/urls/participant.py +++ b/src/shiftings/shifts/urls/participant.py @@ -1,9 +1,13 @@ from django.urls import path +from shiftings.shifts.views.excuse import ExcuseOtherView, ExcuseSelfView, RemoveExcusedView from shiftings.shifts.views.participant import AddOtherParticipantView, AddSelfParticipantView, RemoveParticipantView urlpatterns = [ path('add_me/', AddSelfParticipantView.as_view(), name='add_participant_self'), path('add_other/', AddOtherParticipantView.as_view(), name='add_participant_other'), - path('/remove/', RemoveParticipantView.as_view(), name='remove_participant') + path('/remove/', RemoveParticipantView.as_view(), name='remove_participant'), + path('excuse_me/', ExcuseSelfView.as_view(), name='excuse_self_from_shift'), + path('excuse_other/', ExcuseOtherView.as_view(), name='excuse_other_from_shift'), + path('excused//remove/', RemoveExcusedView.as_view(), name='remove_excused'), ] diff --git a/src/shiftings/shifts/utils/scoring.py b/src/shiftings/shifts/utils/scoring.py new file mode 100644 index 0000000..796bb6e --- /dev/null +++ b/src/shiftings/shifts/utils/scoring.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from decimal import Decimal +from typing import Iterable, TYPE_CHECKING, TypedDict + +if TYPE_CHECKING: + from shiftings.accounts.models import BaseUser + from shiftings.shifts.models import Shift + + +DEFAULT_POINT_WEIGHT = Decimal("1.00") +DEFAULT_IS_MANDATORY = False + + +class ScoreData(TypedDict): + attended: int + excused: int + missed: int + score: Decimal + + +def effective_point_weight(shift: "Shift") -> Decimal: + """Resolve the points awarded for attending ``shift``. + + Falls back from a per-shift override to the shift type to a global default. + """ + if shift.point_weight_override is not None: + return shift.point_weight_override + if shift.shift_type is not None: + return shift.shift_type.point_weight + return DEFAULT_POINT_WEIGHT + + +def effective_is_mandatory(shift: "Shift") -> bool: + """Resolve whether ``shift`` is mandatory. + + Falls back from a per-shift override to the shift type to a global default. + """ + if shift.is_mandatory_override is not None: + return shift.is_mandatory_override + if shift.shift_type is not None: + return shift.shift_type.is_mandatory + return DEFAULT_IS_MANDATORY + + +def member_score_data(shifts: Iterable["Shift"], user: "BaseUser", + penalty: Decimal) -> ScoreData: + """Compute attended/excused/missed counts and the score saldo for ``user``. + + The caller is responsible for filtering ``shifts`` to the desired time + range and to shifts that have already started. + """ + attended = 0 + excused = 0 + missed = 0 + score = Decimal("0") + for shift in shifts: + is_participant = shift.participants.filter(user=user).exists() + is_excused = shift.excused_users.filter(pk=user.pk).exists() + if is_participant: + attended += 1 + score += effective_point_weight(shift) + elif is_excused: + excused += 1 + elif effective_is_mandatory(shift): + missed += 1 + score -= penalty + return {"attended": attended, "excused": excused, "missed": missed, "score": score} diff --git a/src/shiftings/shifts/views/excuse.py b/src/shiftings/shifts/views/excuse.py new file mode 100644 index 0000000..69b0aef --- /dev/null +++ b/src/shiftings/shifts/views/excuse.py @@ -0,0 +1,93 @@ +from typing import Any + +from django.http import HttpResponse, HttpResponseRedirect +from django.views import View +from django.views.generic.edit import FormView + +from shiftings.accounts.models import BaseUser +from shiftings.organizations.models import Organization +from shiftings.organizations.views.organization_base import OrganizationPermissionMixin +from shiftings.shifts.forms.excuse import ExcuseOtherForm +from shiftings.shifts.models import Shift + + +class _ExcuseShiftMixin(OrganizationPermissionMixin): + def get_shift(self) -> Shift: + return self._get_object(Shift, 'pk') + + def get_organization(self) -> Organization: + return self.get_shift().organization + + +class ExcuseSelfView(_ExcuseShiftMixin, View): + """Mark the current user as excused from the shift. + + If the user is already a Participant on this shift, the Participant is + removed so that changing one's mind from Coming to Excused leaves only + the excused state. + """ + + http_method_names = ['post'] + permission_required = 'organizations.participate_in_shift' + + def post(self, request: Any, *args: Any, **kwargs: Any) -> HttpResponse: + shift = self.get_shift() + for participant in list(shift.participants.filter(user=request.user)): + shift.participants.remove(participant) + participant.delete() + shift.excused_users.add(request.user) + return HttpResponseRedirect(shift.get_absolute_url()) + + +class ExcuseOtherView(_ExcuseShiftMixin, FormView): + """Admin form to add another organisation member to the excused list.""" + + form_class = ExcuseOtherForm + template_name = 'generic/form_card.html' + permission_required = 'organizations.add_members_to_shifts' + + def get_form_kwargs(self) -> dict[str, Any]: + kwargs = super().get_form_kwargs() + kwargs['shift'] = self.get_shift() + return kwargs + + def form_valid(self, form: ExcuseOtherForm) -> HttpResponse: + shift = self.get_shift() + user = form.cleaned_data['user'] + for participant in list(shift.participants.filter(user=user)): + shift.participants.remove(participant) + participant.delete() + shift.excused_users.add(user) + return HttpResponseRedirect(shift.get_absolute_url()) + + def get_success_url(self) -> str: + return self.get_shift().get_absolute_url() + + +class RemoveExcusedView(_ExcuseShiftMixin, View): + """Remove a user from the shift's excused list. + + Self may always withdraw (with participate_in_shift); admins with + remove_others_from_shifts may remove anyone. + """ + + http_method_names = ['post'] + + def has_permission(self) -> bool: + target_pk = int(self.kwargs['user_pk']) + if target_pk == self.request.user.pk: + return self.request.user.has_perm( + 'organizations.participate_in_shift', self.get_organization() + ) + return self.request.user.has_perm( + 'organizations.remove_others_from_shifts', self.get_organization() + ) + + def post(self, request: Any, *args: Any, **kwargs: Any) -> HttpResponse: + shift = self.get_shift() + user = BaseUser.objects.filter(pk=kwargs['user_pk']).first() + if user is not None: + shift.excused_users.remove(user) + if request.POST.get('success_url'): + return HttpResponseRedirect(str(request.POST['success_url'])) + return HttpResponseRedirect(shift.get_absolute_url()) diff --git a/src/shiftings/shifts/views/participant.py b/src/shiftings/shifts/views/participant.py index 57af757..e4357b7 100644 --- a/src/shiftings/shifts/views/participant.py +++ b/src/shiftings/shifts/views/participant.py @@ -52,6 +52,7 @@ def form_valid(self, form: AddSelfParticipantForm) -> HttpResponse: raise Http403() self.object = form.save() shift.participants.add(self.object) + shift.excused_users.remove(form.cleaned_data['user']) shift.save() return self.success @@ -80,6 +81,7 @@ def form_valid(self, form: AddSelfParticipantForm) -> HttpResponse: shift = self.get_shift() self.object = form.save() shift.participants.add(self.object) + shift.excused_users.remove(self.request.user) shift.save() return self.success