From 37aa7a5fcf9fa6ecefa1b40072ceb575cc73aea0 Mon Sep 17 00:00:00 2001 From: Ivan Reznichenko Date: Thu, 29 Jun 2023 17:00:28 +0200 Subject: [PATCH 1/3] .gitignore was changed - MacOSX files are now ignored - Vim files are now ignored --- .gitignore | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/.gitignore b/.gitignore index cc774935a..5d1dd9eba 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,30 @@ fill_db.log # Frontend build frontend/ + +### Ignoring all vim's +# Swap files +[._]*.s[a-v][a-z] +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] +# Session file +Session.vim +# Temporary files +.netrwhist +*~ +# Auto-generated tag files +tags +# Persistent undo +[._]*.un~ + +### Ignoring MacOSX files +# General +.DS_Store +.AppleDouble +.LSOverride +# Icon must end with two \r +Icon +# Thumbnails +._* From 973f06cf9cf03f4e68e6707fc7ece2aef32a0804 Mon Sep 17 00:00:00 2001 From: Ivan Reznichenko Date: Thu, 29 Jun 2023 17:04:05 +0200 Subject: [PATCH 2/3] User analytics report was added. --- src/api/routers/analytics.py | 26 +++++++ src/core/db/DTO_models.py | 13 ++++ src/core/db/repository/user_repository.py | 65 ++++++++++++++++- src/core/services/analytics_service.py | 72 ++++++++++++++++--- .../base_analytic_report_settings.py | 8 +++ src/excel_generator/builder.py | 42 +++++------ src/excel_generator/shift_builder.py | 68 +++++++++--------- src/excel_generator/task_builder.py | 9 +-- src/excel_generator/user_builder.py | 67 +++++++++++++++++ 9 files changed, 294 insertions(+), 76 deletions(-) create mode 100644 src/excel_generator/base_analytic_report_settings.py create mode 100644 src/excel_generator/user_builder.py diff --git a/src/api/routers/analytics.py b/src/api/routers/analytics.py index e10f8aaa9..3ec685c0b 100644 --- a/src/api/routers/analytics.py +++ b/src/api/routers/analytics.py @@ -7,6 +7,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi_restful.cbv import cbv +from src.api.response_models.error import generate_error_responses from src.core.services.analytics_service import AnalyticsService router = APIRouter(prefix="/analytics", tags=["Analytics"]) @@ -59,6 +60,7 @@ async def generate_task_report(self) -> StreamingResponse: response_class=StreamingResponse, status_code=HTTPStatus.OK, summary="Формирование отчёта по выбранной смене", + responses=generate_error_responses(HTTPStatus.NOT_FOUND), ) async def generate_report_for_shift(self, shift_id: UUID) -> StreamingResponse: """ @@ -73,3 +75,27 @@ async def generate_report_for_shift(self, shift_id: UUID) -> StreamingResponse: headers = {'Content-Disposition': f'attachment; filename={filename}'} workbook = await self._analytics_service.generate_report_for_shift(shift_id) return StreamingResponse(workbook, headers=headers) + + @router.get( + "/user/{user_id}", + response_model=None, + response_class=StreamingResponse, + status_code=HTTPStatus.OK, + summary="Формирование отчёта по пользователю", + responses=generate_error_responses(HTTPStatus.NOT_FOUND), + ) + async def generate_report_for_user(self, user_id: UUID) -> StreamingResponse: + """Формирует отчёт по выбранному участнику. + + Отчёт содержит: + - список всех задач; + - количество отчетов, принятых с 1-й/2-й/3-й попытки по каждому заданию; + - общее количество принятых/отклонённых/не предоставленных отчётов по каждому заданию; + - список всех смен участника; + - количество отчетов, принятых с 1-й/2-й/3-й попытки по каждой смене; + - общее количество принятых/отклонённых/не предоставленных отчётов по каждой смене + """ + filename = await self._analytics_service.generate_user_report_filename(user_id) + headers = {'Content-Disposition': f'attachment; filename={filename}'} + workbook = await self._analytics_service.generate_report_for_user(user_id) + return StreamingResponse(workbook, headers=headers) diff --git a/src/core/db/DTO_models.py b/src/core/db/DTO_models.py index a60c0cfe9..722e1ccef 100644 --- a/src/core/db/DTO_models.py +++ b/src/core/db/DTO_models.py @@ -94,3 +94,16 @@ class ShiftByUserWithReportSummaryDto: total_declined: int total_skipped: int is_excluded: bool + + +@dataclass +class UserAnalyticReportDto: + sequence_number: int + title: str + approved_from_1_attempt: int + approved_from_2_attempt: int + approved_from_3_attempt: int + approved: int + declined: int + skipped: int + reports_total: int diff --git a/src/core/db/repository/user_repository.py b/src/core/db/repository/user_repository.py index 051acb8e3..288e5edac 100644 --- a/src/core/db/repository/user_repository.py +++ b/src/core/db/repository/user_repository.py @@ -7,8 +7,11 @@ from src.api.request_models.user import UserDescAscSortRequest, UserFieldSortRequest from src.core.db.db import get_session -from src.core.db.DTO_models import ShiftByUserWithReportSummaryDto -from src.core.db.models import Member, Report, Request, Shift, User +from src.core.db.DTO_models import ( + ShiftByUserWithReportSummaryDto, + UserAnalyticReportDto, +) +from src.core.db.models import Member, Report, Request, Shift, Task, User from src.core.db.repository import AbstractRepository @@ -110,3 +113,61 @@ async def get_users_by_shift_id(self, shift_id: UUID) -> list[User]: select(User).where(User.id.in_(select(Request.user_id).where(Request.shift_id == shift_id))) ) return users.scalars().all() + + async def get_user_task_statistics_report_by_id(self, user_id: UUID): + """Отчёт по задачам участника. + + Содержит: + - список всех задач; + - количество отчетов принятых с 1-й/2-й/3-й попытки; + - общее количество принятых/отклонённых/не предоставленных отчётов по каждому заданию. + """ + stmt = ( + select( + Task.sequence_number, + Task.title, + func.count().filter(Report.number_attempt == 0).label('approved_from_1_attempt'), + func.count().filter(Report.number_attempt == 1).label('approved_from_2_attempt'), + func.count().filter(Report.number_attempt == 2).label('approved_from_3_attempt'), + func.count().filter(Report.status == Report.Status.APPROVED).label(Report.Status.APPROVED), + func.count().filter(Report.status == Report.Status.DECLINED).label(Report.Status.DECLINED), + func.count().filter(Report.status == Report.Status.SKIPPED).label(Report.Status.SKIPPED), + func.count().label('reports_total'), + ) + .join(Task.reports) + .join(Report.member) + .where(Member.user_id == user_id) + .group_by(Task.sequence_number, Task.id) + .order_by(Task.sequence_number) + ) + reports = await self._session.execute(stmt) + return tuple(UserAnalyticReportDto(*report) for report in reports.all()) + + async def get_user_shift_statistics_report_by_id(self, user_id: UUID): + """Отчёт по сменам участника. + + Содержит: + - список всех смен; + - количество отчетов принятых с 1-й/2-й/3-й попытки; + - общее количество принятых/отклонённых/не предоставленных отчётов по каждому заданию. + """ + stmt = ( + select( + Shift.sequence_number, + Shift.title, + func.count().filter(Report.number_attempt == 0).label('approved_from_1_attempt'), + func.count().filter(Report.number_attempt == 1).label('approved_from_2_attempt'), + func.count().filter(Report.number_attempt == 2).label('approved_from_3_attempt'), + func.count().filter(Report.status == Report.Status.APPROVED).label(Report.Status.APPROVED), + func.count().filter(Report.status == Report.Status.DECLINED).label(Report.Status.DECLINED), + func.count().filter(Report.status == Report.Status.SKIPPED).label(Report.Status.SKIPPED), + func.count().label('reports_total'), + ) + .join(Shift.reports) + .join(Report.member) + .where(Member.user_id == user_id) + .group_by(Shift.sequence_number, Shift.id) + .order_by(Shift.sequence_number) + ) + reports = await self._session.execute(stmt) + return tuple(UserAnalyticReportDto(*report) for report in reports.all()) diff --git a/src/core/services/analytics_service.py b/src/core/services/analytics_service.py index 5b302f9c8..2baeef103 100644 --- a/src/core/services/analytics_service.py +++ b/src/core/services/analytics_service.py @@ -8,9 +8,14 @@ from src.core.db.repository.shift_repository import ShiftRepository from src.core.db.repository.task_repository import TaskRepository +from src.core.db.repository.user_repository import UserRepository from src.excel_generator.builder import AnalyticReportBuilder from src.excel_generator.shift_builder import ShiftAnalyticReportSettings from src.excel_generator.task_builder import TaskAnalyticReportSettings +from src.excel_generator.user_builder import ( + UserShiftAnalyticReportSettings, + UserTaskAnalyticReportSettings, +) class AnalyticsService: @@ -20,11 +25,13 @@ def __init__( self, task_repository: TaskRepository = Depends(), shift_repository: ShiftRepository = Depends(), + user_repository: UserRepository = Depends(), task_report_builder: AnalyticReportBuilder = Depends(), ) -> None: - self.__task_report_builder = task_report_builder + self.__workbook = task_report_builder self.__task_repository = task_repository self.__shift_repository = shift_repository + self.__user_repository = user_repository @staticmethod async def __generate_task_report_description() -> str: @@ -35,11 +42,11 @@ async def __generate_task_report(self, workbook: Workbook) -> None: """Генерация отчёта с заданиями.""" tasks_statistic = await self.__task_repository.get_tasks_statistics_report() description = await self.__generate_task_report_description() - await self.__task_report_builder.generate_report( + self.__workbook.add_sheet( description, tasks_statistic, workbook=workbook, - analytic_task_report_full=TaskAnalyticReportSettings, + analytic_report_settings=TaskAnalyticReportSettings, ) async def __generate_shift_report_description(self, shift_id: UUID) -> str: @@ -56,31 +63,31 @@ async def __generate_report_for_shift(self, workbook: Workbook, shift_id: UUID) """Генерация отчёта по выбранной смене.""" shift_statistic = await self.__shift_repository.get_shift_statistics_report_by_id(shift_id) description = await self.__generate_shift_report_description(shift_id) - await self.__task_report_builder.generate_report( + self.__workbook.add_sheet( description, shift_statistic, workbook=workbook, - analytic_task_report_full=ShiftAnalyticReportSettings, + analytic_report_settings=ShiftAnalyticReportSettings, ) async def generate_full_report(self) -> BytesIO: """Генерация полного отчёта.""" - workbook = self.__task_report_builder.create_workbook() + workbook = self.__workbook.create_workbook() await self.__generate_task_report(workbook) await self.__generate_task_report(workbook) - return await self.__task_report_builder.get_report_response(workbook) + return self.__workbook.get_report_response(workbook) async def generate_task_report(self) -> BytesIO: """Генерация отчёта с заданиями.""" - workbook = self.__task_report_builder.create_workbook() + workbook = self.__workbook.create_workbook() await self.__generate_task_report(workbook) - return await self.__task_report_builder.get_report_response(workbook) + return self.__workbook.get_report_response(workbook) async def generate_report_for_shift(self, shift_id: UUID) -> BytesIO: """Генерация отчёта по выбранной смене.""" - workbook = self.__task_report_builder.create_workbook() + workbook = self.__workbook.create_workbook() await self.__generate_report_for_shift(workbook, shift_id) - return await self.__task_report_builder.get_report_response(workbook) + return self.__workbook.get_report_response(workbook) async def generate_shift_report_filename(self, shift_id: UUID) -> str: """Генерация названия файла отчета по смене.""" @@ -88,3 +95,46 @@ async def generate_shift_report_filename(self, shift_id: UUID) -> str: shift_name = shift.title.replace(' ', '_').replace('.', '') filename = f"Отчёт_по_смене_№{shift.sequence_number}_{shift_name}_{date.today().strftime('%d-%m-%Y')}.xlsx" return quote_plus(filename) + + async def generate_user_report_filename(self, user_id: UUID) -> str: + """Генерация названия файла отчета по участнику.""" + user = await self.__user_repository.get(user_id) + user_full_name = f"{user.surname}_{user.name}" + date_today = date.today().strftime('%d-%m-%Y') + filename = f"Отчёт_по_участнику_{user_full_name}_{date_today}.xlsx" + return quote_plus(filename) + + async def __generate_user_report_description(self, user_id: UUID) -> str: + """Генерация описания к отчёту по участнику.""" + user = await self.__user_repository.get(user_id) + return ( + f"Отчёт по участнику: {user.name} {user.surname}\n" + f"Дата формирования отчёта: {date.today().strftime('%d.%m.%Y')}" + ) + + async def __generate_report_for_user(self, workbook: Workbook, user_id: UUID) -> Workbook: + """Генерация отчёта по участнику.""" + description = await self.__generate_user_report_description(user_id) + + user_task_statistic = await self.__user_repository.get_user_task_statistics_report_by_id(user_id) + user_shift_statistic = await self.__user_repository.get_user_shift_statistics_report_by_id(user_id) + + self.__workbook.add_sheet( + description, + data=user_task_statistic, + workbook=workbook, + analytic_report_settings=UserTaskAnalyticReportSettings, + ) + self.__workbook.add_sheet( + description, + data=user_shift_statistic, + workbook=workbook, + analytic_report_settings=UserShiftAnalyticReportSettings, + ) + return workbook + + async def generate_report_for_user(self, user_id: UUID) -> BytesIO: + """Генерация отчёта по участнику.""" + workbook = self.__workbook.create_workbook() + workbook = await self.__generate_report_for_user(workbook, user_id) + return self.__workbook.get_report_response(workbook) diff --git a/src/excel_generator/base_analytic_report_settings.py b/src/excel_generator/base_analytic_report_settings.py new file mode 100644 index 000000000..752e5a321 --- /dev/null +++ b/src/excel_generator/base_analytic_report_settings.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + + +@dataclass +class BaseAnalyticReportSettings: + sheet_name: str + header_data: tuple[str] + row_coun: int diff --git a/src/excel_generator/builder.py b/src/excel_generator/builder.py index 158c216be..50e0f4320 100644 --- a/src/excel_generator/builder.py +++ b/src/excel_generator/builder.py @@ -7,41 +7,41 @@ from openpyxl.worksheet.worksheet import Worksheet from src.core.db.DTO_models import TasksAnalyticReportDto -from src.excel_generator.task_builder import BaseAnalyticReportSettings +from src.excel_generator.base_analytic_report_settings import BaseAnalyticReportSettings class AnalyticReportBuilder: """Интерфейс строителя.""" - async def generate_report( + def add_sheet( self, description: str, data: tuple[TasksAnalyticReportDto], workbook: Workbook, - analytic_task_report_full: BaseAnalyticReportSettings, + analytic_report_settings: BaseAnalyticReportSettings, ) -> Workbook: """Генерация листа с данными.""" - worksheet = self._create_sheet(workbook, sheet_name=analytic_task_report_full.sheet_name) - analytic_task_report_full.row_count = 0 - self.__add_description(worksheet, description, analytic_task_report_full) - self.__add_header(worksheet, analytic_task_report_full) - self.__add_data(worksheet, data, analytic_task_report_full) - self.__add_footer(worksheet, analytic_task_report_full) + worksheet = self._create_sheet(workbook, sheet_name=analytic_report_settings.sheet_name) + analytic_report_settings.row_count = 0 + self.__add_description(worksheet, description, analytic_report_settings) + self.__add_header(worksheet, analytic_report_settings) + self.__add_data(worksheet, data, analytic_report_settings) + self.__add_footer(worksheet, analytic_report_settings) self.__apply_styles(worksheet) return workbook def __add_row( self, worksheet: Worksheet, - analytic_task_report: BaseAnalyticReportSettings, + analytic_report_settings: BaseAnalyticReportSettings, data: tuple[str | int], ) -> None: - analytic_task_report.row_count += 1 + analytic_report_settings.row_count += 1 for index, value in enumerate(data, start=1): - worksheet.cell(row=analytic_task_report.row_count, column=index, value=value) + worksheet.cell(row=analytic_report_settings.row_count, column=index, value=value) @staticmethod - async def get_report_response(workbook: Workbook) -> BytesIO: + def get_report_response(workbook: Workbook) -> BytesIO: """Создание ответа.""" stream = BytesIO() workbook.save(stream) @@ -64,28 +64,28 @@ def __add_description( self, worksheet: Worksheet, description: str, - analytic_task_report: BaseAnalyticReportSettings, + analytic_report_settings: BaseAnalyticReportSettings, ) -> None: """Заполняет описание отчета.""" - self.__add_row(worksheet, analytic_task_report, (description,)) + self.__add_row(worksheet, analytic_report_settings, (description,)) - def __add_header(self, worksheet: Worksheet, analytic_task_report: BaseAnalyticReportSettings) -> None: + def __add_header(self, worksheet: Worksheet, analytic_report_settings: BaseAnalyticReportSettings) -> None: """Заполняет первые строки в листе.""" - self.__add_row(worksheet, analytic_task_report, analytic_task_report.header_data) + self.__add_row(worksheet, analytic_report_settings, analytic_report_settings.header_data) def __add_data( self, worksheet: Worksheet, data: tuple[TasksAnalyticReportDto], - analytic_task_report: BaseAnalyticReportSettings, + analytic_report_settings: BaseAnalyticReportSettings, ) -> None: """Заполняет строки данными из БД.""" for task in data: - self.__add_row(worksheet, analytic_task_report, data=astuple(task)) + self.__add_row(worksheet, analytic_report_settings, data=astuple(task)) - def __add_footer(self, worksheet: Worksheet, analytic_task_report: BaseAnalyticReportSettings) -> None: + def __add_footer(self, worksheet: Worksheet, analytic_report_settings: BaseAnalyticReportSettings) -> None: """Заполняет последнюю строку в листе.""" - self.__add_row(worksheet, analytic_task_report, data=analytic_task_report.footer_data) + self.__add_row(worksheet, analytic_report_settings, data=analytic_report_settings.footer_data) def __apply_styles(self, worksheet: Worksheet): """Задаёт форматирование отчёта.""" diff --git a/src/excel_generator/shift_builder.py b/src/excel_generator/shift_builder.py index 43e99e3dd..30e8e7183 100644 --- a/src/excel_generator/shift_builder.py +++ b/src/excel_generator/shift_builder.py @@ -1,34 +1,34 @@ -from src.excel_generator.task_builder import BaseAnalyticReportSettings - - -class ShiftAnalyticReportSettings(BaseAnalyticReportSettings): - """Конфигурация отчёта для выбранной смены.""" - - sheet_name: str = "Отчёт по смене" - header_data: tuple[str] = ( - "№ Задачи", - "Название", - "Принята с 1-й попытки", - "Принята с 2-й попытки", - "Принята с 3-й попытки", - "Кол-во пропусков задания", - "Кол-во одобренных отчётов", - "Кол-во отклонённых отчётов", - "Всего отчётов", - ) - row_count: int = 0 - - @classmethod - @property - def footer_data(cls): - return ( - "ИТОГО:", - "", - f"=SUM(C2:C{cls.row_count})", - f"=SUM(D2:D{cls.row_count})", - f"=SUM(E2:E{cls.row_count})", - f"=SUM(F2:F{cls.row_count})", - f"=SUM(G2:G{cls.row_count})", - f"=SUM(H2:H{cls.row_count})", - f"=SUM(I2:I{cls.row_count})", - ) +from src.excel_generator.base_analytic_report_settings import BaseAnalyticReportSettings + + +class ShiftAnalyticReportSettings(BaseAnalyticReportSettings): + """Конфигурация отчёта для выбранной смены.""" + + sheet_name: str = "Отчёт по смене" + header_data: tuple[str] = ( + "№ Задачи", + "Название", + "Принята с 1-й попытки", + "Принята с 2-й попытки", + "Принята с 3-й попытки", + "Кол-во пропусков задания", + "Кол-во одобренных отчётов", + "Кол-во отклонённых отчётов", + "Всего отчётов", + ) + row_count: int = 0 + + @classmethod + @property + def footer_data(cls): + return ( + "ИТОГО:", + "", + f"=SUM(C2:C{cls.row_count})", + f"=SUM(D2:D{cls.row_count})", + f"=SUM(E2:E{cls.row_count})", + f"=SUM(F2:F{cls.row_count})", + f"=SUM(G2:G{cls.row_count})", + f"=SUM(H2:H{cls.row_count})", + f"=SUM(I2:I{cls.row_count})", + ) diff --git a/src/excel_generator/task_builder.py b/src/excel_generator/task_builder.py index d42f21c0f..3eb9f9571 100644 --- a/src/excel_generator/task_builder.py +++ b/src/excel_generator/task_builder.py @@ -1,11 +1,4 @@ -from dataclasses import dataclass - - -@dataclass -class BaseAnalyticReportSettings: - sheet_name: str - header_data: tuple[str] - row_coun: int +from src.excel_generator.base_analytic_report_settings import BaseAnalyticReportSettings class TaskAnalyticReportSettings(BaseAnalyticReportSettings): diff --git a/src/excel_generator/user_builder.py b/src/excel_generator/user_builder.py new file mode 100644 index 000000000..bd3bc208e --- /dev/null +++ b/src/excel_generator/user_builder.py @@ -0,0 +1,67 @@ +from src.excel_generator.base_analytic_report_settings import BaseAnalyticReportSettings + + +class UserTaskAnalyticReportSettings(BaseAnalyticReportSettings): + """Конфигурация отчёта участника в разрезе задач.""" + + sheet_name: str = "Отчёт по задачам участника" + header_data: tuple[str] = ( + "№ Задачи", + "Название задачи", + "1", + "2", + "3", + "П", + "Д", + "О", + "В", + ) + row_count: int = 0 + + @classmethod + @property + def footer_data(cls): + return ( + "ИТОГО:", + "", + f"=SUM(C2:C{cls.row_count})", + f"=SUM(D2:D{cls.row_count})", + f"=SUM(E2:E{cls.row_count})", + f"=SUM(F2:F{cls.row_count})", + f"=SUM(G2:G{cls.row_count})", + f"=SUM(H2:H{cls.row_count})", + f"=SUM(I2:I{cls.row_count})", + ) + + +class UserShiftAnalyticReportSettings(BaseAnalyticReportSettings): + """Конфигурация отчёта участника в разрезе смен.""" + + sheet_name: str = "Отчёт по сменам участника" + header_data: tuple[str] = ( + "№ Смены", + "Название смены", + "1", + "2", + "3", + "П", + "Д", + "О", + "В", + ) + row_count: int = 0 + + @classmethod + @property + def footer_data(cls): + return ( + "ИТОГО:", + "", + f"=SUM(C2:C{cls.row_count})", + f"=SUM(D2:D{cls.row_count})", + f"=SUM(E2:E{cls.row_count})", + f"=SUM(F2:F{cls.row_count})", + f"=SUM(G2:G{cls.row_count})", + f"=SUM(H2:H{cls.row_count})", + f"=SUM(I2:I{cls.row_count})", + ) From c9d1a7a80710e9a9c00933de1298fe3ca0bf6b71 Mon Sep 17 00:00:00 2001 From: Ivan Reznichenko Date: Fri, 30 Jun 2023 23:48:48 +0200 Subject: [PATCH 3/3] Full user report. First draft --- src/core/db/repository/user_repository.py | 52 +++++++----- src/core/services/analytics_service.py | 35 ++++++-- .../base_analytic_report_settings.py | 2 +- src/excel_generator/builder.py | 5 +- src/excel_generator/user_builder.py | 80 +++++++++++++++---- 5 files changed, 130 insertions(+), 44 deletions(-) diff --git a/src/core/db/repository/user_repository.py b/src/core/db/repository/user_repository.py index 288e5edac..59c2be1d3 100644 --- a/src/core/db/repository/user_repository.py +++ b/src/core/db/repository/user_repository.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Optional, Sequence from uuid import UUID from fastapi import Depends @@ -114,40 +114,42 @@ async def get_users_by_shift_id(self, shift_id: UUID) -> list[User]: ) return users.scalars().all() - async def get_user_task_statistics_report_by_id(self, user_id: UUID): + async def get_user_task_statistics_by_id_and_shift(self, user_id: UUID, shift_id: Optional[UUID] = None): """Отчёт по задачам участника. Содержит: - - список всех задач; + - список *всех* задач; - количество отчетов принятых с 1-й/2-й/3-й попытки; - общее количество принятых/отклонённых/не предоставленных отчётов по каждому заданию. """ + sub_stmt = select(Member.id).filter(Member.user_id == user_id) + if shift_id: + sub_stmt = sub_stmt.filter(Member.shift_id == shift_id) + stmt = ( select( Task.sequence_number, Task.title, - func.count().filter(Report.number_attempt == 0).label('approved_from_1_attempt'), - func.count().filter(Report.number_attempt == 1).label('approved_from_2_attempt'), - func.count().filter(Report.number_attempt == 2).label('approved_from_3_attempt'), - func.count().filter(Report.status == Report.Status.APPROVED).label(Report.Status.APPROVED), - func.count().filter(Report.status == Report.Status.DECLINED).label(Report.Status.DECLINED), - func.count().filter(Report.status == Report.Status.SKIPPED).label(Report.Status.SKIPPED), - func.count().label('reports_total'), + func.count(Report.id).filter(Report.number_attempt == 0).label('approved_from_1_attempt'), + func.count(Report.id).filter(Report.number_attempt == 1).label('approved_from_2_attempt'), + func.count(Report.id).filter(Report.number_attempt == 2).label('approved_from_3_attempt'), + func.count(Report.id).filter(Report.status == Report.Status.APPROVED).label(Report.Status.APPROVED), + func.count(Report.id).filter(Report.status == Report.Status.DECLINED).label(Report.Status.DECLINED), + func.count(Report.id).filter(Report.status == Report.Status.SKIPPED).label(Report.Status.SKIPPED), + func.count(Report.id).label('reports_total'), ) - .join(Task.reports) - .join(Report.member) - .where(Member.user_id == user_id) + .outerjoin(Task.reports.and_(Report.member_id.in_(sub_stmt))) .group_by(Task.sequence_number, Task.id) .order_by(Task.sequence_number) ) reports = await self._session.execute(stmt) return tuple(UserAnalyticReportDto(*report) for report in reports.all()) - async def get_user_shift_statistics_report_by_id(self, user_id: UUID): + async def get_user_shift_statistics_by_id(self, user_id: UUID): """Отчёт по сменам участника. Содержит: - - список всех смен; + - список смен, в которых участвует указанный пользователь; - количество отчетов принятых с 1-й/2-й/3-й попытки; - общее количество принятых/отклонённых/не предоставленных отчётов по каждому заданию. """ @@ -155,13 +157,13 @@ async def get_user_shift_statistics_report_by_id(self, user_id: UUID): select( Shift.sequence_number, Shift.title, - func.count().filter(Report.number_attempt == 0).label('approved_from_1_attempt'), - func.count().filter(Report.number_attempt == 1).label('approved_from_2_attempt'), - func.count().filter(Report.number_attempt == 2).label('approved_from_3_attempt'), - func.count().filter(Report.status == Report.Status.APPROVED).label(Report.Status.APPROVED), - func.count().filter(Report.status == Report.Status.DECLINED).label(Report.Status.DECLINED), - func.count().filter(Report.status == Report.Status.SKIPPED).label(Report.Status.SKIPPED), - func.count().label('reports_total'), + func.count(Report.id).filter(Report.number_attempt == 0).label('approved_from_1_attempt'), + func.count(Report.id).filter(Report.number_attempt == 1).label('approved_from_2_attempt'), + func.count(Report.id).filter(Report.number_attempt == 2).label('approved_from_3_attempt'), + func.count(Report.id).filter(Report.status == Report.Status.APPROVED).label(Report.Status.APPROVED), + func.count(Report.id).filter(Report.status == Report.Status.DECLINED).label(Report.Status.DECLINED), + func.count(Report.id).filter(Report.status == Report.Status.SKIPPED).label(Report.Status.SKIPPED), + func.count(Report.id).label('reports_total'), ) .join(Shift.reports) .join(Report.member) @@ -171,3 +173,9 @@ async def get_user_shift_statistics_report_by_id(self, user_id: UUID): ) reports = await self._session.execute(stmt) return tuple(UserAnalyticReportDto(*report) for report in reports.all()) + + async def get_user_shifts_titles(self, user_id: UUID) -> Sequence[Shift]: + """Получить список смен заданного участника.""" + stmt = select(Shift).join(Member.shift).filter(Member.user_id == user_id) + shifts = await self._session.execute(stmt) + return shifts.scalars().all() diff --git a/src/core/services/analytics_service.py b/src/core/services/analytics_service.py index 2baeef103..201997989 100644 --- a/src/core/services/analytics_service.py +++ b/src/core/services/analytics_service.py @@ -1,3 +1,4 @@ +from dataclasses import astuple from datetime import date from io import BytesIO from urllib.parse import quote_plus @@ -13,6 +14,7 @@ from src.excel_generator.shift_builder import ShiftAnalyticReportSettings from src.excel_generator.task_builder import TaskAnalyticReportSettings from src.excel_generator.user_builder import ( + UserFullAnalyticReportSettings, UserShiftAnalyticReportSettings, UserTaskAnalyticReportSettings, ) @@ -112,29 +114,52 @@ async def __generate_user_report_description(self, user_id: UUID) -> str: f"Дата формирования отчёта: {date.today().strftime('%d.%m.%Y')}" ) - async def __generate_report_for_user(self, workbook: Workbook, user_id: UUID) -> Workbook: + async def __add_sheets_to_user_report(self, workbook: Workbook, user_id: UUID) -> Workbook: """Генерация отчёта по участнику.""" description = await self.__generate_user_report_description(user_id) - user_task_statistic = await self.__user_repository.get_user_task_statistics_report_by_id(user_id) - user_shift_statistic = await self.__user_repository.get_user_shift_statistics_report_by_id(user_id) - + user_task_statistic = await self.__user_repository.get_user_task_statistics_by_id_and_shift(user_id) self.__workbook.add_sheet( description, data=user_task_statistic, workbook=workbook, analytic_report_settings=UserTaskAnalyticReportSettings, ) + + user_shift_statistic = await self.__user_repository.get_user_shift_statistics_by_id(user_id) self.__workbook.add_sheet( description, data=user_shift_statistic, workbook=workbook, analytic_report_settings=UserShiftAnalyticReportSettings, ) + + all_tasks_data: dict[int, list] = dict() + user_shift_and_task_report = UserFullAnalyticReportSettings() + + user_shifts = await self.__user_repository.get_user_shifts_titles(user_id) + + for shift in user_shifts: + shift_tasks = await self.__user_repository.get_user_task_statistics_by_id_and_shift(user_id, shift.id) + + user_shift_and_task_report.add_shift(shift.title) + + for task in shift_tasks: + all_tasks_data.setdefault(task.sequence_number, [task.sequence_number, task.title]).extend( + astuple(task)[2:] + ) + + self.__workbook.add_sheet( + description, + data=tuple(all_tasks_data.values()), + workbook=workbook, + analytic_report_settings=user_shift_and_task_report, + ) + return workbook async def generate_report_for_user(self, user_id: UUID) -> BytesIO: """Генерация отчёта по участнику.""" workbook = self.__workbook.create_workbook() - workbook = await self.__generate_report_for_user(workbook, user_id) + await self.__add_sheets_to_user_report(workbook, user_id) return self.__workbook.get_report_response(workbook) diff --git a/src/excel_generator/base_analytic_report_settings.py b/src/excel_generator/base_analytic_report_settings.py index 752e5a321..cf123fd5c 100644 --- a/src/excel_generator/base_analytic_report_settings.py +++ b/src/excel_generator/base_analytic_report_settings.py @@ -5,4 +5,4 @@ class BaseAnalyticReportSettings: sheet_name: str header_data: tuple[str] - row_coun: int + row_count: int diff --git a/src/excel_generator/builder.py b/src/excel_generator/builder.py index 50e0f4320..14973f2c4 100644 --- a/src/excel_generator/builder.py +++ b/src/excel_generator/builder.py @@ -1,3 +1,4 @@ +import dataclasses import enum from dataclasses import astuple from io import BytesIO @@ -81,7 +82,9 @@ def __add_data( ) -> None: """Заполняет строки данными из БД.""" for task in data: - self.__add_row(worksheet, analytic_report_settings, data=astuple(task)) + if dataclasses.is_dataclass(task): + task = astuple(task) + self.__add_row(worksheet, analytic_report_settings, task) def __add_footer(self, worksheet: Worksheet, analytic_report_settings: BaseAnalyticReportSettings) -> None: """Заполняет последнюю строку в листе.""" diff --git a/src/excel_generator/user_builder.py b/src/excel_generator/user_builder.py index bd3bc208e..162844394 100644 --- a/src/excel_generator/user_builder.py +++ b/src/excel_generator/user_builder.py @@ -1,3 +1,5 @@ +from dataclasses import dataclass, field + from src.excel_generator.base_analytic_report_settings import BaseAnalyticReportSettings @@ -8,13 +10,13 @@ class UserTaskAnalyticReportSettings(BaseAnalyticReportSettings): header_data: tuple[str] = ( "№ Задачи", "Название задачи", - "1", - "2", - "3", - "П", - "Д", - "О", - "В", + "Принята с 1-й попытки", + "Принята с 2-й попытки", + "Принята с 3-й попытки", + "Кол-во одобренных отчётов", + "Кол-во отклонённых отчётов", + "Кол-во пропущенных заданий", + "Всего отчётов", ) row_count: int = 0 @@ -34,20 +36,21 @@ def footer_data(cls): ) -class UserShiftAnalyticReportSettings(BaseAnalyticReportSettings): +@dataclass +class UserShiftAnalyticReportSettings: """Конфигурация отчёта участника в разрезе смен.""" sheet_name: str = "Отчёт по сменам участника" header_data: tuple[str] = ( "№ Смены", "Название смены", - "1", - "2", - "3", - "П", - "Д", - "О", - "В", + "Принята с 1-й попытки", + "Принята с 2-й попытки", + "Принята с 3-й попытки", + "Кол-во одобренных отчётов", + "Кол-во отклонённых отчётов", + "Кол-во пропущенных заданий", + "Всего отчётов", ) row_count: int = 0 @@ -65,3 +68,50 @@ def footer_data(cls): f"=SUM(H2:H{cls.row_count})", f"=SUM(I2:I{cls.row_count})", ) + + +@dataclass +class UserFullAnalyticReportSettings: + sheet_name: str = "Полный отчёт" + shifts: list[str | None] = field(default_factory=lambda: []) + header_data: list[str] = field(default_factory=lambda: ["№", "Название задачи"]) + row_count: int = 0 + + def add_shift(self, shift_name: str): + statistics_column_names = ["1п", "2п", "3п", "Од", "От", "Пр", "Вс"] + self.shifts.append(shift_name) + self.header_data.extend(statistics_column_names) + + def add_summary_column(self): + ... + + @staticmethod + def _make_column_name(index: int): + """Вычисляет имя колонки excel по индексу. + + Имя колонки в excel — это последовательность A, B, ... Z, AA, AB, ... ZZ, AAA, AAB + + """ + a = ord("A") + z = ord("Z") + + q, third_index = divmod(index, z - a + 1) + first_index, second_index = divmod(q, z - a + 1) + + first_letter = chr(a - 1 + first_index) if first_index else "" + second_letter = chr(a - 1 + second_index) if second_index else "" + third_letter = chr(a + third_index) + + return "".join([first_letter, second_letter, third_letter]) + + @property + def footer_data(self): + first_row_for_calculation = 3 # номер строки, где начинаются данные + result = ["ИТОГО:", ""] + formulae = "=SUM({column}{start_row}:{column}{end_row})" + + for index in range(len(result), len(self.header_data)): + column = self._make_column_name(index) + result.append(formulae.format(column=column, start_row=first_row_for_calculation, end_row=self.row_count)) + + return result