From 9a6fc3fdd763e899e336ffd37efe2444976323de Mon Sep 17 00:00:00 2001 From: AlexSserb Date: Wed, 4 Dec 2024 11:00:00 +0400 Subject: [PATCH] feat: work shift pauses --- models/work_shifts.py | 34 +++++ routers/work_shifts.py | 50 +++++++- schemas/work_shifts.py | 27 +++- services/work_shifts.py | 267 ++++++++++++++++++++++++++++++---------- utils/work_time.py | 10 +- 5 files changed, 316 insertions(+), 72 deletions(-) diff --git a/models/work_shifts.py b/models/work_shifts.py index b4b96b5..6664b93 100644 --- a/models/work_shifts.py +++ b/models/work_shifts.py @@ -2,6 +2,7 @@ from datetime import datetime from typing import TYPE_CHECKING from sqlalchemy import ForeignKey +from sqlalchemy.sql import expression from sqlalchemy.orm import Mapped, mapped_column, relationship from models.base import BaseModel @@ -20,6 +21,10 @@ class WorkShift(BaseModel): finished_at: Mapped[datetime] = mapped_column( nullable=True, ) + is_paused: Mapped[bool] = mapped_column( + default=False, + server_default=expression.false(), + ) user_id: Mapped[int] = mapped_column( ForeignKey("users.id"), @@ -29,3 +34,32 @@ class WorkShift(BaseModel): back_populates="work_shifts", lazy="selectin", ) + + pauses: Mapped[list["WorkShiftPause"]] = relationship( + "WorkShiftPause", + back_populates="work_shift", + uselist=True, + foreign_keys="[WorkShiftPause.work_shift_id]", + cascade="all, delete", + ) + + +class WorkShiftPause(BaseModel): + __tablename__ = "work_shifts_pauses" + + id: Mapped[int] = mapped_column(primary_key=True) + started_at: Mapped[datetime] = mapped_column( + nullable=False, + ) + finished_at: Mapped[datetime] = mapped_column( + nullable=True, + ) + + work_shift_id: Mapped[int] = mapped_column( + ForeignKey("work_shifts.id"), + ) + work_shift: Mapped[WorkShift] = relationship( + "WorkShift", + back_populates="pauses", + lazy="selectin", + ) diff --git a/routers/work_shifts.py b/routers/work_shifts.py index 4116a42..f7281eb 100644 --- a/routers/work_shifts.py +++ b/routers/work_shifts.py @@ -47,7 +47,7 @@ async def finish_shift( user_id: int, user: CurrentUserDependency, ): - return await WorkShiftsService(session).finish_shift(user, user_id) + return await WorkShiftsService(session).finish_shift_by_user_id(user, user_id) @work_shifts_router.post( @@ -86,3 +86,51 @@ async def delete_work_shift( shift_id: int, ): return await WorkShiftsService(session).delete_shift(shift_id) + + +@work_shifts_router.post( + "/pause/start/{shift_id}", + response_model=StartPauseByShiftIdResponse, + operation_id="start_pause_by_shift_id", +) +async def start_pause_by_shift_id( + session: SessionDependency, + shift_id: int, +): + return await WorkShiftsService(session).start_pause_by_shift_id(shift_id) + + +@work_shifts_router.post( + "/pause/start/for-user/{user_id}", + response_model=StartPauseByUserIdResponse, + operation_id="start_pause_by_user_id", +) +async def start_pause_by_user_id( + session: SessionDependency, + user_id: int, +): + return await WorkShiftsService(session).start_pause_by_user_id(user_id) + + +@work_shifts_router.post( + "/pause/finish/{shift_id}", + response_model=FinishPauseByShiftIdResponse, + operation_id="finish_pause_by_shift_id", +) +async def finish_pause_by_shift_id( + session: SessionDependency, + shift_id: int, +): + return await WorkShiftsService(session).finish_pause_by_shift_id(shift_id) + + +@work_shifts_router.post( + "/pause/finish/for-user/{shift_id}", + response_model=FinishPauseByUserIdResponse, + operation_id="finish_pause_by_user_id", +) +async def finish_pause_by_user_id( + session: SessionDependency, + user_id: int, +): + return await WorkShiftsService(session).finish_pause_by_user_id(user_id) diff --git a/schemas/work_shifts.py b/schemas/work_shifts.py index edee337..25a0841 100644 --- a/schemas/work_shifts.py +++ b/schemas/work_shifts.py @@ -11,10 +11,16 @@ class WorkShiftSchema(BaseSchema): id: int started_at: datetime finished_at: Optional[datetime] = None - hours: Optional[float] = None + is_paused: Optional[bool] = None user: UserSchema +class WorkShiftRowSchema(BaseSchema): + work_shift: WorkShiftSchema + total_hours: Optional[float] = None + pause_hours: Optional[float] = None + + # endregion # region Responses @@ -32,11 +38,28 @@ class FinishShiftByIdResponse(OkMessageSchema): class GetWorkShiftsResponse(BaseSchema): - shifts: List[WorkShiftSchema] + shifts: List[WorkShiftRowSchema] pagination_info: PaginationInfoSchema class DeleteShiftResponse(OkMessageSchema): pass + +class StartPauseByUserIdResponse(OkMessageSchema): + pass + + +class FinishPauseByUserIdResponse(OkMessageSchema): + pass + + +class StartPauseByShiftIdResponse(OkMessageSchema): + pass + + +class FinishPauseByShiftIdResponse(OkMessageSchema): + pass + + # endregion diff --git a/services/work_shifts.py b/services/work_shifts.py index 61eb0cd..e4817d2 100644 --- a/services/work_shifts.py +++ b/services/work_shifts.py @@ -2,10 +2,11 @@ from datetime import date, timedelta import math from fastapi import HTTPException, status -from sqlalchemy import select, delete, func -from sqlalchemy.orm import joinedload +from sqlalchemy import select, func, extract, literal, label, Select +from sqlalchemy.orm import joinedload, selectinload from models import WorkShift, User +from models.work_shifts import WorkShiftPause from schemas.base import PaginationSchema from schemas.time_tracking import UpdateTimeTrackingRecordRequest from schemas.work_shifts import * @@ -16,9 +17,10 @@ from utils.work_time import hours_to_hours_and_minutes class WorkShiftsService(BaseService): - async def _get_last_work_shift_for_today(self, user_id: int) -> Optional[WorkShift]: + async def _get_last_work_shift(self, user_id: int, for_today: bool = False) -> Optional[WorkShift]: stmt = ( select(WorkShift) + .options(selectinload(WorkShift.pauses)) .where(WorkShift.user_id == user_id) .order_by(WorkShift.started_at.desc()) .limit(1) @@ -28,16 +30,17 @@ class WorkShiftsService(BaseService): work_shift = work_shift.one_or_none() work_shift = work_shift[0] if work_shift else None - if work_shift and work_shift.started_at.date() == date.today(): - return work_shift - return None + if for_today: + return work_shift if work_shift and work_shift.started_at.date() == date.today() else None + + return work_shift async def start_shift(self, user_id: int) -> StartShiftResponse: employee = await self.session.get(User, user_id) if not employee or employee.is_deleted: return StartShiftResponse(ok=False, message=f"Пользователь с ID {user_id} не найден") - work_shift = await self._get_last_work_shift_for_today(user_id) + work_shift = await self._get_last_work_shift(user_id, for_today=True) if work_shift: if not work_shift.finished_at: return StartShiftResponse(ok=False, message="Предыдущая смена еще не закончена") @@ -48,90 +51,153 @@ class WorkShiftsService(BaseService): await self.session.commit() return StartShiftResponse(ok=True, message="Смена начата") - async def finish_shift(self, user: User, user_id: int) -> FinishShiftResponse: + async def finish_shift_by_user_id(self, user: User, user_id: int) -> FinishShiftResponse: employee = await self.session.get(User, user_id) if not employee or employee.is_deleted: return FinishShiftResponse(ok=False, message=f"Пользователь с ID {user_id} не найден") - work_shift = await self._get_last_work_shift_for_today(user_id) + work_shift = await self._get_last_work_shift(user_id) - if not work_shift or work_shift.finished_at: - return FinishShiftResponse(ok=False, message="Смена для сотрудника еще не начата") - - work_shift.finished_at = datetime.now() - await self.session.commit() - - diff: timedelta = work_shift.finished_at - work_shift.started_at - hours = diff.total_seconds() / 3600 - - if diff.total_seconds() < 60: - return FinishShiftResponse(ok=True, message=f"Смена закончена. Отработано 0 ч. 0 мин.") - - data = UpdateTimeTrackingRecordRequest( - user_id=user_id, - date=work_shift.started_at.date(), - hours=hours, - ) - await TimeTrackingService(self.session).update_record(user, data) - - hours, minutes = hours_to_hours_and_minutes(hours) - return FinishShiftResponse(ok=True, message=f"Смена закончена. Отработано {hours} ч. {minutes} мин.") + ok, message = await self._finish_shift_common(user, work_shift) + return FinishShiftResponse(ok=ok, message=message) async def finish_shift_by_id(self, user: User, shift_id: int) -> FinishShiftByIdResponse: - work_shift = await self.session.get(WorkShift, shift_id) + stmt = ( + select(WorkShift) + .options(selectinload(WorkShift.pauses)) + .where(WorkShift.id == shift_id) + ) + work_shift = await self.session.execute(stmt) + work_shift = work_shift.scalars().one_or_none() + ok, message = await self._finish_shift_common(user, work_shift) + return FinishShiftByIdResponse(ok=ok, message=message) + + async def _finish_shift_common(self, user: User, work_shift: Optional[WorkShift]) -> tuple[bool, str]: if not work_shift or work_shift.finished_at: - return FinishShiftByIdResponse(ok=False, message="Смена для сотрудника еще не начата") + return False, "Смена для сотрудника еще не начата" + + if work_shift.is_paused: + await self.finish_pause_by_shift_id(work_shift.id) work_shift.finished_at = datetime.now() await self.session.commit() - diff: timedelta = work_shift.finished_at - work_shift.started_at - hours = diff.total_seconds() / 3600 + pause_time = timedelta() + for pause in work_shift.pauses: + pause_time += pause.finished_at - pause.started_at - if diff.total_seconds() < 60: - return FinishShiftByIdResponse(ok=True, message=f"Смена закончена. Отработано 0 ч. 0 мин.") + total_work_time: timedelta = work_shift.finished_at - work_shift.started_at + pure_work_seconds = total_work_time.total_seconds() - pause_time.total_seconds() + hours = pure_work_seconds / 3600 - data = UpdateTimeTrackingRecordRequest( - user_id=work_shift.user_id, - date=work_shift.started_at.date(), - hours=hours, + if pure_work_seconds >= 60: + data = UpdateTimeTrackingRecordRequest( + user_id=work_shift.user_id, + date=work_shift.started_at.date(), + hours=hours, + ) + await TimeTrackingService(self.session).update_record(user, data) + + hours, minutes = hours_to_hours_and_minutes(total_work_time) + return True, f"Смена закончена. Отработано {hours} ч. {minutes} мин." + + @staticmethod + def get_work_shifts_history_stmt() -> Select: + sub_hours = ( + select( + WorkShift.id, + label( + "total_hours", + extract('epoch', WorkShift.finished_at) - extract('epoch', WorkShift.started_at) + ), + func.sum( + extract('epoch', WorkShiftPause.finished_at) - extract('epoch', WorkShiftPause.started_at) + ).label("pause_hours"), + ) + .join(WorkShiftPause, isouter=True) + .where(WorkShift.finished_at.is_not(None)) + .group_by(WorkShift.id) + .subquery() ) - await TimeTrackingService(self.session).update_record(user, data) - hours, minutes = hours_to_hours_and_minutes(hours) - return FinishShiftByIdResponse(ok=True, message=f"Смена закончена. Отработано {hours} ч. {minutes} мин.") + return ( + select( + WorkShift, + sub_hours.c.total_hours, + sub_hours.c.pause_hours, + ) + .join(sub_hours, sub_hours.c.id == WorkShift.id) + .options(joinedload(WorkShift.user)) + .order_by(WorkShift.started_at.desc()) + ) - async def get_shifts(self, is_active: bool, pagination: PaginationSchema) -> GetWorkShiftsResponse: + @staticmethod + def get_active_work_shifts_stmt() -> Select: + return ( + select( + WorkShift, + literal(0), + literal(0), + ) + .options(joinedload(WorkShift.user)) + .where(WorkShift.finished_at.is_(None)) + .order_by(WorkShift.started_at.desc()) + ) + + @staticmethod + def validate_pagination(pagination: PaginationSchema): if not is_valid_pagination(pagination): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid pagination') - page = max(0, pagination.page - 1) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid pagination") - total_shifts = await self.session.scalar( + async def get_total_shifts_count(self, is_active: bool) -> int: + count_query = ( select(func.count()) .select_from(WorkShift) .where(WorkShift.finished_at.is_(None) if is_active else WorkShift.finished_at.is_not(None)) ) - if not total_shifts: - return GetWorkShiftsResponse( - shifts=[], - pagination_info=PaginationInfoSchema( - total_pages=0, - total_items=0 - ) - ) + return await self.session.scalar(count_query) + @staticmethod + def empty_shifts_response() -> GetWorkShiftsResponse: + return GetWorkShiftsResponse( + shifts=[], + pagination_info=PaginationInfoSchema( + total_pages=0, + total_items=0 + ) + ) + + async def get_shifts(self, is_active: bool, pagination: PaginationSchema) -> GetWorkShiftsResponse: + self.validate_pagination(pagination) + + page = max(0, pagination.page - 1) + + total_shifts = await self.get_total_shifts_count(is_active) + if not total_shifts: + return self.empty_shifts_response() total_pages = math.ceil(total_shifts / pagination.items_per_page) - stmt = ( - select(WorkShift) - .options(joinedload(WorkShift.user)) - .where(WorkShift.finished_at.is_(None) if is_active else WorkShift.finished_at.is_not(None)) - .order_by(WorkShift.started_at.desc()) + if is_active: + stmt = self.get_active_work_shifts_stmt() + else: + stmt = self.get_work_shifts_history_stmt() + + stmt_with_pagination = ( + stmt .offset(page * pagination.items_per_page) .limit(pagination.items_per_page) ) - shifts = (await self.session.execute(stmt)).scalars().all() + + shifts_rows = await self.session.execute(stmt_with_pagination) + shifts = [] + for shift, total_hours, pause_hours in shifts_rows: + shift = WorkShiftRowSchema( + work_shift=shift, + total_hours=total_hours, + pause_hours=pause_hours, + ) + shifts.append(shift) return GetWorkShiftsResponse( shifts=shifts, @@ -142,10 +208,77 @@ class WorkShiftsService(BaseService): ) async def delete_shift(self, shift_id: int) -> DeleteShiftResponse: - stmt = ( - delete(WorkShift) - .where(WorkShift.id == shift_id) - ) - await self.session.execute(stmt) - await self.session.commit() + work_shift = await self.session.get(WorkShift, shift_id) + if work_shift: + await self.session.delete(work_shift) + await self.session.commit() return DeleteShiftResponse(ok=True, message="Запись о смене успешно удалена") + + async def _get_last_work_shift_pause(self, work_shift_id: int) -> Optional[WorkShiftPause]: + stmt = ( + select(WorkShiftPause) + .where(WorkShiftPause.work_shift_id == work_shift_id) + .order_by(WorkShiftPause.started_at.desc()) + .limit(1) + ) + + work_shift_pause = await self.session.execute(stmt) + work_shift_pause = work_shift_pause.one_or_none() + work_shift_pause = work_shift_pause[0] if work_shift_pause else None + + return work_shift_pause if work_shift_pause and work_shift_pause.started_at.date() == date.today() else None + + async def start_pause(self, work_shift: Optional[WorkShift]) -> tuple[bool, str]: + if not work_shift: + return False, "Смена не найдена" + if work_shift.finished_at: + return False, "Смена уже завершена" + + work_shift_pause = await self._get_last_work_shift_pause(work_shift.id) + if work_shift_pause and not work_shift_pause.finished_at: + return False, "Перерыв для смены уже начат" + + work_shift.is_paused = True + work_shift_pause = WorkShiftPause(work_shift_id=work_shift.id, started_at=datetime.now()) + self.session.add(work_shift_pause) + await self.session.commit() + return True, "Перерыв начат" + + async def start_pause_by_user_id(self, user_id: int) -> StartPauseByUserIdResponse: + last_shift = await self._get_last_work_shift(user_id) + ok, message = await self.start_pause(last_shift) + return StartPauseByUserIdResponse(ok=ok, message=message) + + async def start_pause_by_shift_id(self, shift_id: int) -> StartPauseByShiftIdResponse: + work_shift = await self.session.get(WorkShift, shift_id) + ok, message = await self.start_pause(work_shift) + return StartPauseByShiftIdResponse(ok=ok, message=message) + + async def finish_pause(self, work_shift: Optional[WorkShift]) -> tuple[bool, str]: + if not work_shift: + return False, "Смена не найдена" + if work_shift.finished_at: + return False, "Смена уже завершена" + + work_shift_pause = await self._get_last_work_shift_pause(work_shift.id) + if not work_shift_pause or work_shift_pause.finished_at: + return False, "Перерыв еще не начат" + + work_shift.is_paused = False + work_shift_pause.finished_at = datetime.now() + await self.session.commit() + + diff: timedelta = work_shift_pause.finished_at - work_shift_pause.started_at + + hours, minutes = hours_to_hours_and_minutes(diff) + return True, f"Перерыв закончен: {hours} ч. {minutes} мин." + + async def finish_pause_by_user_id(self, user_id: int) -> FinishPauseByUserIdResponse: + last_shift = await self._get_last_work_shift(user_id) + ok, message = await self.finish_pause(last_shift) + return FinishPauseByUserIdResponse(ok=ok, message=message) + + async def finish_pause_by_shift_id(self, shift_id: int) -> FinishPauseByShiftIdResponse: + work_shift = await self.session.get(WorkShift, shift_id) + ok, message = await self.finish_pause(work_shift) + return FinishPauseByShiftIdResponse(ok=ok, message=message) diff --git a/utils/work_time.py b/utils/work_time.py index dace465..68b8e08 100644 --- a/utils/work_time.py +++ b/utils/work_time.py @@ -1,7 +1,13 @@ +from datetime import timedelta + from math import floor -def hours_to_hours_and_minutes(hours: float) -> tuple[int, int]: +def hours_to_hours_and_minutes(time: timedelta) -> tuple[int, int]: + if time.total_seconds() < 60: + return 0, 0 + + hours = time.total_seconds() / 3600 res_hours = int(floor(hours)) - minutes = int(round((hours - res_hours) * 60)) + minutes = int(floor((hours - res_hours) * 60)) return res_hours, minutes