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'),
Comment thread
pablo-schmeiser marked this conversation as resolved.
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 %}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be nicer to by default load the orgs default point weight here

</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 %}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is excusing oneself when you're neither a participant nor excused intended?
I would assume users should in this case get displayed nothing

<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