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 (
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:
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
@@ -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):
+29 -2
View File
@@ -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(
"""
+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:
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(
"""
+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"]]
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)
)
+17 -2
View File
@@ -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):
+7 -7
View File
@@ -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):