{% 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 %}
+
+ {% elif user_is_excused %}
+
+ {% else %}
+
+ {% 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 @@
+
+ {% endif %}
{% for member in members %}
@@ -20,7 +27,13 @@
{{ member.other }}
{% endif %}
{{ member.total }}
+ {% if attendance_points_enabled %}
+
{{ member.attended }}
+
{{ member.excused }}
+
{{ member.missed }}
+
{{ member.points }}
+ {% endif %}
{% endfor %}
-
\ 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