Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added a SlotModificationService to do shift deletion and updates in a controlled way. #497

Merged
merged 5 commits into from
Jun 25, 2024
Merged
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
27 changes: 27 additions & 0 deletions tapir/shifts/management/commands/test_slot_modification_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import datetime

from django.core.management.base import BaseCommand

from tapir.shifts.models import ShiftUserCapability
from tapir.shifts.services.slot_modification_service import SlotModificationService


class Command(BaseCommand):
def handle(self, *args, **options):
changes = [
SlotModificationService.ParameterSet(
workday_or_weekend="workday",
time=datetime.time(hour=8, minute=15),
origin_slot_name=SlotModificationService.SlotNames.WARENANNAHME,
target_slot_name=None,
target_capabilities=None,
),
SlotModificationService.ParameterSet(
workday_or_weekend="weekend",
time=datetime.time(hour=19, minute=15),
origin_slot_name=SlotModificationService.SlotNames.ALLGEMEIN,
target_slot_name=SlotModificationService.SlotNames.KASSE,
target_capabilities=frozenset([ShiftUserCapability.CASHIER]),
),
]
SlotModificationService.apply_changes(changes)
5 changes: 4 additions & 1 deletion tapir/shifts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ def get_group_from_index(index: int) -> ShiftTemplateGroup | None:
return None


# Generate weekdays
# Generate weekdays, 0 is Monday, 6 is Sunday
WEEKDAY_CHOICES = [
(i, _(calendar.day_name[i])) for i in calendar.Calendar().iterweekdays()
]
Expand Down Expand Up @@ -342,6 +342,9 @@ class ShiftSlotTemplate(RequiredCapabilitiesMixin, models.Model):
null=False,
)

def __str__(self):
return f"{self.name}, {self.shift_template}"

def get_display_name(self):
display_name = self.shift_template.get_display_name()
if self.name:
Expand Down
226 changes: 226 additions & 0 deletions tapir/shifts/services/slot_modification_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import csv
import datetime
import io
from dataclasses import dataclass
from typing import List

from django.db import transaction
from django.db.models import Q
from django.utils import timezone

from tapir.accounts.models import TapirUser
from tapir.shifts.models import (
ShiftTemplate,
ShiftSlotTemplate,
ShiftAttendanceTemplate,
ShiftAttendance,
ShiftSlot,
)
from tapir.utils.user_utils import UserUtils


class SlotModificationService:
WORKDAY = "workday"
WEEKEND = "weekend"

@dataclass(frozen=True)
class ParameterSet:
workday_or_weekend: str
tvedeane marked this conversation as resolved.
Show resolved Hide resolved
time: datetime.time
origin_slot_name: str
target_slot_name: str | None # if None, the slot will be deleted
target_capabilities: frozenset | None

class SlotNames:
WARENANNAHME = "Warenannahme & Lager"
KASSE = "Kasse"
REINIGUNG = "Reinigung & Aufräumen"
ALLGEMEIN = ""

@classmethod
def build_changes(
cls,
parameter_sets: List[ParameterSet],
excluded_shift_template_ids: list[int] | None = None,
):
return {
parameter_set: cls.pick_slots_to_modify(
parameter_set, excluded_shift_template_ids
)
for parameter_set in parameter_sets
}

@classmethod
def preview_changes(
Copy link
Contributor

Choose a reason for hiding this comment

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

Seems this method is not used, did you add it for future use?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes I thought we may want to see if a change would affect anyone before applying the changes.

cls,
parameter_sets: List[ParameterSet],
excluded_shift_template_ids: list[int] | None = None,
):
changes = cls.build_changes(parameter_sets, excluded_shift_template_ids)
print(cls.get_affected_members(changes))

@classmethod
@transaction.atomic
def apply_changes(
cls,
parameter_sets: List[ParameterSet],
excluded_shift_template_ids: list[int] | None = None,
):
changes = cls.build_changes(parameter_sets, excluded_shift_template_ids)
print(cls.get_affected_members(changes))

for parameter_set, slots_templates in changes.items():
for slot_template in slots_templates:
cls.apply_change(parameter_set, slot_template)

@classmethod
def apply_change(
cls, parameter_set: ParameterSet, slot_template: ShiftSlotTemplate
):
if parameter_set.target_slot_name is None:
slot_template.generated_slots.all().delete()
slot_template.delete()
return

slot_template.generated_slots.update(name=parameter_set.target_slot_name)
slot_template.name = parameter_set.target_slot_name
slot_template.save()

if parameter_set.target_capabilities is None:
return

slot_template.generated_slots.update(
required_capabilities=list(parameter_set.target_capabilities)
)
slot_template.required_capabilities = list(parameter_set.target_capabilities)
slot_template.save()

@classmethod
def pick_slots_to_modify(
cls,
parameter_set: ParameterSet,
excluded_shift_template_ids: list[int] | None = None,
):
shift_templates = cls.pick_shift_templates(
parameter_set, excluded_shift_template_ids
)
return [
cls.pick_slot_template_from_shift_template(parameter_set, shift_template)
for shift_template in shift_templates
]

@classmethod
def pick_shift_templates(
cls,
parameter_set: ParameterSet,
excluded_shift_template_ids: list[int] | None = None,
) -> List[ShiftTemplate]:
if excluded_shift_template_ids is None:
excluded_shift_template_ids = []

shift_templates = ShiftTemplate.objects.filter(
start_time=parameter_set.time
).exclude(id__in=excluded_shift_template_ids)
weekend_shifts_filter = Q(weekday__in=[5, 6])
if parameter_set.workday_or_weekend == cls.WORKDAY:
return shift_templates.exclude(weekend_shifts_filter)
return shift_templates.filter(weekend_shifts_filter)

@classmethod
def pick_slot_template_from_shift_template(
cls, parameter_set: ParameterSet, shift_template: ShiftTemplate
) -> ShiftSlotTemplate:
# The shift is assumed to have been picked according to the parameters, here we only look at the slot

candidate_slot_templates = shift_template.slot_templates.filter(
name=parameter_set.origin_slot_name
)

if not candidate_slot_templates.exists():
raise ShiftSlotTemplate.DoesNotExist(
f"Could not find slot template with name {parameter_set.origin_slot_name} "
f"on shift template {parameter_set.origin_slot_name}"
)

if candidate_slot_templates.count() == 1:
return candidate_slot_templates.first()

# if possible, pick a slot that doesn't have an attendance
existing_attendance_templates = ShiftAttendanceTemplate.objects.filter(
slot_template__in=candidate_slot_templates
)
slot_templates_without_attendance = candidate_slot_templates.exclude(
attendance_template__in=existing_attendance_templates
)
if slot_templates_without_attendance.count() == 1:
return slot_templates_without_attendance.first()
if slot_templates_without_attendance.count() > 1:
candidate_slot_templates = slot_templates_without_attendance

return candidate_slot_templates.order_by("-id").first()

@classmethod
def get_affected_members(cls, changes: dict[ParameterSet, list[ShiftSlotTemplate]]):
result = io.StringIO()
writer = csv.writer(result)
writer.writerow(
["member_id", "member_email", "member_name", "change", "slot", "warnings"]
)

for parameter_set, slot_templates in changes.items():
for slot_template in slot_templates:
already_written_user = None
if hasattr(slot_template, "attendance_template"):
tapir_user = slot_template.attendance_template.user
already_written_user = tapir_user
cls.write_csv_row(writer, tapir_user, slot_template, parameter_set)
affected_attendances = ShiftAttendance.objects.filter(
slot__in=slot_template.generated_slots.all(),
slot__shift__start_time__gt=timezone.now(),
).with_valid_state()
if already_written_user:
affected_attendances = affected_attendances.exclude(
user=already_written_user
)
for attendance in affected_attendances:
cls.write_csv_row(
writer, attendance.user, attendance.slot, parameter_set
)

return result.getvalue()

@classmethod
def write_csv_row(
tvedeane marked this conversation as resolved.
Show resolved Hide resolved
cls,
writer,
tapir_user: TapirUser,
slot: ShiftSlotTemplate | ShiftSlot,
parameter_set: ParameterSet,
):
prefix = "ABCD" if isinstance(slot, ShiftSlotTemplate) else "not-ABCD"

if parameter_set.target_slot_name is None:
change = f"{prefix} delete"
else:
change = (
f"{prefix} from {parameter_set.origin_slot_name} "
f"to {parameter_set.target_slot_name or 'Allgemein'}"
)

warning = "OK"
if parameter_set.target_capabilities is not None:
required_capabilities = parameter_set.target_capabilities
member_capabilities = set(tapir_user.shift_user_data.capabilities)
if not required_capabilities.issubset(member_capabilities):
warning = f"Missing qualifications: {list(required_capabilities.difference(member_capabilities))}"

writer.writerow(
[
tapir_user.get_member_number(),
tapir_user.email,
tapir_user.get_display_name(UserUtils.DISPLAY_NAME_TYPE_FULL),
change,
slot,
warning,
]
)
Loading
Loading