From 3f07acfbd527d9aae05e49f26cdaa6a78f3c433a Mon Sep 17 00:00:00 2001 From: "Andreas (@xAndy) Zimmermann" Date: Sat, 13 May 2023 16:25:33 +0200 Subject: [PATCH] fair shift distribution, no overlapping shifts --- shiftregister/fallback/models.py | 49 ++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/shiftregister/fallback/models.py b/shiftregister/fallback/models.py index c4bbf40..9d2e0a4 100644 --- a/shiftregister/fallback/models.py +++ b/shiftregister/fallback/models.py @@ -1,6 +1,8 @@ from shiftregister.importer.models import * from django.db.models import Max, Sum -from django.db.models import Count, Exists, OuterRef, Subquery, Func +from django.db.models import Count, Exists, OuterRef, ExpressionWrapper +from django.db.models.lookups import LessThan +from django.db.models.fields import DateTimeField import math night_shift_query = Q(start_at__hour__gte=21) | Q(start_at__hour__lte=10) @@ -49,10 +51,35 @@ class TeamMember(models.Model): print( f"total:{total_slot_count} max:{max_shifts_per_member} calc:{shifts_per_member} chosen:{shift_count} calc:{active_team_members*shift_count} free `before:{free_slot_count} {self.name}" ) + blocked_times = [] + for shift in self.fallback_shifts.all(): + blocked_times.append( + Q(start_at__gte=(shift.start_at + shift.duration)) + | Q(end_at__lte=shift.start_at) + ) # easy part: enough free shifts for everyone: - shifts = free_bucket.order_by("?")[:shift_count] - for shift in shifts: + assigned_shift_count = 0 + for _ in range(shift_count): + shift = ( + free_bucket.annotate( + end_at=ExpressionWrapper( + F("start_at") + F("duration"), + output_field=models.DateTimeField(), + ) + ) + .filter(*blocked_times) + .order_by("?") + .first() + ) + if not shift: + break self.fallback_shifts.add(shift) + assigned_shift_count += 1 + blocked_times.append( + Q(start_at__gte=(shift.start_at + shift.duration)) + | Q(end_at__lte=shift.start_at) + ) + # blocked_times.append(Q(start_at__gte=(shift.start_at+shift.duration)) | LessThan(F('start_at')+F('duration'), shift.start_at, output_field=DateTimeField())) # there is a chance that even if qota*teammembers team members are activatet, there are still unasigned shifts # this happens if there are shifts with multiple people left, as we can not assign multiple slots for @@ -61,7 +88,7 @@ class TeamMember(models.Model): # if len(shifts) >= (shift_count - 1): # return - shifts_needed = shift_count - len(shifts) + shifts_needed = shift_count - assigned_shift_count # this is not done very often so we can do this kinda inefficient but readable and maintainable: # for each missing shift, get the team member who has the most shifts in our bucket and take one of them at random # but also take care to not take any slots for shifts we already have... @@ -93,14 +120,20 @@ class TeamMember(models.Model): for member in sorted_members: # now get all their assignments in the relevant bucket but exclude the ones where we already have a slot in the same shift... assignment = ( - FallbackAssignment.objects.filter( - team_member_id=member.id, shift_id__in=canidate_shift_ids + FallbackAssignment.objects.annotate( + start_at=F("shift__start_at"), + end_at=ExpressionWrapper( + F("shift__start_at") + F("shift__duration"), + output_field=models.DateTimeField(), + ), ) + .filter(team_member_id=member.id, shift_id__in=canidate_shift_ids) .exclude( shift_id__in=FallbackAssignment.objects.filter( team_member_id=self.pk ).values("shift_id") ) + .filter(*blocked_times) .order_by("?") .first() ) @@ -110,6 +143,10 @@ class TeamMember(models.Model): print("could not find any matching assignments to take away") return shifts_needed -= 1 + blocked_times.append( + Q(start_at__gte=(assignment.shift.start_at + assignment.shift.duration)) + | Q(end_at__lte=assignment.shift.start_at) + ) self.fallback_shifts.add(assignment.shift) assignment.delete()