fixing the checkin

This commit is contained in:
2026-09-18 21:59:44 -04:00
parent e26eb302d0
commit db0a2a3042
7 changed files with 194 additions and 23 deletions
+19 -1
View File
@@ -16,6 +16,8 @@ from scheduler.core.schemas.schedule import (
) )
from scheduler.core.services.schedule_service import ( from scheduler.core.services.schedule_service import (
ALREADY_WORKED_DAY, ALREADY_WORKED_DAY,
INVALID_DAY,
INVALID_TIME,
MAX_DAYS_REACHED, MAX_DAYS_REACHED,
ScheduleService, ScheduleService,
) )
@@ -38,6 +40,10 @@ def create_schedule(
) )
if schedule is None: if schedule is None:
raise HTTPException(status_code=404, detail="Employee not found") 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 return schedule
@@ -51,8 +57,14 @@ def upload(
idx = 0 idx = 0
date_format = "%Y-%m-%d %H:%M:%S" date_format = "%Y-%m-%d %H:%M:%S"
for rows in csvReader: for rows in csvReader:
try:
enter_time = datetime.strptime(rows["enter_time"], date_format) enter_time = datetime.strptime(rows["enter_time"], date_format)
out_time = datetime.strptime(rows["out_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( schedule = service.create_schedule(
int(rows["user_id"]), int(rows["user_id"]),
enter_time, enter_time,
@@ -60,7 +72,7 @@ def upload(
None, None,
rows["shift"], rows["shift"],
) )
if schedule is None: if schedule is None or schedule in ("invalid_day", "invalid_time"):
continue continue
schedule = service.clock_in_schedule( schedule = service.clock_in_schedule(
@@ -89,6 +101,8 @@ def clock_in(
) )
if schedule is None: if schedule is None:
raise HTTPException(status_code=404, detail="Employee not found") 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: if schedule == ALREADY_WORKED_DAY:
raise HTTPException(status_code=409, detail="Employee already worked this day") raise HTTPException(status_code=409, detail="Employee already worked this day")
if schedule == MAX_DAYS_REACHED: if schedule == MAX_DAYS_REACHED:
@@ -105,6 +119,10 @@ def clock_out(
schedule = service.clock_out(id, payload.day_of_week, payload.out_time) schedule = service.clock_out(id, payload.day_of_week, payload.out_time)
if schedule is None: if schedule is None:
raise HTTPException(status_code=404, detail="Schedule not found") 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 return schedule
@@ -1,4 +1,4 @@
from datetime import datetime from datetime import datetime, timezone
from scheduler.database.company_repository import CompanyRepository from scheduler.database.company_repository import CompanyRepository
from scheduler.database.employee_repository import EmployeeRepository from scheduler.database.employee_repository import EmployeeRepository
@@ -6,6 +6,21 @@ from scheduler.database.schedule_repository import ScheduleRepository
ALREADY_WORKED_DAY = "already_worked_day" ALREADY_WORKED_DAY = "already_worked_day"
MAX_DAYS_REACHED = "max_days_reached" 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: class ScheduleService:
@@ -29,10 +44,26 @@ class ScheduleService:
): ):
if self.employee_repository.get(user_id) is None: if self.employee_repository.get(user_id) is None:
return 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( return self.schedule_repository.create(
user_id, enter_time, day_of_week, out_time, shift 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( def clock_in(
self, self,
user_id: int, user_id: int,
@@ -44,10 +75,23 @@ class ScheduleService:
if employee is None: if employee is None:
return 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 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 MAX_DAYS_REACHED
return self.schedule_repository.create(user_id, enter_time, day_of_week, None, shift) 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): def clock_out(self, user_id: int, day_of_week: str, out_time: datetime):
if self.employee_repository.get(user_id) is None: if self.employee_repository.get(user_id) is None:
return 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) 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): def clock_out_schedule(self, schedule_id: int, out_time: datetime):
+29 -2
View File
@@ -26,7 +26,21 @@ class ScheduleRepository:
self.connection.commit() self.connection.commit()
return schedule 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: with self.connection.cursor() as cursor:
cursor.execute( cursor.execute(
""" """
@@ -40,7 +54,20 @@ class ScheduleRepository:
) )
return cursor.fetchone() 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: with self.connection.cursor() as cursor:
cursor.execute( cursor.execute(
""" """
+46 -2
View File
@@ -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: with connection.cursor() as cursor:
cursor.execute( 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()] 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: with connection.cursor() as cursor:
cursor.execute( cursor.execute(
""" """
+20 -4
View File
@@ -47,6 +47,19 @@ def _get_shift_preferences(connection, user_id, day_of_week):
return [row["first"], row["second"], row["third"]] 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): def _get_next_day(current_day):
day = _value(current_day) day = _value(current_day)
if day not in DAYS: 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): if _is_shift_full(connection, day_of_week, shift, company_id):
return None return None
now = datetime.now()
enter_time = _datetime_for_day(_value(day_of_week), now)
if not validate_employee_can_join_shift( 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 None
return storage.add_schedule( return storage.add_schedule(
connection, connection,
user_id, user_id,
datetime.now(), enter_time,
_value(day_of_week), _value(day_of_week),
None, None,
_value(shift), _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): def detect_preferred_shift_conflict(connection, user_id, day_of_week, preferred_shift):
company_id = _get_user_company_id(connection, user_id) company_id = _get_user_company_id(connection, user_id)
now = datetime.now()
return ( return (
_is_shift_full(connection, day_of_week, preferred_shift, company_id) _is_shift_full(connection, day_of_week, preferred_shift, company_id)
or not validate_user_has_not_worked_same_day( 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)
) )
+17 -2
View File
@@ -21,6 +21,19 @@ def _get_user_default_preference(connection, user_id):
return storage.get_user_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): def _get_preferences_for_day(connection, user_id, day_of_week):
row = storage.get_weekly_preferences(connection, user_id, _value(day_of_week)) row = storage.get_weekly_preferences(connection, user_id, _value(day_of_week))
if row is not None: 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): def assign_preferred_shift(connection, user_id, day_of_week, preferred_shift):
day = _value(day_of_week) day = _value(day_of_week)
shift = _value(preferred_shift) 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 return None
company_id = storage.get_user_company_id(connection, user_id) company_id = storage.get_user_company_id(connection, user_id)
if storage.count_shift_employees(connection, day, shift, company_id) >= 2: if storage.count_shift_employees(connection, day, shift, company_id) >= 2:
return None 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): def fill_missing_shift_coverage(connection, company_id, day_of_week, shift):
+7 -7
View File
@@ -1,12 +1,12 @@
from scheduler.database import storage from scheduler.database import storage
def validate_user_has_not_worked_same_day(connection, user_id, day_of_week): 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) is 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): 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)) < 5 return len(storage.get_days_worked_by_user(connection, user_id, reference)) < 5
def validate_shift_has_minimum_two_employees( def validate_shift_has_minimum_two_employees(
@@ -17,10 +17,10 @@ def validate_shift_has_minimum_two_employees(
) >= 2 ) >= 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( return validate_user_has_not_worked_same_day(
connection, user_id, day_of_week connection, user_id, day_of_week, reference
) and validate_user_has_not_worked_more_than_five_days(connection, user_id) ) and validate_user_has_not_worked_more_than_five_days(connection, user_id, reference)
def validate_company_has_enough_employees(connection, company_id): def validate_company_has_enough_employees(connection, company_id):