Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 4 additions & 4 deletions tapir/shifts/emails/shift_watch_mail.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from django.utils.translation import gettext_lazy as _


from tapir.coop.models import ShareOwner
from tapir.core.mail_option import MailOption
from tapir.core.tapir_email_builder_base import TapirEmailBuilderBase
Expand All @@ -12,10 +11,10 @@
class ShiftWatchEmailBuilder(TapirEmailBuilderBase):
option = MailOption.OPTIONAL_ENABLED

def __init__(self, shift_watch: ShiftWatch, staffing_status: StaffingStatusChoices):
def __init__(self, shift_watch: ShiftWatch, reason: str):
super().__init__()
self.shift = shift_watch.shift
self.reason = f"{staffing_status.label}: {shift_watch.shift.get_display_name()}"
self.reason = f"{reason}: {shift_watch.shift.get_display_name()}"

@classmethod
def get_unique_id(cls) -> str:
Expand Down Expand Up @@ -53,7 +52,8 @@ def get_dummy_version(cls) -> TapirEmailBuilderBase | None:
if not share_owner or not shift_watch:
return None
mail = cls(
shift_watch=shift_watch, staffing_status=StaffingStatusChoices.UNDERSTAFFED
shift_watch=shift_watch,
reason=StaffingStatusChoices.UNDERSTAFFED.label,
)
mail.get_full_context(
share_owner=share_owner,
Expand Down
14 changes: 13 additions & 1 deletion tapir/shifts/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -718,7 +718,7 @@ def get_shift_object(self) -> Shift | ShiftTemplate:
class ShiftWatchForm(forms.ModelForm):
class Meta:
model = ShiftWatch
fields = ["staffing_status"]
fields = ["staffing_status", "watched_capabilities"]

staffing_status = forms.MultipleChoiceField(
required=False,
Expand All @@ -727,6 +727,16 @@ class Meta:
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"
),
)

def __init__(self, *args, **kwargs):
request_user: TapirUser = kwargs.pop("request_user")
Expand All @@ -737,8 +747,10 @@ def __init__(self, *args, **kwargs):
)
if last_shiftwatch:
self.initial["staffing_status"] = last_shiftwatch.staffing_status
self.initial["watched_capabilities"] = last_shiftwatch.watched_capabilities
else:
self.initial["staffing_status"] = get_staffingstatus_defaults()
self.initial["watched_capabilities"] = []


class ShiftTemplateField(ModelMultipleChoiceField):
Expand Down
57 changes: 16 additions & 41 deletions tapir/shifts/management/commands/send_shift_watch_mail.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,42 +9,21 @@
from tapir.shifts.models import (
ShiftWatch,
StaffingStatusChoices,
ShiftUserCapability,
ShiftSlot,
)
from tapir.shifts.services.shift_watch_creation_service import ShiftWatchCreator


def get_shift_coordinator_status(
this_valid_slot_ids: list[int], last_valid_slot_ids: list[int]
):
# Team-leader/Shift-Coordinator notifications
def is_shift_coordinator_available(slot_ids: list[int]):
return ShiftSlot.objects.filter(
id__in=slot_ids,
required_capabilities__contains=[ShiftUserCapability.SHIFT_COORDINATOR],
)

this_sc_available = is_shift_coordinator_available(this_valid_slot_ids)
last_sc_available = is_shift_coordinator_available(last_valid_slot_ids)
if not this_sc_available and last_sc_available:
return StaffingStatusChoices.SHIFT_COORDINATOR_MINUS
elif this_sc_available and not last_sc_available:
return StaffingStatusChoices.SHIFT_COORDINATOR_PLUS
return None


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."

def handle(self, *args, **options):
for shift_watch_data in ShiftWatch.objects.filter(
shift__end_time__gte=timezone.now() # end_time not start_time because flexible-shifts can be running the whole day
shift__end_time__gte=timezone.now()
).select_related("user", "shift"):
self.send_shift_watch_mail_per_user_and_shift(shift_watch_data)

def send_shift_watch_mail_per_user_and_shift(self, shift_watch_data: ShiftWatch):
notification_reasons: list[StaffingStatusChoices] = []
notification_reasons: list[str] = []

this_valid_slot_ids = ShiftWatchCreator.get_valid_slot_ids(
shift_watch_data.shift
Expand All @@ -62,41 +41,37 @@ def send_shift_watch_mail_per_user_and_shift(self, shift_watch_data: ShiftWatch)
last_status=shift_watch_data.last_staffing_status,
)
if current_status:
notification_reasons.append(current_status)
notification_reasons.append(current_status.label)
shift_watch_data.last_staffing_status = current_status

# Check shift coordinator status
shift_coordinator_status = get_shift_coordinator_status(
this_valid_slot_ids, shift_watch_data.last_valid_slot_ids
# 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,
)
if shift_coordinator_status is not None:
notification_reasons.append(shift_coordinator_status)
notification_reasons.extend(capability_notifications)

# General attendance change notifications
if not notification_reasons:
# If no other status like "Understaffed" or "teamleader registered" appeared, inform user about general change
if valid_attendances_count > len(shift_watch_data.last_valid_slot_ids):
notification_reasons.append(StaffingStatusChoices.ATTENDANCE_PLUS)
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)
notification_reasons.append(
StaffingStatusChoices.ATTENDANCE_MINUS.label
)

# Send notifications
for reason in notification_reasons:
if reason.value in shift_watch_data.staffing_status:
self.send_shift_watch_mail(
shift_watch=shift_watch_data, staffing_status=reason
)
self.send_shift_watch_mail(shift_watch=shift_watch_data, reason=reason)

shift_watch_data.last_valid_slot_ids = this_valid_slot_ids
shift_watch_data.save()

@staticmethod
def send_shift_watch_mail(
shift_watch: ShiftWatch, staffing_status: StaffingStatusChoices
):
def send_shift_watch_mail(shift_watch: ShiftWatch, reason: str):
email_builder = ShiftWatchEmailBuilder(
shift_watch=shift_watch,
staffing_status=staffing_status,
reason=reason,
)
SendMailService.send_to_tapir_user(
actor=None,
Expand Down
28 changes: 28 additions & 0 deletions tapir/shifts/migrations/0075_shiftwatch_watched_capabilities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Generated by Django 5.2.12 on 2026-03-17 06:48

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


class Migration(migrations.Migration):

dependencies = [
("shifts", "0074_alter_shift_num_required_attendances"),
]

operations = [
migrations.AddField(
model_name="shiftwatch",
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,
),
),
]
19 changes: 13 additions & 6 deletions tapir/shifts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1206,12 +1206,6 @@ class StaffingStatusChoices(models.TextChoices):
ATTENDANCE_MINUS = "SLOTS_MINUS", _(
"One attendance or more un-registered, but the shift is neither understaffed nor full or almost full."
)
SHIFT_COORDINATOR_MINUS = "SHIFT_COORDINATOR_MINUS", _(
"The Shift Coordinator has unregistered"
)
SHIFT_COORDINATOR_PLUS = "SHIFT_COORDINATOR_PLUS", _(
"A Shift Coordinator has registered"
)


def get_staffingstatus_choices():
Expand All @@ -1222,6 +1216,10 @@ def get_staffingstatus_defaults():
return [StaffingStatusChoices.FULL, StaffingStatusChoices.UNDERSTAFFED]


def get_shift_capability_choices():
return list(SHIFT_USER_CAPABILITY_CHOICES.items())


class ShiftWatch(models.Model):
user = models.ForeignKey(
TapirUser, related_name="user_watching_shift", on_delete=models.CASCADE
Expand Down Expand Up @@ -1253,6 +1251,15 @@ class ShiftWatch(models.Model):
on_delete=models.CASCADE,
)

watched_capabilities = ArrayField(
models.CharField(
max_length=128, choices=get_shift_capability_choices, blank=False
),
default=list,
blank=True,
null=False,
)

def __str__(self):
shift_name = self.shift.get_display_name()
shift_url = self.shift.get_absolute_url()
Expand Down
40 changes: 40 additions & 0 deletions tapir/shifts/services/shift_watch_creation_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,43 @@ def create_shift_watches_for_shift_based_on_recurring(cls, shift: Shift) -> None

if new_watches:
ShiftWatch.objects.bulk_create(new_watches)

@classmethod
def get_capability_status_changes(
cls,
this_valid_slot_ids: list[int],
last_valid_slot_ids: list[int],
watched_capabilities: list[str],
) -> list[str]:
if not watched_capabilities:
return []

notifications = []

current_slots = ShiftSlot.objects.filter(
id__in=this_valid_slot_ids
).values_list("required_capabilities", flat=True)

last_slots = ShiftSlot.objects.filter(id__in=last_valid_slot_ids).values_list(
"required_capabilities", flat=True
)

current_capabilities_set = set()
for capabilities in current_slots:
current_capabilities_set.update(capabilities)

last_capabilities_set = set()
for capabilities in last_slots:
last_capabilities_set.update(capabilities)

for capability in watched_capabilities:
has_now = capability in current_capabilities_set
had_before = capability in last_capabilities_set

if has_now and not had_before:
notifications.append(f"Member with capability added: {capability}")
elif not has_now and had_before:
notifications.append(
f"Member with capability unregistered: {capability}"
)
return notifications
96 changes: 96 additions & 0 deletions tapir/shifts/tests/test_get_capability_status_change.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import pytest

from tapir.shifts.models import (
ShiftSlot,
SHIFT_USER_CAPABILITY_CHOICES,
ShiftUserCapability,
)
from tapir.shifts.services.shift_watch_creation_service import ShiftWatchCreator
from tapir.shifts.tests.factories import (
ShiftSlotFactory,
ShiftFactory,
ShiftWatchFactory,
)


@pytest.fixture(scope="function")
def set_up_slots(db):
shift = ShiftFactory.create(nb_slots=0)
slot_1 = ShiftSlotFactory.create(
id=1, shift=shift, required_capabilities=[ShiftUserCapability.SHIFT_COORDINATOR]
)
slot_2 = ShiftSlotFactory.create(id=2, shift=shift, required_capabilities=[])
slot_3 = ShiftSlotFactory.create(
id=3, shift=shift, required_capabilities=[ShiftUserCapability.HANDLING_CHEESE]
)
# ShiftWatchFactory.create(shift=shift)
return {
"slot_1": slot_1.id,
"slot_2": slot_2.id,
"slot_3": slot_3.id,
}


@pytest.mark.django_db
@pytest.mark.parametrize(
"this_valid_slot_ids, last_valid_slot_ids, watched_capabilities, expected_notifications",
[
([], [], [], []),
(
[1, 2],
[2],
[ShiftUserCapability.SHIFT_COORDINATOR],
[f"Member with capability added: {ShiftUserCapability.SHIFT_COORDINATOR}"],
),
(
[2],
[1, 2],
[ShiftUserCapability.SHIFT_COORDINATOR],
[
f"Member with capability unregistered: {ShiftUserCapability.SHIFT_COORDINATOR}"
],
),
([1, 2], [1, 2], [{ShiftUserCapability.SHIFT_COORDINATOR}], []),
(
[3],
[2],
[ShiftUserCapability.HANDLING_CHEESE],
[f"Member with capability added: {ShiftUserCapability.HANDLING_CHEESE}"],
),
(
[3],
[2],
[ShiftUserCapability.SHIFT_COORDINATOR],
[],
), # only should trigger when HANDLING_CHEESE is watched
(
[2],
[3],
[ShiftUserCapability.HANDLING_CHEESE],
[
f"Member with capability unregistered: {ShiftUserCapability.HANDLING_CHEESE}"
],
),
],
)
def test_get_capability_status_changes(
set_up_slots,
this_valid_slot_ids,
last_valid_slot_ids,
watched_capabilities,
expected_notifications,
):
print(
ShiftSlot.objects.filter(id__in=this_valid_slot_ids).values_list(
"required_capabilities", flat=True
)
)
print(
ShiftSlot.objects.filter(id__in=last_valid_slot_ids).values_list(
"required_capabilities", flat=True
)
)
result = ShiftWatchCreator.get_capability_status_changes(
this_valid_slot_ids, last_valid_slot_ids, watched_capabilities
)
assert result == expected_notifications
Loading
Loading