feat: work shifts by QR codes
This commit is contained in:
		
							
								
								
									
										1
									
								
								generators/work_shifts_qr_code_generator/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								generators/work_shifts_qr_code_generator/__init__.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
from .generator import WorkShiftsQRCodeGenerator
 | 
			
		||||
							
								
								
									
										68
									
								
								generators/work_shifts_qr_code_generator/generator.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								generators/work_shifts_qr_code_generator/generator.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,68 @@
 | 
			
		||||
import os
 | 
			
		||||
from io import BytesIO
 | 
			
		||||
 | 
			
		||||
from reportlab.pdfgen.canvas import Canvas
 | 
			
		||||
from reportlab_qrcode import QRCodeImage
 | 
			
		||||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
 | 
			
		||||
from reportlab.lib.units import mm
 | 
			
		||||
from reportlab.pdfbase import pdfmetrics
 | 
			
		||||
from reportlab.pdfbase.ttfonts import TTFont
 | 
			
		||||
from reportlab.platypus import SimpleDocTemplate, Paragraph
 | 
			
		||||
from sqlalchemy.ext.asyncio import AsyncSession
 | 
			
		||||
 | 
			
		||||
from constants import APP_PATH
 | 
			
		||||
from models import User
 | 
			
		||||
from services.user import UserService
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class WorkShiftsQRCodeGenerator:
 | 
			
		||||
    def __init__(self, session: AsyncSession):
 | 
			
		||||
        self._session = session
 | 
			
		||||
        assets_folder = os.path.join(APP_PATH, 'assets')
 | 
			
		||||
        fonts_folder = os.path.join(assets_folder, 'fonts')
 | 
			
		||||
        font_file_path = os.path.join(fonts_folder, 'DejaVuSans.ttf')
 | 
			
		||||
        self.page_width = 58 * mm
 | 
			
		||||
        self.page_height = 40 * mm
 | 
			
		||||
        pdfmetrics.registerFont(TTFont('DejaVuSans', font_file_path))
 | 
			
		||||
 | 
			
		||||
        styles = getSampleStyleSheet()
 | 
			
		||||
        self.small_style = ParagraphStyle(
 | 
			
		||||
            'Small',
 | 
			
		||||
            parent=styles['Normal'],
 | 
			
		||||
            alignment=1,
 | 
			
		||||
            fontName='DejaVuSans',  # Specify the new font
 | 
			
		||||
            fontSize=9,
 | 
			
		||||
            leading=12,
 | 
			
		||||
            spaceAfter=2,
 | 
			
		||||
            rightIndent=2,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def _create_doc(self, buffer):
 | 
			
		||||
        return SimpleDocTemplate(
 | 
			
		||||
            buffer,
 | 
			
		||||
            pagesize=(self.page_width, self.page_height),
 | 
			
		||||
            rightMargin=1,
 | 
			
		||||
            leftMargin=1,
 | 
			
		||||
            topMargin=1,
 | 
			
		||||
            bottomMargin=1
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    async def generate(self, user_id: int) -> BytesIO:
 | 
			
		||||
        buffer = BytesIO()
 | 
			
		||||
        doc = self._create_doc(buffer)
 | 
			
		||||
 | 
			
		||||
        def on_first_page(canvas: Canvas, doc):
 | 
			
		||||
            qr = QRCodeImage(str(user_id), size=33 * mm)
 | 
			
		||||
            qr.drawOn(canvas, 34, 0)
 | 
			
		||||
 | 
			
		||||
        user: User = await UserService(self._session).get_by_id(user_id)
 | 
			
		||||
        position = user.position.name if user.position else ""
 | 
			
		||||
        user_info = Paragraph(
 | 
			
		||||
            f"{user.first_name} {user.second_name}\n{position}",
 | 
			
		||||
            self.small_style
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        doc.build([user_info], on_first_page)
 | 
			
		||||
 | 
			
		||||
        buffer.seek(0)
 | 
			
		||||
        return buffer
 | 
			
		||||
							
								
								
									
										1
									
								
								main.py
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								main.py
									
									
									
									
									
								
							@@ -45,6 +45,7 @@ routers_list = [
 | 
			
		||||
    routers.time_tracking_router,
 | 
			
		||||
    routers.billing_router,
 | 
			
		||||
    routers.task_router,
 | 
			
		||||
    routers.work_shifts_router,
 | 
			
		||||
]
 | 
			
		||||
for router in routers_list:
 | 
			
		||||
    app.include_router(router)
 | 
			
		||||
 
 | 
			
		||||
@@ -5,6 +5,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
 | 
			
		||||
 | 
			
		||||
from enums.user import UserRole
 | 
			
		||||
from models.base import BaseModel
 | 
			
		||||
from models.work_shifts import WorkShift
 | 
			
		||||
 | 
			
		||||
if TYPE_CHECKING:
 | 
			
		||||
    from models.payroll import PayRate, PaymentRecord
 | 
			
		||||
@@ -91,6 +92,12 @@ class User(BaseModel):
 | 
			
		||||
        uselist=True,
 | 
			
		||||
        foreign_keys="PaymentRecord.user_id"
 | 
			
		||||
    )
 | 
			
		||||
    work_shifts: Mapped[list["WorkShift"]] = relationship(
 | 
			
		||||
        "WorkShift",
 | 
			
		||||
        back_populates="user",
 | 
			
		||||
        uselist=True,
 | 
			
		||||
        foreign_keys="WorkShift.user_id"
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Position(BaseModel):
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										30
									
								
								models/work_shifts.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								models/work_shifts.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,30 @@
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
from typing import TYPE_CHECKING
 | 
			
		||||
 | 
			
		||||
from sqlalchemy import ForeignKey
 | 
			
		||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
 | 
			
		||||
 | 
			
		||||
from models.base import BaseModel
 | 
			
		||||
 | 
			
		||||
if TYPE_CHECKING:
 | 
			
		||||
    from models import User
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class WorkShift(BaseModel):
 | 
			
		||||
    __tablename__ = "work_shifts"
 | 
			
		||||
 | 
			
		||||
    id: Mapped[int] = mapped_column(primary_key=True)
 | 
			
		||||
    started_at: Mapped[datetime] = mapped_column(
 | 
			
		||||
        nullable=False,
 | 
			
		||||
    )
 | 
			
		||||
    finished_at: Mapped[datetime] = mapped_column(
 | 
			
		||||
        nullable=True,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    user_id: Mapped[int] = mapped_column(
 | 
			
		||||
        ForeignKey("users.id"),
 | 
			
		||||
    )
 | 
			
		||||
    user: Mapped["User"] = relationship(
 | 
			
		||||
        "User",
 | 
			
		||||
        back_populates="work_shifts",
 | 
			
		||||
    )
 | 
			
		||||
@@ -26,6 +26,7 @@ lexorank-py
 | 
			
		||||
celery[redis]
 | 
			
		||||
celery
 | 
			
		||||
aioshutil
 | 
			
		||||
reportlab-qrcode
 | 
			
		||||
# PDF
 | 
			
		||||
reportlab
 | 
			
		||||
weasyprint
 | 
			
		||||
 
 | 
			
		||||
@@ -13,3 +13,4 @@ from .payroll import payroll_router
 | 
			
		||||
from .time_tracking import time_tracking_router
 | 
			
		||||
from .billing import billing_router
 | 
			
		||||
from .task import task_router
 | 
			
		||||
from .work_shifts import work_shifts_router
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										87
									
								
								routers/work_shifts.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								routers/work_shifts.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,87 @@
 | 
			
		||||
from io import BytesIO
 | 
			
		||||
 | 
			
		||||
from fastapi import APIRouter, Response
 | 
			
		||||
 | 
			
		||||
from backend.dependecies import SessionDependency, CurrentUserDependency
 | 
			
		||||
from generators.work_shifts_qr_code_generator import WorkShiftsQRCodeGenerator
 | 
			
		||||
from schemas.work_shifts import StartShiftResponse, FinishShiftResponse, ActiveWorkShiftsResponse, DeleteShiftResponse, \
 | 
			
		||||
    FinishShiftByIdResponse
 | 
			
		||||
from services.work_shifts import WorkShiftsService
 | 
			
		||||
 | 
			
		||||
work_shifts_router = APIRouter(
 | 
			
		||||
    prefix="/work-shifts",
 | 
			
		||||
    tags=["work-shifts"],
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@work_shifts_router.get(
 | 
			
		||||
    "/generate-qr-code/{user_id}",
 | 
			
		||||
    operation_id="generate_qr_code",
 | 
			
		||||
)
 | 
			
		||||
async def generate_qr_code(
 | 
			
		||||
        session: SessionDependency,
 | 
			
		||||
        user_id: int,
 | 
			
		||||
):
 | 
			
		||||
    pdf_file: BytesIO = await WorkShiftsQRCodeGenerator(session).generate(user_id)
 | 
			
		||||
    return Response(pdf_file.getvalue(), media_type="application/pdf")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@work_shifts_router.post(
 | 
			
		||||
    "/start-shift/{user_id}",
 | 
			
		||||
    response_model=StartShiftResponse,
 | 
			
		||||
    operation_id="start_shift",
 | 
			
		||||
)
 | 
			
		||||
async def start_shift(
 | 
			
		||||
        session: SessionDependency,
 | 
			
		||||
        user_id: int,
 | 
			
		||||
):
 | 
			
		||||
    return await WorkShiftsService(session).start_shift(user_id)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@work_shifts_router.post(
 | 
			
		||||
    "/finish-shift/{user_id}",
 | 
			
		||||
    response_model=FinishShiftResponse,
 | 
			
		||||
    operation_id="finish_shift",
 | 
			
		||||
)
 | 
			
		||||
async def finish_shift(
 | 
			
		||||
        session: SessionDependency,
 | 
			
		||||
        user_id: int,
 | 
			
		||||
        user: CurrentUserDependency,
 | 
			
		||||
):
 | 
			
		||||
    return await WorkShiftsService(session).finish_shift(user, user_id)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@work_shifts_router.post(
 | 
			
		||||
    "/finish-shift-by-id/{shift_id}",
 | 
			
		||||
    response_model=FinishShiftByIdResponse,
 | 
			
		||||
    operation_id="finish_work_shift_by_id",
 | 
			
		||||
)
 | 
			
		||||
async def finish_work_shift_by_id(
 | 
			
		||||
        session: SessionDependency,
 | 
			
		||||
        user: CurrentUserDependency,
 | 
			
		||||
        shift_id: int,
 | 
			
		||||
):
 | 
			
		||||
    return await WorkShiftsService(session).finish_shift_by_id(user, shift_id)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@work_shifts_router.get(
 | 
			
		||||
    "/get-active-shifts",
 | 
			
		||||
    response_model=ActiveWorkShiftsResponse,
 | 
			
		||||
    operation_id="get_active_shifts",
 | 
			
		||||
)
 | 
			
		||||
async def get_active_shifts(
 | 
			
		||||
        session: SessionDependency,
 | 
			
		||||
):
 | 
			
		||||
    return await WorkShiftsService(session).get_active_shifts()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@work_shifts_router.delete(
 | 
			
		||||
    "/delete-shift/{shift_id}",
 | 
			
		||||
    response_model=DeleteShiftResponse,
 | 
			
		||||
    operation_id="delete_work_shift",
 | 
			
		||||
)
 | 
			
		||||
async def delete_work_shift(
 | 
			
		||||
        session: SessionDependency,
 | 
			
		||||
        shift_id: int,
 | 
			
		||||
):
 | 
			
		||||
    return await WorkShiftsService(session).delete_shift(shift_id)
 | 
			
		||||
							
								
								
									
										39
									
								
								schemas/work_shifts.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								schemas/work_shifts.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,39 @@
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
from typing import List
 | 
			
		||||
 | 
			
		||||
from schemas.base import OkMessageSchema, BaseSchema
 | 
			
		||||
from schemas.user import UserSchema
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# region Entities
 | 
			
		||||
 | 
			
		||||
class ActiveWorkShiftSchema(BaseSchema):
 | 
			
		||||
    id: int
 | 
			
		||||
    started_at: datetime
 | 
			
		||||
    user: UserSchema
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# endregion
 | 
			
		||||
 | 
			
		||||
# region Responses
 | 
			
		||||
 | 
			
		||||
class StartShiftResponse(OkMessageSchema):
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class FinishShiftResponse(OkMessageSchema):
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class FinishShiftByIdResponse(OkMessageSchema):
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ActiveWorkShiftsResponse(BaseSchema):
 | 
			
		||||
    shifts: List[ActiveWorkShiftSchema]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class DeleteShiftResponse(OkMessageSchema):
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
# endregion
 | 
			
		||||
							
								
								
									
										114
									
								
								services/work_shifts.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								services/work_shifts.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,114 @@
 | 
			
		||||
from datetime import datetime, date, timedelta
 | 
			
		||||
 | 
			
		||||
from sqlalchemy import select, delete
 | 
			
		||||
from sqlalchemy.orm import joinedload
 | 
			
		||||
 | 
			
		||||
from models import WorkShift, User
 | 
			
		||||
from schemas.time_tracking import UpdateTimeTrackingRecordRequest
 | 
			
		||||
from schemas.user import *
 | 
			
		||||
from schemas.work_shifts import StartShiftResponse, FinishShiftResponse, ActiveWorkShiftsResponse, DeleteShiftResponse, \
 | 
			
		||||
    FinishShiftByIdResponse
 | 
			
		||||
from services.base import BaseService
 | 
			
		||||
from services.time_tracking import TimeTrackingService
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class WorkShiftsService(BaseService):
 | 
			
		||||
    async def _get_last_work_shift_for_today(self, user_id: int) -> Optional[WorkShift]:
 | 
			
		||||
        stmt = (
 | 
			
		||||
            select(WorkShift)
 | 
			
		||||
            .where(WorkShift.user_id == user_id)
 | 
			
		||||
            .order_by(WorkShift.started_at.desc())
 | 
			
		||||
            .limit(1)
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        work_shift = await self.session.execute(stmt)
 | 
			
		||||
        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
 | 
			
		||||
 | 
			
		||||
    async def start_shift(self, user_id: int) -> StartShiftResponse:
 | 
			
		||||
        employee = await self.session.get(User, user_id)
 | 
			
		||||
        if not employee:
 | 
			
		||||
            return StartShiftResponse(ok=False, message=f"Пользователь с ID {user_id} не найден")
 | 
			
		||||
 | 
			
		||||
        work_shift = await self._get_last_work_shift_for_today(user_id)
 | 
			
		||||
        if work_shift:
 | 
			
		||||
            if not work_shift.finished_at:
 | 
			
		||||
                return StartShiftResponse(ok=False, message="Предыдущая смена еще не закончена")
 | 
			
		||||
            return StartShiftResponse(ok=False, message="Смена для сотрудника на сегодня уже закончена")
 | 
			
		||||
 | 
			
		||||
        work_shift = WorkShift(user_id=user_id, started_at=datetime.now())
 | 
			
		||||
        self.session.add(work_shift)
 | 
			
		||||
        await self.session.commit()
 | 
			
		||||
        return StartShiftResponse(ok=True, message="Смена начата")
 | 
			
		||||
 | 
			
		||||
    async def finish_shift(self, user: User, user_id: int) -> FinishShiftResponse:
 | 
			
		||||
        employee = await self.session.get(User, user_id)
 | 
			
		||||
        if not employee:
 | 
			
		||||
            return FinishShiftResponse(ok=False, message=f"Пользователь с ID {user_id} не найден")
 | 
			
		||||
 | 
			
		||||
        work_shift = await self._get_last_work_shift_for_today(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 = int(diff.total_seconds() // 3600)
 | 
			
		||||
 | 
			
		||||
        data = UpdateTimeTrackingRecordRequest(
 | 
			
		||||
            user_id=user_id,
 | 
			
		||||
            date=work_shift.started_at.date(),
 | 
			
		||||
            hours=hours,
 | 
			
		||||
        )
 | 
			
		||||
        await TimeTrackingService(self.session).update_record(user, data)
 | 
			
		||||
 | 
			
		||||
        return FinishShiftResponse(ok=True, message=f"Смена закончена. Отработано {hours} ч.")
 | 
			
		||||
 | 
			
		||||
    async def finish_shift_by_id(self, user: User, shift_id: int) -> FinishShiftByIdResponse:
 | 
			
		||||
        work_shift = await self.session.get(WorkShift, shift_id)
 | 
			
		||||
 | 
			
		||||
        if not work_shift or work_shift.finished_at:
 | 
			
		||||
            return FinishShiftByIdResponse(ok=False, message="Смена для сотрудника еще не начата")
 | 
			
		||||
 | 
			
		||||
        work_shift.finished_at = datetime.now()
 | 
			
		||||
        await self.session.commit()
 | 
			
		||||
 | 
			
		||||
        diff: timedelta = work_shift.finished_at - work_shift.started_at
 | 
			
		||||
        hours = int(diff.total_seconds() // 3600)
 | 
			
		||||
 | 
			
		||||
        data = UpdateTimeTrackingRecordRequest(
 | 
			
		||||
            user_id=work_shift.user_id,
 | 
			
		||||
            date=work_shift.started_at.date(),
 | 
			
		||||
            hours=hours,
 | 
			
		||||
        )
 | 
			
		||||
        await TimeTrackingService(self.session).update_record(user, data)
 | 
			
		||||
 | 
			
		||||
        return FinishShiftByIdResponse(ok=True, message=f"Смена закончена. Отработано {hours} ч.")
 | 
			
		||||
 | 
			
		||||
    async def get_active_shifts(self) -> ActiveWorkShiftsResponse:
 | 
			
		||||
        stmt = (
 | 
			
		||||
            select(WorkShift)
 | 
			
		||||
            .options(joinedload(WorkShift.user))
 | 
			
		||||
            .where(WorkShift.finished_at == None)
 | 
			
		||||
            .order_by(WorkShift.started_at.desc())
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        shifts = await self.session.execute(stmt)
 | 
			
		||||
        shifts = shifts.scalars().all()
 | 
			
		||||
        response = ActiveWorkShiftsResponse(shifts=shifts)
 | 
			
		||||
        return response
 | 
			
		||||
 | 
			
		||||
    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()
 | 
			
		||||
        return DeleteShiftResponse(ok=True, message="Запись о смене успешно удалена")
 | 
			
		||||
		Reference in New Issue
	
	Block a user