Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/user statistics #378

Draft
wants to merge 3 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
._*
26 changes: 26 additions & 0 deletions src/api/routers/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down Expand Up @@ -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:
"""
Expand All @@ -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)
13 changes: 13 additions & 0 deletions src/core/db/DTO_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
75 changes: 72 additions & 3 deletions src/core/db/repository/user_repository.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Optional
from typing import Optional, Sequence
from uuid import UUID

from fastapi import Depends
Expand All @@ -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


Expand Down Expand Up @@ -110,3 +113,69 @@ 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_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(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'),
)
.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_by_id(self, user_id: UUID):
"""Отчёт по сменам участника.

Содержит:
- список смен, в которых участвует указанный пользователь;
- количество отчетов принятых с 1-й/2-й/3-й попытки;
- общее количество принятых/отклонённых/не предоставленных отчётов по каждому заданию.
"""
stmt = (
select(
Shift.sequence_number,
Shift.title,
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)
.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())

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()
97 changes: 86 additions & 11 deletions src/core/services/analytics_service.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from dataclasses import astuple
from datetime import date
from io import BytesIO
from urllib.parse import quote_plus
Expand All @@ -8,9 +9,15 @@

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 (
UserFullAnalyticReportSettings,
UserShiftAnalyticReportSettings,
UserTaskAnalyticReportSettings,
)


class AnalyticsService:
Expand All @@ -20,11 +27,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:
Expand All @@ -35,11 +44,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:
Expand All @@ -56,35 +65,101 @@ 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:
"""Генерация названия файла отчета по смене."""
shift = await self.__shift_repository.get(shift_id)
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 __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_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()
await self.__add_sheets_to_user_report(workbook, user_id)
return self.__workbook.get_report_response(workbook)
8 changes: 8 additions & 0 deletions src/excel_generator/base_analytic_report_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from dataclasses import dataclass


@dataclass
class BaseAnalyticReportSettings:
sheet_name: str
header_data: tuple[str]
row_count: int
Loading