fixing the checkin
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
"""
|
||||
|
||||
@@ -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(
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user