Skip to content

Commit

Permalink
5oappy/feature/integration testing schedulerv2 (#306)
Browse files Browse the repository at this point in the history
## Describe your changes
note: merged lindas changes into this branch before Linda's was merged
into main so there may be some inconsistencies that will need to be
manually resolved.
real changes begin at 1ecbfe0.

I know its a mess but it works!!

The scheduler works correctly according to this control flow:

1) api is called and..
2) optimiser instance is created
3) optimiser invokes calculator instance
4) calculator instance retrieves all required data
5) optimiser calls calculator methods to assign varaibles
6) minizinc instance created with the model string
7) variables are assigned to minizinc model string
8) minizinc solves the model
9) minizinc returns the posssible assignments
10) possible assignments are returned to api
11) api invokes save result to persist possible assignments to database
and setting relevant flags.
12) api returns {"message": "Optimisation completed successfully",
"result": <minizinc result string>}


Further info on calculator:

- The scheduler will optimise all recently created shifts marked via the
status flag being set to SUBMITTED (as in just submitted).
- The scheduler will allow for users to be assigned to multiple shifts
provided their skills align.
- The scheduler will allow for clashes in scheduling.
- The actual saving of volunteer shift will check for clashes.
- `unavailability_record` is created for each successful user assigned
and saved to the shift (each `shift_request_volunteer` entry added to
the db). This prevents the optimiser from thinking user is available for
subsequent optimisations.

pictures:
before calling optimiser:
<img width="871" alt="Screenshot 2024-10-06 at 11 13 05 PM"
src="https://github.com/user-attachments/assets/e492839f-f0e8-4e9b-9029-a73b304c466c">

after calling optimiser:
<img width="871" alt="Screenshot 2024-10-06 at 11 13 20 PM"
src="https://github.com/user-attachments/assets/f0711a5f-50b9-49e1-ba28-c75facb1e7e0">

logs:
<img width="821" alt="Screenshot 2024-10-06 at 11 56 37 PM"
src="https://github.com/user-attachments/assets/3d0c4c5b-3c4b-4970-b34e-301f6fafbb25">

<img width="186" alt="Screenshot 2024-10-07 at 12 00 01 AM"
src="https://github.com/user-attachments/assets/c52f2c5d-39cd-4b7f-b963-fe5741cf0dfd">
<img width="197" alt="Screenshot 2024-10-07 at 12 01 48 AM"
src="https://github.com/user-attachments/assets/ba65dc65-365e-4c8d-a658-85df00b2e4da">
<img width="193" alt="Screenshot 2024-10-06 at 10 16 14 PM"
src="https://github.com/user-attachments/assets/584080c2-5f41-4a2e-92d0-9f505084e4b7">

note: the second phone has duplicate shifts because of a bug that
allowed 2 shifts to be posted via post man at the same time and got
persisted when the optimiser was not set up to handle clashes yet.


signed:
Steven, Hafizh, Linda. 

## Issue ticket number and link


[FIR-4](https://fireapp-emergiq-2024.atlassian.net/browse/FIR-4)
[FIR-108](https://fireapp-emergiq-2024.atlassian.net/browse/FIR-108)

---------

Co-authored-by: YiranLI <[email protected]>
Co-authored-by: anannnchim <[email protected]>
Co-authored-by: Muhammad Hafizh Hasyim <[email protected]>
  • Loading branch information
4 people authored Oct 7, 2024
1 parent 7957255 commit 55f07b1
Show file tree
Hide file tree
Showing 7 changed files with 294 additions and 108 deletions.
3 changes: 2 additions & 1 deletion controllers/v2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@
from .v2_blueprint import v2_api, v2_bp
from .unavailability import *
from .shift import *
from .fcm_tokens import *
from .fcm_tokens import *
from .optimiser import *
2 changes: 2 additions & 0 deletions controllers/v2/optimiser/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from .api import *
from .response_models import optimiser_response_model
52 changes: 52 additions & 0 deletions controllers/v2/optimiser/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from flask_restful import Resource, marshal_with, reqparse
from .response_models import optimiser_response_model
from repository.shift_repository import ShiftRepository
from services.jwk import requires_auth, is_user_or_has_role
from domain import UserType, session_scope
from controllers.v2.v2_blueprint import v2_api
from services.optimiser.optimiser import Optimiser
import logging

# Initialise parser for potential arguments in future extensions (if needed)
parser = reqparse.RequestParser()
parser.add_argument('debug', type=bool, required=False, help="Optional debug mode flag.")


class OptimiserResource(Resource):
optimiser_repository: ShiftRepository

def __init__(self, optimiser_repository: ShiftRepository = ShiftRepository()):
self.optimiser_repository = optimiser_repository

@requires_auth
@is_user_or_has_role(None, UserType.ROOT_ADMIN)
@marshal_with(optimiser_response_model) # Use the marshalling model
def post(self):
# Parse debug argument
try:
args = parser.parse_args()
except Exception:
args = {}
# Default debug to False if it's not provided or the body is empty
debug = args.get('debug', False)

try:
with session_scope() as session:
# Initialise and run the optimiser
optimiser = Optimiser(session=session, repository=self.optimiser_repository, debug=debug)
result = optimiser.solve()
optimiser.save_result(result)

# Return raw data, the marshaller will format it
return {
"message": "Optimisation completed successfully",
"result": str(result)
}

except Exception as e:
logging.error(f"Error running optimiser: {e}")
return {"message": "Internal server error", "result": str(e)}, 500


# Register the OptimiserResource in the blueprint
v2_api.add_resource(OptimiserResource, '/v2/optimiser')
6 changes: 6 additions & 0 deletions controllers/v2/optimiser/response_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from flask_restful import fields

optimiser_response_model = {
'message': fields.String,
'result': fields.Raw
}
174 changes: 136 additions & 38 deletions repository/shift_repository.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import logging
from typing import List
from typing import List, Optional

from datetime import datetime

Expand All @@ -12,7 +12,8 @@ class ShiftRepository:
def __init__(self):
pass

def post_shift_request(self, user_id, title, start_time, end_time, vehicle_type):

def post_shift_request(self, user_id: int, title: str, start_time: datetime, end_time: datetime, vehicle_type: int) -> Optional[int]:
"""
Creates a new shift request and associated shift positions based on the vehicle type.
Expand Down Expand Up @@ -43,7 +44,6 @@ def post_shift_request(self, user_id, title, start_time, end_time, vehicle_type)
title=title,
startTime=start_time,
endTime=end_time,
status=ShiftStatus.PENDING, # need to be changed to submitted after linda pr approved
update_date_time=now, # Update timestamp
insert_date_time=now # Insert timestamp
)
Expand All @@ -65,9 +65,7 @@ def post_shift_request(self, user_id, title, start_time, end_time, vehicle_type)
return None




def create_positions(self, session, shiftId, vehicleType):
def create_positions(self, session, shiftId: int, vehicleType: int) -> bool:
"""
Creates shift positions based on the vehicle type for a given shift request.
Expand Down Expand Up @@ -108,8 +106,9 @@ def create_positions(self, session, shiftId, vehicleType):
logging.error(f"Error creating positions: {e}")
return False


def get_shift(self, userId) -> List[ShiftRecord]:

"""
Retrieves all shift events for a given user that have not ended yet.
Expand Down Expand Up @@ -141,8 +140,40 @@ def get_shift(self, userId) -> List[ShiftRecord]:
except Exception as e:
logging.error(f"Error retrieving shifts for user {userId}: {e}")
raise


def update_shift_pending(self, shift_id: int) -> None:
"""
Updates the status of a shift to 'PENDING'.
Parameters:
----------
shift_id : int
The ID of the shift to update.
"""
with session_scope() as session:
try:
# Fetch the ShiftRequest record with the given shift_id
shift_request = session.query(ShiftRequest).filter_by(id=shift_id).first()

if shift_request:
# Update the status to 'PENDING'
shift_request.status = ShiftStatus.PENDING
shift_request.last_update_datetime = datetime.now() # Update the timestamp

# Commit the transaction to save changes
session.commit()
logging.info(f"Shift {shift_id} status updated to PENDING.")
else:
# Handle the case where the shift with the given id does not exist
logging.error(f"Shift with id {shift_id} not found.")
raise ValueError(f"Shift with id {shift_id} not found.")
except Exception as e:
session.rollback()
logging.error(f"Error updating shift {shift_id} to PENDING: {e}")
raise

def update_shift_status(self, user_id, shift_id, new_status: ShiftVolunteerStatus):
def update_shift_status(self, user_id: int, shift_id: int, new_status: ShiftVolunteerStatus) -> bool:
"""
Updates the status of a volunteer's shift request in the database.
Expand All @@ -152,7 +183,7 @@ def update_shift_status(self, user_id, shift_id, new_status: ShiftVolunteerStatu
The ID of the user whose shift request status is to be updated.
shift_id : int
The ID of the shift request to be updated.
new_status : str
new_status : ShiftVolunteerStatus
The new status to set for the shift request.
Returns:
-------
Expand Down Expand Up @@ -184,7 +215,7 @@ def update_shift_status(self, user_id, shift_id, new_status: ShiftVolunteerStatu
unavailability_record = UnavailabilityTime(
userId=user_id,
title=f"shift {shift_id}",
periodicity=1,
periodicity=3,
start=shift_request.startTime,
end=shift_request.endTime,
is_shift=True
Expand All @@ -203,49 +234,116 @@ def update_shift_status(self, user_id, shift_id, new_status: ShiftVolunteerStatu
logging.error(f"Error updating shift request for user {user_id} and shift_id {shift_id}: {e}")
return False

def check_conflict_shifts(self, session, userId, shiftId):
def save_shift_assignments(self, assignments: List[dict]) -> None:
"""
Saves multiple shift assignments and creates unavailability records in bulk,
checking for conflicts before saving.
Parameters:
----------
assignments : List[dict]
A list of dictionaries containing assignment data.
"""
with session_scope() as session:
try:
shift_volunteers = []
unavailability_records = []

for assignment in assignments:
user_id = assignment['user_id']
shift_id = assignment['shift_id']
role_code = assignment['role_code']
shift_start = assignment['shift_start']
shift_end = assignment['shift_end']

# Check for conflicts
has_conflict = self.check_conflict_shifts(
session=session,
user_id=user_id,
shift_id=shift_id,
shift_start=shift_start,
shift_end=shift_end
)

if has_conflict:
logging.info(f"Conflict detected for user {user_id} on shift {shift_id}. Assignment skipped.")
continue # Skip this assignment

# Fetch the ShiftPosition based on shift_id and role_code
shift_position = session.query(ShiftPosition).filter_by(
shift_id=shift_id,
role_code=role_code
).first()

if not shift_position:
logging.error(f"ShiftPosition not found for shift_id {shift_id} and role_code {role_code}.")
continue # Skip this assignment

# Create ShiftRequestVolunteer object
shift_volunteer = ShiftRequestVolunteer(
user_id=user_id,
request_id=shift_id,
position_id=shift_position.id,
status=ShiftVolunteerStatus.ACCEPTED,
update_date_time=datetime.now(),
insert_date_time=datetime.now(),
)
shift_volunteers.append(shift_volunteer)

# Create UnavailabilityTime object
unavailability_record = UnavailabilityTime(
userId=user_id,
title=f"Shift {shift_id}",
periodicity=3,
start=shift_start,
end=shift_end,
is_shift=True
)
unavailability_records.append(unavailability_record)

# Bulk save all records
session.bulk_save_objects(shift_volunteers)
session.bulk_save_objects(unavailability_records)

session.commit()
logging.info(f"Successfully saved {len(shift_volunteers)} shift assignments.")

except Exception as e:
session.rollback()
logging.error(f"Error saving shift assignments: {e}")
raise

def check_conflict_shifts(self, session, user_id: int, shift_id: int, shift_start: datetime, shift_end: datetime) -> bool:
"""
Check if a given user has any conflicting confirmed shifts with the current shift request.
:param session: Database session.
:param userId: the user id of the current shift request to check for conflicts.
:param shiftId: the ID of the current shift request to check for conflicts.
:param user_id: The user ID to check for conflicts.
:param shift_id: The ID of the current shift request to check for conflicts.
:param shift_start: Start time of the current shift.
:param shift_end: End time of the current shift.
:return: True if there is a conflict, False if no conflicts are found.
"""
try:
# Query all confirmed shifts for the user
# Query all confirmed shifts for the user excluding the current shift
confirmed_shifts = session.query(ShiftRequestVolunteer).join(ShiftRequest).filter(
ShiftRequestVolunteer.user_id == userId,
ShiftRequestVolunteer.status == ShiftVolunteerStatus.ACCEPTED
ShiftRequestVolunteer.user_id == user_id,
ShiftRequestVolunteer.status == ShiftVolunteerStatus.ACCEPTED,
ShiftRequestVolunteer.request_id != shift_id
).all()
# The current shift information with start time and end time
current_shift_information = session.query(ShiftRequestVolunteer).join(ShiftRequest).filter(
ShiftRequestVolunteer.user_id == userId,
ShiftRequestVolunteer.request_id == shiftId
).first()

# Iterate over all confirmed shifts and check for time conflicts
for shift in confirmed_shifts:
if (shift.shift_request.startTime < current_shift_information.shift_request.endTime and
current_shift_information.shift_request.startTime < shift.shift_request.endTime):
for shift_volunteer in confirmed_shifts:
confirmed_shift = shift_volunteer.shift_request
if (shift_start < confirmed_shift.endTime and shift_end > confirmed_shift.startTime):
# A conflict is found if the time ranges overlap
return True

# If no conflicts are found, return False
return False
except Exception as e:
# Log the error and return False in case of an exception
logging.error(f"Error checking shift conflicts for user {userId} and request {shiftId}: {e}")
logging.error(f"Error checking shift conflicts for user {user_id} and request {shift_id}: {e}")
return False

def save_shift_volunteers(self, volunteers: List[ShiftRequestVolunteer]) -> None:
"""
Saves a list of ShiftRequestVolunteer objects to the database.
"""
with session_scope() as session:
try:
session.bulk_save_objects(volunteers)
session.commit()
logging.info(f"Successfully saved {len(volunteers)} shift volunteers.")
except Exception as e:
session.rollback()
logging.error(f"Error saving shift volunteers: {e}")
raise

Loading

0 comments on commit 55f07b1

Please sign in to comment.