Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
6bc3b30
init
crosspolar Jun 14, 2026
a4ff278
migration
crosspolar Jun 14, 2026
ac0ed64
add watched_capabilities to Shiftwatchcreationservice
crosspolar Jun 14, 2026
e9b5941
add more
crosspolar Jun 14, 2026
c0ef10a
trnaslatsddf
crosspolar Jun 14, 2026
c987db5
Merge branch 'master' into 790-watch-abcd-shift-by-qualification
crosspolar Jun 16, 2026
dbc18ee
tests
crosspolar Jun 16, 2026
c1e5ee4
translation
crosspolar Jun 16, 2026
8e0de18
test_createRecurringShiftWatch_neitherStaffingStatusNorCapabilities_v…
crosspolar Jun 16, 2026
fb36117
explicitly set staffing_status
crosspolar Jun 26, 2026
d5a83ab
assert_email_send now expects string
crosspolar Jun 26, 2026
17cf67c
watched_capabilities
crosspolar Jun 26, 2026
68fefe5
use slot-objects in list not pks
crosspolar Jun 26, 2026
9b2c2bc
test_handle_noStaffingStatusSelected_noMailSent
crosspolar Jun 30, 2026
53fa85a
fix that capabilities are shown in str
crosspolar Jun 30, 2026
48318de
Merge branch 'master' into 790-watch-abcd-shift-by-qualification
crosspolar Jul 3, 2026
eaa4f2c
fix: only consider staffing_status if user wants to and only consider…
crosspolar Jul 3, 2026
0020849
remove print()
crosspolar Jul 3, 2026
1c62efe
remove print()
crosspolar Jul 3, 2026
0606514
ShiftUserCapabilities should array
crosspolar Jul 3, 2026
9bd4b3d
add another capability to be sure
crosspolar Jul 3, 2026
4798188
test_handle_registerAnotherUserToSameCapability_MailSent
crosspolar Jul 3, 2026
6b1ca1d
linting
crosspolar Jul 7, 2026
80205ed
translation
crosspolar Jul 9, 2026
35e5c83
Revert "test_handle_registerAnotherUserToSameCapability_MailSent"
crosspolar Jul 9, 2026
5cdae46
Merge branch 'master' into 790-watch-abcd-shift-by-qualification
crosspolar Jul 9, 2026
49f4b6c
translation
crosspolar Jul 10, 2026
fa9697a
fix: send_shift_watch_mail need reason=
crosspolar Jul 10, 2026
90763ec
fix typing
crosspolar Jul 10, 2026
37399a1
add watched_capabilities to all tests
crosspolar Jul 10, 2026
c4e6320
create check_staffing_status and check_watched_capabilities
crosspolar Jul 28, 2026
1d88246
Merge branch 'master' into 790-watch-abcd-shift-by-qualification
crosspolar Jul 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions tapir/shifts/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,7 @@ class Meta:
"weekdays",
"shift_template_group",
"staffing_status",
"watched_capabilities",
]

weekdays = forms.MultipleChoiceField(
Expand All @@ -788,21 +789,35 @@ class Meta:
label=_("ABCD Week"),
)
staffing_status = forms.MultipleChoiceField(
required=True,
required=False,
choices=get_staffingstatus_choices,
label=_("Shift changes you would like to be informed about"),
widget=CheckboxSelectMultiple(),
disabled=False,
)
watched_capabilities = forms.MultipleChoiceField(
required=False,
choices=SHIFT_USER_CAPABILITY_CHOICES.items(),
label=_("Notify me when these capabilities become available or unavailable"),
widget=CheckboxSelectMultiple(),
disabled=False,
help_text=_(
"Get notified when someone with specific skills registers or unregisters"
),
)

WEEKDAYS_ERROR = _(
"If weekdays or %(shift_template_group)s are selected, "
"%(shift_templates)s may not be selected, and vice versa."
)
AT_LEAST_ONE_ERROR = _(
AT_LEAST_ONE_PATTERN_ERROR = _(
"At least one of the fields (%(shift_templates)s, weekdays, or %(shift_template_group)s) must be selected."
)

AT_LEAST_ONE_TARGET_ERROR = _(
"At least one of the fields staffing_status or required capabilities must be selected."
)

def _format_field_names(self):
return {
"shift_template_group": self.fields["shift_template_group"].label,
Expand All @@ -813,6 +828,8 @@ def clean(self):
cleaned_data = super().clean()
shift_templates = cleaned_data.get("shift_templates")
weekdays = cleaned_data.get("weekdays")
staffing_status = cleaned_data.get("staffing_status")
watched_capabilities = cleaned_data.get("watched_capabilities")
cleaned_data["weekdays"] = list(map(int, weekdays))
shift_template_group = cleaned_data.get("shift_template_group")

Expand All @@ -823,6 +840,10 @@ def clean(self):

if not (shift_templates or weekdays or shift_template_group):
raise forms.ValidationError(
self.AT_LEAST_ONE_ERROR % self._format_field_names()
self.AT_LEAST_ONE_PATTERN_ERROR % self._format_field_names()
)
if not (staffing_status or watched_capabilities):
raise forms.ValidationError(
self.AT_LEAST_ONE_TARGET_ERROR % self._format_field_names()
)
return cleaned_data
80 changes: 52 additions & 28 deletions tapir/shifts/management/commands/send_shift_watch_mail.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,52 @@
from tapir.shifts.services.shift_watch_creation_service import ShiftWatchCreator


def check_staffing_status(
shift_watch_data: ShiftWatch,
valid_attendances_count: int,
notification_reasons: list[str],
) -> None:
"""Check for staffing status changes and add notifications if needed."""
if len(shift_watch_data.staffing_status) == 0:
return

# Determine staffing status
current_status = ShiftWatchCreator.get_staffing_status_if_changed(
number_of_available_slots=shift_watch_data.shift.slots.count(),
valid_attendances=valid_attendances_count,
required_attendances=shift_watch_data.shift.num_required_attendances,
last_status=shift_watch_data.last_staffing_status,
)
if current_status:
notification_reasons.append(current_status.label)
shift_watch_data.last_staffing_status = current_status

# General attendance change notifications
if not notification_reasons:
if valid_attendances_count > len(shift_watch_data.last_valid_slot_ids):
notification_reasons.append(StaffingStatusChoices.ATTENDANCE_PLUS.label)
elif valid_attendances_count < len(shift_watch_data.last_valid_slot_ids):
notification_reasons.append(StaffingStatusChoices.ATTENDANCE_MINUS.label)


def check_watched_capabilities(
shift_watch_data: ShiftWatch,
this_valid_slot_ids: list,
notification_reasons: list[str],
) -> None:
"""Check for watched capability changes and add notifications if needed."""
if len(shift_watch_data.watched_capabilities) == 0:
return

capability_notifications = ShiftWatchCreator.get_capability_status_changes(
this_valid_slot_ids=this_valid_slot_ids,
last_valid_slot_ids=shift_watch_data.last_valid_slot_ids,
watched_capabilities=shift_watch_data.watched_capabilities,
)
if capability_notifications:
notification_reasons.extend(capability_notifications)


class Command(BaseCommand):
help = "Sent to a member when there is a relevant change in shift staffing and the member wants to know about it."

Expand All @@ -31,36 +77,14 @@ def send_shift_watch_mail_per_user_and_shift(self, shift_watch_data: ShiftWatch)
)

valid_attendances_count = len(this_valid_slot_ids)
required_attendances_count = shift_watch_data.shift.num_required_attendances
number_of_available_slots = shift_watch_data.shift.slots.count()

# Determine staffing status
current_status = ShiftWatchCreator.get_staffing_status_if_changed(
number_of_available_slots=number_of_available_slots,
valid_attendances=valid_attendances_count,
required_attendances=required_attendances_count,
last_status=shift_watch_data.last_staffing_status,
)
if current_status:
notification_reasons.append(current_status.label)
shift_watch_data.last_staffing_status = current_status

# Check watched capabilities
capability_notifications = ShiftWatchCreator.get_capability_status_changes(
this_valid_slot_ids=this_valid_slot_ids,
last_valid_slot_ids=shift_watch_data.last_valid_slot_ids,
watched_capabilities=shift_watch_data.watched_capabilities,

check_staffing_status(
shift_watch_data, valid_attendances_count, notification_reasons
)
notification_reasons.extend(capability_notifications)

# General attendance change notifications
if not notification_reasons:
if valid_attendances_count > len(shift_watch_data.last_valid_slot_ids):
notification_reasons.append(StaffingStatusChoices.ATTENDANCE_PLUS.label)
elif valid_attendances_count < len(shift_watch_data.last_valid_slot_ids):
notification_reasons.append(
StaffingStatusChoices.ATTENDANCE_MINUS.label
)
check_watched_capabilities(
shift_watch_data, this_valid_slot_ids, notification_reasons
)

for reason in notification_reasons:
self.send_shift_watch_mail(shift_watch=shift_watch_data, reason=reason)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,5 @@ def handle(self, *args, **options):
)
if current_status == StaffingStatusChoices.UNDERSTAFFED:
SendShiftWatchCommand.send_shift_watch_mail(
shift_watch_data, staffing_status=StaffingStatusChoices.UNDERSTAFFED
shift_watch_data, reason=StaffingStatusChoices.UNDERSTAFFED
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Generated by Django 5.2.15 on 2026-06-14 18:50

import django.contrib.postgres.fields
import tapir.shifts.models
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("shifts", "0075_shiftwatch_watched_capabilities"),
]

operations = [
migrations.AddField(
model_name="recurringshiftwatch",
name="watched_capabilities",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(
choices=tapir.shifts.models.get_shift_capability_choices,
max_length=128,
),
blank=True,
default=list,
size=None,
),
),
]
18 changes: 16 additions & 2 deletions tapir/shifts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1262,12 +1262,17 @@ class ShiftWatch(models.Model):
def __str__(self):
shift_name = self.shift.get_display_name()
shift_url = self.shift.get_absolute_url()

staffing_statuses = ", ".join(status for status in self.staffing_status)
watched_caps = ", ".join(cap for cap in self.watched_capabilities)

return format_html(
'{} is watching <a href="{}">{}</a> for changes of {}',
'{} is watching <a href="{}">{}</a> for changes of {} (capabilities: {})',
self.user.username,
shift_url,
shift_name,
", ".join(status for status in self.staffing_status),
staffing_statuses,
watched_caps,
)

class Meta:
Expand Down Expand Up @@ -1307,3 +1312,12 @@ class to generate recurring ShiftWatches from.
blank=False,
default=get_staffingstatus_defaults,
)

watched_capabilities = ArrayField(
models.CharField(
max_length=128, choices=get_shift_capability_choices, blank=False
),
default=list,
blank=True,
null=False,
)
7 changes: 4 additions & 3 deletions tapir/shifts/services/shift_watch_creation_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
class ShiftWatchCreator:
@classmethod
def get_staffing_status_for_shift(
cls, shift: Shift, last_status: str = None
cls, shift: Shift, last_status: str | None = None
) -> str | None:
"""
Compute the staffing status for a Shift instance by extracting the required
Expand Down Expand Up @@ -58,7 +58,7 @@ def calculate_staffing_status(
number_of_available_slots: int,
valid_attendances: int,
required_attendances: int,
last_status: str = None,
last_status: str | None = None,
):
"""Determine the staffing status based on attendance counts. Returns None if status has not changed."""
if valid_attendances < required_attendances:
Expand All @@ -81,7 +81,7 @@ def get_staffing_status_if_changed(
number_of_available_slots: int,
valid_attendances: int,
required_attendances: int,
last_status: str = None,
last_status: str | None = None,
) -> None | StaffingStatusChoices:
"""
Determine if the staffing status has changed. Return **None** if the staffing status has not changed.
Expand Down Expand Up @@ -135,6 +135,7 @@ def create_shift_watches_for_recurring(cls, recurring: RecurringShiftWatch):
user=recurring.user,
shift=shift,
staffing_status=recurring.staffing_status,
watched_capabilities=recurring.watched_capabilities,
last_staffing_status=ShiftWatchCreator.get_initial_staffing_status_for_shift(
shift=shift
),
Expand Down
1 change: 1 addition & 0 deletions tapir/shifts/templates/shifts/shiftwatch_overview.html
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ <h5>{% translate "Recurring Shift Watches" %}</h5>
<strong>{% translate "Observed changes:" %}</strong>
<ul>
{% for status in recurringshiftwatch.staffing_status %}<li>{{ status }}</li>{% endfor %}
{% for status in recurringshiftwatch.watched_capabilities %}<li>{{ status }}</li>{% endfor %}
</ul>
</div>
</li>
Expand Down
Loading