From 127a18f5337ec9fb51dedb800998320187e1ff87 Mon Sep 17 00:00:00 2001 From: Daniel Hauser Date: Wed, 24 Jun 2026 12:33:49 +0200 Subject: [PATCH 1/9] feat: add is_mandatory and point_weight to ShiftType Adds two opt-in fields for the upcoming Session Points feature. Defaults (False, 1.00) preserve current behaviour for every org. --- .../0007_shifttype_attendance_points.py | 35 +++++++++++++++++++ src/shiftings/shifts/models/type.py | 10 ++++++ 2 files changed, 45 insertions(+) create mode 100644 src/shiftings/shifts/migrations/0007_shifttype_attendance_points.py 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/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: From cca72c68b1a5364dabe9c26e709b9c5c4dd73e38 Mon Sep 17 00:00:00 2001 From: Daniel Hauser Date: Wed, 24 Jun 2026 12:33:49 +0200 Subject: [PATCH 2/9] feat: add excused_users M2M and override fields to Shift excused_users lets a member declare they are not coming without being counted as attending. point_weight_override and is_mandatory_override are nullable and fall back to the shift type when blank, so one-off events do not need a dedicated ShiftType. --- .../0008_shift_attendance_points.py | 46 +++++++++++++++++++ src/shiftings/shifts/models/shift.py | 11 +++++ 2 files changed, 57 insertions(+) create mode 100644 src/shiftings/shifts/migrations/0008_shift_attendance_points.py 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/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) From 15fc6a1e515f313ac7a9741dba05e24ef37083fa Mon Sep 17 00:00:00 2001 From: Daniel Hauser Date: Wed, 24 Jun 2026 12:33:49 +0200 Subject: [PATCH 3/9] feat: add Session Points toggle and penalty to summary settings attendance_points_enabled is the single boolean that gates the whole feature per-organisation. no_response_penalty is the org-wide constant deducted for unresponsive mandatory shifts. --- .../0009_summarysettings_attendance_points.py | 35 +++++++++++++++++++ src/shiftings/shifts/models/summary.py | 9 +++++ 2 files changed, 44 insertions(+) create mode 100644 src/shiftings/shifts/migrations/0009_summarysettings_attendance_points.py 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/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 = () From d1452ed4ce2a1c34f36240f3eb92c1b4bcd386eb Mon Sep 17 00:00:00 2001 From: Daniel Hauser Date: Wed, 24 Jun 2026 12:33:49 +0200 Subject: [PATCH 4/9] feat: add Session Points scoring utility Pure functions to resolve effective point weight and mandatory flag for a shift, and to aggregate per-member attended/excused/missed counts and the resulting saldo. Resolution falls back from a per-shift override to the shift type to a default. --- src/shiftings/shifts/utils/scoring.py | 68 +++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 src/shiftings/shifts/utils/scoring.py 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} From 577a5e70ac0d2786328d5b820a5ae89a83504920 Mon Sep 17 00:00:00 2001 From: Daniel Hauser Date: Wed, 24 Jun 2026 12:33:49 +0200 Subject: [PATCH 5/9] feat: expose Session Points fields on type and summary forms ShiftTypeForm gains is_mandatory and point_weight. OrganizationShiftSummaryForm gains attendance_points_enabled and no_response_penalty. --- src/shiftings/shifts/forms/summary.py | 3 ++- src/shiftings/shifts/forms/type.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) 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) From 21e0866ac6e7285af6ce9d6bbb2b3f20204ff0c2 Mon Sep 17 00:00:00 2001 From: Daniel Hauser Date: Wed, 24 Jun 2026 12:33:50 +0200 Subject: [PATCH 6/9] feat: conditionally expose override fields on ShiftForm point_weight_override and is_mandatory_override are added to the ShiftForm but popped at runtime when the bound organisation has Session Points disabled. Orgs without the feature see no extra fields on shift create/edit. --- src/shiftings/shifts/forms/shift.py | 9 +++++++-- .../shifts/templates/shifts/create_shift.html | 14 +++++++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) 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/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 %} From 120be266a6ef9f515ca52febaad811de3f5b23a7 Mon Sep 17 00:00:00 2001 From: Daniel Hauser Date: Wed, 24 Jun 2026 12:33:50 +0200 Subject: [PATCH 7/9] feat: add POST endpoints to set or clear Excused status ExcuseSelfView adds the current user to Shift.excused_users and cleans up any existing Participant. WithdrawExcuseView removes them from the excused list. Both are gated by the existing participate_in_shift permission, POST only, idempotent. --- src/shiftings/shifts/forms/excuse.py | 20 +++++ src/shiftings/shifts/urls/participant.py | 6 +- src/shiftings/shifts/views/excuse.py | 93 +++++++++++++++++++++++ src/shiftings/shifts/views/participant.py | 2 + 4 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 src/shiftings/shifts/forms/excuse.py create mode 100644 src/shiftings/shifts/views/excuse.py 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/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/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 From 4ea83fb2d2fcae8cf5468aa0eaaebb3f4a4d1a35 Mon Sep 17 00:00:00 2001 From: Daniel Hauser Date: Wed, 24 Jun 2026 12:33:50 +0200 Subject: [PATCH 8/9] feat: render Session Points info and Excuse buttons on shift detail Adds an inclusion tag that resolves the effective point weight and mandatory flag for a shift, and a small helper tag to check whether the current request user is on the excused list. The shift detail page shows the Session Points info line; the participants block shows an Excuse or Withdraw button depending on the user's state. All gated by attendance_points_enabled. --- .../shifts/templates/shifts/shift.html | 46 ++++++++++++++----- .../templates/shifts/shift_excused.html | 33 +++++++++++++ .../templates/shifts/shift_participants.html | 18 +++++--- .../shifts/template/excused_display.html | 20 ++++++++ .../template/shift_attendance_info.html | 5 ++ src/shiftings/shifts/templatetags/shifts.py | 18 ++++++++ 6 files changed, 122 insertions(+), 18 deletions(-) create mode 100644 src/shiftings/shifts/templates/shifts/shift_excused.html create mode 100644 src/shiftings/shifts/templates/shifts/template/excused_display.html create mode 100644 src/shiftings/shifts/templates/shifts/template/shift_attendance_info.html 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/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..c93c73b 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() @@ -136,3 +137,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() From 6ade37e3f5d9bd4288ae843b0a0595633d3831b5 Mon Sep 17 00:00:00 2001 From: Daniel Hauser Date: Wed, 24 Jun 2026 12:33:50 +0200 Subject: [PATCH 9/9] feat: show Session Points columns in the summary view Extends member_shift_summary to compute per-member attended, excused, missed and points using the scoring utility. The summary template gains four columns rendered only when the organisation has Session Points enabled, and sorts the table by points descending in that case. --- .../shifts/template/member_shift_summary.html | 15 +++++++++++- src/shiftings/shifts/templatetags/shifts.py | 24 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) 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/templatetags/shifts.py b/src/shiftings/shifts/templatetags/shifts.py index c93c73b..d4fd11d 100644 --- a/src/shiftings/shifts/templatetags/shifts.py +++ b/src/shiftings/shifts/templatetags/shifts.py @@ -77,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