Skip to content
Draft
20 changes: 20 additions & 0 deletions src/shiftings/shifts/forms/excuse.py
Original file line number Diff line number Diff line change
@@ -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)
9 changes: 7 additions & 2 deletions src/shiftings/shifts/forms/shift.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -30,14 +31,18 @@ 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()
start = cleaned_data.get('start')
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:
Expand Down
3 changes: 2 additions & 1 deletion src/shiftings/shifts/forms/summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion src/shiftings/shifts/forms/type.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
46 changes: 46 additions & 0 deletions src/shiftings/shifts/migrations/0008_shift_attendance_points.py
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
11 changes: 11 additions & 0 deletions src/shiftings/shifts/models/shift.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
9 changes: 9 additions & 0 deletions src/shiftings/shifts/models/summary.py
Original file line number Diff line number Diff line change
@@ -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 _
Expand All @@ -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 = ()
Expand Down
10 changes: 10 additions & 0 deletions src/shiftings/shifts/models/type.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from decimal import Decimal
from typing import Any, TYPE_CHECKING

from colorfield.fields import ColorField
Expand Down Expand Up @@ -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:
Expand Down
14 changes: 13 additions & 1 deletion src/shiftings/shifts/templates/shifts/create_shift.html
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,18 @@ <h4>Update Shift "{{ name }} for {{ organization }}"</h4>
</div>
</div>
{% bootstrap_field form.additional_infos %}
{% if form.point_weight_override %}
<hr>
<div class="text-center"><h5>{% trans "Session Points (Override)" %}</h5></div>
<div class="center-items">
<div class="col-6 me-1">
{% bootstrap_field form.point_weight_override %}
</div>
<div class="col-6 ms-1">
{% bootstrap_field form.is_mandatory_override %}
</div>
</div>
{% endif %}
</form>
</div>
</div>
Expand All @@ -87,4 +99,4 @@ <h4>{% trans "Create from Template" %}</h4>
</div>
{% endif %}
</div>
{% endblock %}
{% endblock %}
46 changes: 35 additions & 11 deletions src/shiftings/shifts/templates/shifts/shift.html
Original file line number Diff line number Diff line change
Expand Up @@ -58,23 +58,47 @@ <h3 class="m-0 w-75">
{{ time }} ago
{% endblocktrans %}
</dd>
{% shift_attendance_info shift %}
</dl>
</div>
{% if can_see_participants %}
<div class="col-12 col-md-7">
<div>
{% 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 %}
<a class="btn btn-outline-success ms-3" href="{% url 'add_participant_other' shift.pk %}">
<i class="fa-solid fa-person-circle-plus me-2"></i>{% trans "Add participant" %}
</a>
<div class="row">
<div class="{% if shift.organization.summary_settings.attendance_points_enabled %}col-12 col-md-6{% else %}col-12{% endif %}">
<div>
{% 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 %}
<a class="btn btn-outline-success btn-sm ms-2"
href="{% url 'add_participant_other' shift.pk %}"
title="{% trans 'Add participant' %}">
<i class="fa-solid fa-person-circle-plus"></i>
</a>
{% endif %}
{% endif %}
{% endif %}
{% endif %}
</div>
{% include "shifts/shift_participants.html" with shift=shift %}
</div>
{% if shift.organization.summary_settings.attendance_points_enabled %}
<div class="col-12 col-md-6 mt-3 mt-md-0">
<div>
{% trans "Excused" %}:
{% if org_perms.add_members_to_shifts %}
{% if shift.start.date >= current_date or org_perms.add_to_past_shift %}
<a class="btn btn-outline-warning btn-sm ms-2"
href="{% url 'excuse_other_from_shift' shift.pk %}"
title="{% trans 'Excuse other user' %}">
<i class="fa-solid fa-person-circle-xmark"></i>
</a>
{% endif %}
{% endif %}
</div>
{% include "shifts/shift_excused.html" with shift=shift %}
</div>
{% endif %}
</div>
{% include "shifts/shift_participants.html" with shift=shift %}
<div class="mt-3">
{% trans "Additional Infos" %}:
<div class="px-3 shift-info overflow-auto">
Expand All @@ -92,4 +116,4 @@ <h3 class="m-0 w-75">
</div>
</div>
</div>
{% endblock %}
{% endblock %}
33 changes: 33 additions & 0 deletions src/shiftings/shifts/templates/shifts/shift_excused.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{% load i18n %}
<div class="mx-3 mt-2 shift-slots shift-info overflow-auto">
{% 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 %}
<form method="post" action="{{ excuse_url }}" class="d-flex mt-2 mx-2">
{% csrf_token %}
<button type="submit" class="btn border border-2 card-link link w-100">
<i class="fa-solid fa-person-circle-xmark me-2"></i>{% trans "I can't come" %}
</button>
</form>
{% elif user_is_excused %}
<form method="post" action="{{ withdraw_url }}" class="d-flex mt-2 mx-2">
{% csrf_token %}
<button type="submit" class="btn border border-2 card-link link w-100">
<i class="fa-solid fa-rotate-left me-2"></i>{% trans 'Withdraw excuse' %}
</button>
</form>
{% else %}
<form method="post" action="{{ excuse_url }}" class="d-flex mt-2 mx-2">
{% csrf_token %}
<button type="submit" class="btn border border-2 card-link link w-100">
<i class="fa-solid fa-person-circle-xmark me-2"></i>{% trans 'Excuse me' %}
</button>
</form>
{% endif %}
{% endif %}
</div>
18 changes: 11 additions & 7 deletions src/shiftings/shifts/templates/shifts/shift_participants.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,16 @@
{% endfor %}
{% if shift.max_users == 0 %}
{% if shift.start.date >= current_date or org_perms.add_to_past_shift %}
<div class="d-flex">
<button type="button" class="btn border border-2 card-link link w-100 mx-2" data-bs-toggle="modal"
data-bs-target="#addSelfForm{{ shift.pk }}Modal">
<i class="fa-solid fa-person-circle-plus me-2"></i>{% trans 'Add me' %}
</button>
</div>
{% is_request_user_excused shift as user_is_excused %}
{% if not user_is_participant %}
<div class="d-flex mt-2">
<button type="button" class="btn border border-2 card-link link w-100 mx-2" data-bs-toggle="modal"
data-bs-target="#addSelfForm{{ shift.pk }}Modal">
<i class="fa-solid fa-person-circle-plus me-2"></i>
{% if user_is_excused %}{% trans 'Add me anyway' %}{% else %}{% trans 'Add me' %}{% endif %}
</button>
</div>
{% endif %}
{% endif %}
{% endif %}
</div>
</div>
Loading