From db0a2a30422d5ce807345a6db040ec1755a3f2d9 Mon Sep 17 00:00:00 2001 From: Carlos Gutierrez Date: Fri, 18 Sep 2026 21:59:44 -0400 Subject: [PATCH] fixing the checkin --- src/scheduler/api/schedule_routes.py | 24 +++++++- .../core/services/schedule_service.py | 57 ++++++++++++++++++- src/scheduler/database/schedule_repository.py | 31 +++++++++- src/scheduler/database/storage.py | 48 +++++++++++++++- src/scheduler/manager/conflict.py | 24 ++++++-- src/scheduler/manager/scheduler.py | 19 ++++++- src/scheduler/manager/validator.py | 14 ++--- 7 files changed, 194 insertions(+), 23 deletions(-) diff --git a/src/scheduler/api/schedule_routes.py b/src/scheduler/api/schedule_routes.py index a613055..bdaf2c2 100644 --- a/src/scheduler/api/schedule_routes.py +++ b/src/scheduler/api/schedule_routes.py @@ -16,6 +16,8 @@ from scheduler.core.schemas.schedule import ( ) from scheduler.core.services.schedule_service import ( ALREADY_WORKED_DAY, + INVALID_DAY, + INVALID_TIME, MAX_DAYS_REACHED, ScheduleService, ) @@ -38,6 +40,10 @@ def create_schedule( ) if schedule is None: raise HTTPException(status_code=404, detail="Employee not found") + if schedule == INVALID_DAY: + raise HTTPException(status_code=400, detail="day_of_week does not match enter_time") + if schedule == INVALID_TIME: + raise HTTPException(status_code=400, detail="out_time must be after enter_time") return schedule @@ -51,8 +57,14 @@ def upload( idx = 0 date_format = "%Y-%m-%d %H:%M:%S" for rows in csvReader: - enter_time = datetime.strptime(rows["enter_time"], date_format) - out_time = datetime.strptime(rows["out_time"], date_format) + try: + enter_time = datetime.strptime(rows["enter_time"], date_format) + out_time = datetime.strptime(rows["out_time"], date_format) + except ValueError: + continue + # only validate out_time ordering; allow UTC vs local day drift + if out_time < enter_time: + continue schedule = service.create_schedule( int(rows["user_id"]), enter_time, @@ -60,7 +72,7 @@ def upload( None, rows["shift"], ) - if schedule is None: + if schedule is None or schedule in ("invalid_day", "invalid_time"): continue schedule = service.clock_in_schedule( @@ -89,6 +101,8 @@ def clock_in( ) if schedule is None: raise HTTPException(status_code=404, detail="Employee not found") + if schedule == INVALID_DAY: + raise HTTPException(status_code=400, detail="day_of_week does not match enter_time") if schedule == ALREADY_WORKED_DAY: raise HTTPException(status_code=409, detail="Employee already worked this day") if schedule == MAX_DAYS_REACHED: @@ -105,6 +119,10 @@ def clock_out( schedule = service.clock_out(id, payload.day_of_week, payload.out_time) if schedule is None: raise HTTPException(status_code=404, detail="Schedule not found") + if schedule == INVALID_DAY: + raise HTTPException(status_code=400, detail="day_of_week does not match out_time") + if schedule == INVALID_TIME: + raise HTTPException(status_code=400, detail="out_time must be after enter_time") return schedule diff --git a/src/scheduler/core/services/schedule_service.py b/src/scheduler/core/services/schedule_service.py index dcef899..cecb2d0 100644 --- a/src/scheduler/core/services/schedule_service.py +++ b/src/scheduler/core/services/schedule_service.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone from scheduler.database.company_repository import CompanyRepository from scheduler.database.employee_repository import EmployeeRepository @@ -6,6 +6,21 @@ from scheduler.database.schedule_repository import ScheduleRepository ALREADY_WORKED_DAY = "already_worked_day" MAX_DAYS_REACHED = "max_days_reached" +INVALID_DAY = "invalid_day" +INVALID_TIME = "invalid_time" + + +def _to_naive(dt: datetime) -> datetime: + if dt is None: + return None + if dt.tzinfo is not None: + return dt.astimezone(timezone.utc).replace(tzinfo=None) + return dt + + +def _day_for_datetime(dt: datetime) -> str: + mapping = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"] + return mapping[_to_naive(dt).weekday()] class ScheduleService: @@ -29,10 +44,26 @@ class ScheduleService: ): if self.employee_repository.get(user_id) is None: return None + enter_time = _to_naive(enter_time) + out_time = _to_naive(out_time) + # day_of_week is provided as local day (e.g. Friday) while enter_time is UTC (e.g. 2026-09-19T01:39:00Z -> Saturday UTC) + # strict UTC check would reject valid local times, so only validate out_time ordering + if out_time is not None and out_time < enter_time: + return INVALID_TIME return self.schedule_repository.create( user_id, enter_time, day_of_week, out_time, shift ) + def _week_bounds(self, reference: datetime): + from datetime import timedelta + + reference = _to_naive(reference) + days_from_sunday = (reference.weekday() + 1) % 7 + start = reference.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta( + days=days_from_sunday + ) + return start, start + timedelta(days=7) + def clock_in( self, user_id: int, @@ -44,10 +75,23 @@ class ScheduleService: if employee is None: return None - if self.schedule_repository.get_by_user_and_day(user_id, day_of_week) is not None: + enter_time = _to_naive(enter_time) + week_start, week_end = self._week_bounds(enter_time) + + if ( + self.schedule_repository.get_by_user_and_day( + user_id, day_of_week, week_start, week_end + ) + is not None + ): return ALREADY_WORKED_DAY - if self.schedule_repository.count_distinct_days_by_user(user_id) >= 5: + if ( + self.schedule_repository.count_distinct_days_by_user( + user_id, week_start, week_end + ) + >= 5 + ): return MAX_DAYS_REACHED return self.schedule_repository.create(user_id, enter_time, day_of_week, None, shift) @@ -58,6 +102,13 @@ class ScheduleService: def clock_out(self, user_id: int, day_of_week: str, out_time: datetime): if self.employee_repository.get(user_id) is None: return None + out_time = _to_naive(out_time) + # allow UTC vs local day drift for out_time as well + existing = self.schedule_repository.get_by_user_and_day(user_id, day_of_week) + if existing is not None: + existing_enter = _to_naive(existing["enter_time"]) + if out_time < existing_enter: + return INVALID_TIME return self.schedule_repository.update_clock_out(user_id, day_of_week, out_time) def clock_out_schedule(self, schedule_id: int, out_time: datetime): diff --git a/src/scheduler/database/schedule_repository.py b/src/scheduler/database/schedule_repository.py index daccb59..47c9e44 100644 --- a/src/scheduler/database/schedule_repository.py +++ b/src/scheduler/database/schedule_repository.py @@ -26,7 +26,21 @@ class ScheduleRepository: self.connection.commit() return schedule - def get_by_user_and_day(self, user_id: int, day_of_week: str): + def get_by_user_and_day(self, user_id: int, day_of_week: str, week_start=None, week_end=None): + if week_start is not None and week_end is not None: + with self.connection.cursor() as cursor: + cursor.execute( + """ + SELECT id, user_id, enter_time, day_of_week, out_time, shift + FROM schedules + WHERE user_id = %s AND day_of_week = %s + AND enter_time >= %s AND enter_time < %s + ORDER BY date_created DESC + LIMIT 1 + """, + (user_id, day_of_week, week_start, week_end), + ) + return cursor.fetchone() with self.connection.cursor() as cursor: cursor.execute( """ @@ -40,7 +54,20 @@ class ScheduleRepository: ) return cursor.fetchone() - def count_distinct_days_by_user(self, user_id: int): + def count_distinct_days_by_user(self, user_id: int, week_start=None, week_end=None): + if week_start is not None and week_end is not None: + with self.connection.cursor() as cursor: + cursor.execute( + """ + SELECT COUNT(DISTINCT day_of_week) AS days_worked + FROM schedules + WHERE user_id = %s + AND enter_time >= %s AND enter_time < %s + """, + (user_id, week_start, week_end), + ) + row = cursor.fetchone() + return row["days_worked"] with self.connection.cursor() as cursor: cursor.execute( """ diff --git a/src/scheduler/database/storage.py b/src/scheduler/database/storage.py index 584b33f..5e09a33 100644 --- a/src/scheduler/database/storage.py +++ b/src/scheduler/database/storage.py @@ -61,7 +61,35 @@ def add_schedule(connection, user_id, enter_time, day_of_week, out_time, shift): ) -def get_days_worked_by_user(connection, user_id): +def _week_bounds(reference): + from datetime import timedelta, timezone + + # Convert aware to naive UTC for DB comparison + if getattr(reference, "tzinfo", None) is not None: + reference = reference.astimezone(timezone.utc).replace(tzinfo=None) + # Sunday start + days_from_sunday = (reference.weekday() + 1) % 7 + start_date = (reference - timedelta(days=days_from_sunday)).replace( + hour=0, minute=0, second=0, microsecond=0 + ) + return start_date, start_date + timedelta(days=7) + + +def get_days_worked_by_user(connection, user_id, reference=None): + if reference is not None: + week_start, week_end = _week_bounds(reference) + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT DISTINCT day_of_week + FROM schedules + WHERE user_id = %s + AND enter_time >= %s AND enter_time < %s + ORDER BY day_of_week + """, + (user_id, week_start, week_end), + ) + return [row["day_of_week"] for row in cursor.fetchall()] with connection.cursor() as cursor: cursor.execute( """ @@ -75,7 +103,23 @@ def get_days_worked_by_user(connection, user_id): return [row["day_of_week"] for row in cursor.fetchall()] -def get_user_enter_time_by_day(connection, user_id, day_of_week): +def get_user_enter_time_by_day(connection, user_id, day_of_week, reference=None): + if reference is not None: + week_start, week_end = _week_bounds(reference) + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT enter_time + FROM schedules + WHERE user_id = %s AND day_of_week = %s + AND enter_time >= %s AND enter_time < %s + ORDER BY date_created DESC + LIMIT 1 + """, + (user_id, day_of_week, week_start, week_end), + ) + row = cursor.fetchone() + return None if row is None else row["enter_time"] with connection.cursor() as cursor: cursor.execute( """ diff --git a/src/scheduler/manager/conflict.py b/src/scheduler/manager/conflict.py index 088df8e..d9e81a8 100644 --- a/src/scheduler/manager/conflict.py +++ b/src/scheduler/manager/conflict.py @@ -47,6 +47,19 @@ def _get_shift_preferences(connection, user_id, day_of_week): return [row["first"], row["second"], row["third"]] +def _datetime_for_day(day_of_week: str, reference: datetime) -> datetime: + from datetime import timedelta + + days_from_sunday = (reference.weekday() + 1) % 7 + week_start = reference.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta( + days=days_from_sunday + ) + offset = {"sunday": 0, "monday": 1, "tuesday": 2, "wednesday": 3, "thursday": 4, "friday": 5, "saturday": 6}[ + _value(day_of_week).lower() + ] + return week_start + timedelta(days=offset, hours=9) + + def _get_next_day(current_day): day = _value(current_day) if day not in DAYS: @@ -62,14 +75,16 @@ def _assign_shift(connection, user_id, day_of_week, shift, company_id=None): ) if _is_shift_full(connection, day_of_week, shift, company_id): return None + now = datetime.now() + enter_time = _datetime_for_day(_value(day_of_week), now) if not validate_employee_can_join_shift( - connection, user_id, _value(day_of_week), _value(shift) + connection, user_id, _value(day_of_week), _value(shift), enter_time ): return None return storage.add_schedule( connection, user_id, - datetime.now(), + enter_time, _value(day_of_week), None, _value(shift), @@ -78,12 +93,13 @@ def _assign_shift(connection, user_id, day_of_week, shift, company_id=None): def detect_preferred_shift_conflict(connection, user_id, day_of_week, preferred_shift): company_id = _get_user_company_id(connection, user_id) + now = datetime.now() return ( _is_shift_full(connection, day_of_week, preferred_shift, company_id) or not validate_user_has_not_worked_same_day( - connection, user_id, _value(day_of_week) + connection, user_id, _value(day_of_week), now ) - or not validate_user_has_not_worked_more_than_five_days(connection, user_id) + or not validate_user_has_not_worked_more_than_five_days(connection, user_id, now) ) diff --git a/src/scheduler/manager/scheduler.py b/src/scheduler/manager/scheduler.py index ea9f59d..29df6a9 100644 --- a/src/scheduler/manager/scheduler.py +++ b/src/scheduler/manager/scheduler.py @@ -21,6 +21,19 @@ def _get_user_default_preference(connection, user_id): return storage.get_user_preference(connection, user_id) +def _datetime_for_day(day_of_week: str, reference: datetime) -> datetime: + from datetime import timedelta + + days_from_sunday = (reference.weekday() + 1) % 7 + week_start = reference.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta( + days=days_from_sunday + ) + offset = {"sunday": 0, "monday": 1, "tuesday": 2, "wednesday": 3, "thursday": 4, "friday": 5, "saturday": 6}[ + _value(day_of_week).lower() + ] + return week_start + timedelta(days=offset, hours=9) + + def _get_preferences_for_day(connection, user_id, day_of_week): row = storage.get_weekly_preferences(connection, user_id, _value(day_of_week)) if row is not None: @@ -77,12 +90,14 @@ def generate_weekly_schedule(connection, company_id): def assign_preferred_shift(connection, user_id, day_of_week, preferred_shift): day = _value(day_of_week) shift = _value(preferred_shift) - if not validate_employee_can_join_shift(connection, user_id, day, shift): + now = datetime.now() + enter_time = _datetime_for_day(day, now) + if not validate_employee_can_join_shift(connection, user_id, day, shift, enter_time): return None company_id = storage.get_user_company_id(connection, user_id) if storage.count_shift_employees(connection, day, shift, company_id) >= 2: return None - return storage.add_schedule(connection, user_id, datetime.now(), day, None, shift) + return storage.add_schedule(connection, user_id, enter_time, day, None, shift) def fill_missing_shift_coverage(connection, company_id, day_of_week, shift): diff --git a/src/scheduler/manager/validator.py b/src/scheduler/manager/validator.py index a27b059..159b8f9 100644 --- a/src/scheduler/manager/validator.py +++ b/src/scheduler/manager/validator.py @@ -1,12 +1,12 @@ from scheduler.database import storage -def validate_user_has_not_worked_same_day(connection, user_id, day_of_week): - return storage.get_user_enter_time_by_day(connection, user_id, day_of_week) is None +def validate_user_has_not_worked_same_day(connection, user_id, day_of_week, reference=None): + return storage.get_user_enter_time_by_day(connection, user_id, day_of_week, reference) is None -def validate_user_has_not_worked_more_than_five_days(connection, user_id): - return len(storage.get_days_worked_by_user(connection, user_id)) < 5 +def validate_user_has_not_worked_more_than_five_days(connection, user_id, reference=None): + return len(storage.get_days_worked_by_user(connection, user_id, reference)) < 5 def validate_shift_has_minimum_two_employees( @@ -17,10 +17,10 @@ def validate_shift_has_minimum_two_employees( ) >= 2 -def validate_employee_can_join_shift(connection, user_id, day_of_week, shift): +def validate_employee_can_join_shift(connection, user_id, day_of_week, shift, reference=None): return validate_user_has_not_worked_same_day( - connection, user_id, day_of_week - ) and validate_user_has_not_worked_more_than_five_days(connection, user_id) + connection, user_id, day_of_week, reference + ) and validate_user_has_not_worked_more_than_five_days(connection, user_id, reference) def validate_company_has_enough_employees(connection, company_id):