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
22 changes: 22 additions & 0 deletions App/controllers/scheduler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from App.models import Staff, Shift
from App.controllers.user import get_user
from App.strategies.evendistribution import EvenDistribution
from App.strategies.balancedaynight import BalanceDayNight
from App.strategies.minimizedays import MinimizeDays

STRATEGIES = { "even": EvenDistribution, "balance_day_night": BalanceDayNight, "minimize_days": MinimizeDays }

def auto_generate_schedule(admin_id, strategy_name="even"):
admin = get_user(admin_id)
if admin.role != "admin":
raise PermissionError("Only admins can generate schedules")

strategy_class = STRATEGIES.get(strategy_name)
if not strategy_class:
raise ValueError(f"Invalid strategy '{strategy_name}'. Valid strategies are: {list(STRATEGIES.keys())}")

strategy = strategy_class()
staff_list = Staff.query.all()
shifts = Shift.query.all()

return strategy.distribute_shifts(staff_list, shifts)
6 changes: 6 additions & 0 deletions App/strategies/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from .strategy import Schedule, ScheduleStrategy, get_staff_id, get_shift_id, get_shift_day, get_shift_type
from .evendistribution import EvenDistribution
from .balancedaynight import BalanceDayNight
from .minimizedays import MinimizeDays

__all__ = ["Schedule", "ScheduleStrategy", "get_staff_id", "get_shift_id", "get_shift_day", "get_shift_type", "EvenDistribution", "BalanceDayNight", "MinimizeDays"]
42 changes: 42 additions & 0 deletions App/strategies/balancedaynight.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from .strategy import *



class BalanceDayNight(ScheduleStrategy):


def distribute_shifts(self, staff, shifts, week_start=None):
assignments = {}

if not staff:
return Schedule(assignments)

staff_ids = [get_staff_id(member) for member in staff]
for staff_id in staff_ids:
assignments[staff_id] = []

counts = {staff_id: {'day': 0, 'night': 0} for staff_id in staff_ids}

for shift in shifts:
shift_type = get_shift_type(shift)

def score(staff_id):
return (counts[staff_id].get(shift_type, 0), len(assignments[staff_id]))

chosen_staff = min(staff_ids, key=score)
assignments[chosen_staff].append(shift)
counts[chosen_staff][shift_type] = counts[chosen_staff].get(shift_type, 0) + 1


return Schedule(assignments)

"""
This strategy uses a greedy algorithm to balance the number of day and night shifts assigned to each staff member.
It first determins whether the shift is a day or night shift using the get_shift_type function.
It calculates a score for each staff member based on how many shifts of that type they already have assigned, as well as their total number of assigned shifts.
The staff member with the lowest score is chosen to receive the shift, helping to ensure an even distribution of day and night shifts among all staff members.

e.g if s1 has 2 day shifts and s2 has 1, the next day shift will go to s2 to balance it out.

- VR.
"""
28 changes: 28 additions & 0 deletions App/strategies/evendistribution.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from .strategy import *



class EvenDistribution(ScheduleStrategy):

def distribute_shifts(self, staff, shifts, week_start=None):
assignments = {}

if not staff:
return Schedule(assignments)


staff_ids = [get_staff_id(member) for member in staff]
for staff_id in staff_ids:
assignments[staff_id] = []

i = 0
num_staff = len(staff_ids)

for shift in shifts:
staff_id = staff_ids[i % num_staff]
assignments[staff_id].append(shift)
i += 1

return Schedule(assignments)

# This use an even distribution, a round robin approach, to assign shifts to staff members in order. - VR
50 changes: 50 additions & 0 deletions App/strategies/minimizedays.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from .strategy import *

class MinimizeDays(ScheduleStrategy):


def distribute_shifts(self, staff, shifts, week_start=None):
assignments = {}

if not staff:
return Schedule(assignments)

staff_ids = [get_staff_id(member) for member in staff]

for staff_id in staff_ids:
assignments[staff_id] = []

days = {staff_id: set() for staff_id in staff_ids}

for shift in shifts:
shift_day = get_shift_day(shift)

chosen = None

for staff_id in staff_ids:
if shift_day in days[staff_id]:
chosen = staff_id
break

if chosen is None:
chosen = min(staff_ids, key = lambda x: (len(days[x]), len(assignments[x])))

assignments[chosen].append(shift)
if shift_day is not None:
days[chosen].add(shift_day)


return Schedule(assignments)


"""
This strategy as the name suggests aims to reduce the number of distinct days that each staff members comes in to work.
When a new shift is to be assigned, it first checks if there is any staff member who is already scheduled to work on that day.
If such a staff member is found, the shift is assigned to them, so that they can work multiple shifts on the same day and thus minimize the total number of days they need to come in.
If no staff member is already scheduled for that day, the strategy selects the staff member who currently has the fewest distinct working days.
If there is a tie, it further breaks the tie by choosing the staff member with the fewest total assigned shifts.
This gives the staff more complete days off by goruping all their shifts into fewer days instead of spreading them out.
- VR.


"""
110 changes: 110 additions & 0 deletions App/strategies/strategy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
from abc import ABC, abstractmethod

class Schedule:
def __init__(self, assignments):
if assignments is not None:
self.assignments = assignments
else:
self.assignments = {}

def to_dict(self):
out = {}
for shiftID, shifts in self.assignments.items():
out[shiftID] = []
for item in shifts:
if isinstance(item, dict):
out[shiftID].append(item)
elif hasattr(item, "get_json"):
out[shiftID].append(item.get_json())
else:
out[shiftID].append(str(item))
return out

class ScheduleStrategy(ABC):
@abstractmethod
def distribute_shifts(self, staff, shifts, week_start=None):
raise NotImplementedError("distribute_shifts must be implemented by subclasses")


def get_staff_id(staff_member):
if staff_member is None:
raise ValueError("Staff member cannot be None")

if isinstance(staff_member, dict):
if "id" in staff_member and staff_member["id"] is not None:
return str(staff_member["id"])
else:
val = getattr(staff_member, "id", None)
if val is not None:
return str(val)

raise ValueError("Unable to determine staff member ID")


def get_shift_day(shift):
if shift is None:
return None

if isinstance(shift, dict):
st = shift.get("start_time")
else:
st = getattr(shift, "start_time", None)

if st is not None:
if hasattr(st, "date"):
return st.date()
if hasattr(st, "strftime"):
return st.strftime("%Y-%m-%d")
if isinstance(st, str):
return st.split("T")[0]

return None


def get_shift_type(shift):
if shift is None:
return "day"

if isinstance(shift, dict):
startTime = shift.get("start_time")
else:
startTime = getattr(shift, "start_time", None)

if startTime is not None:
if hasattr(startTime, "hour"):
hour = startTime.hour
if (hour >= 18 or hour < 6):
return "night"
else:
return "day"

if isinstance(startTime, str):
try:
if "T" in startTime:
timePart = startTime.split("T")[1]
hour = int(timePart.split(":")[0])
else:
hour = int(startTime.split(":")[0])
if (hour >= 18 or hour < 6):
return "night"
else:
return "day"

except Exception:
return "day"

return "day"


def get_shift_id(shift):
if shift is None:
return None

if isinstance(shift, dict):
if "id" in shift and shift["id"] is not None:
return shift["id"]
else:
val = getattr(shift, "id", None)
if val is not None:
return val
return None