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 (
|
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:
|
||||||
enter_time = datetime.strptime(rows["enter_time"], date_format)
|
try:
|
||||||
out_time = datetime.strptime(rows["out_time"], date_format)
|
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(
|
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):
|
||||||
|
|||||||
@@ -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(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user