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 +._* 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..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 @@ -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,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() diff --git a/src/core/services/analytics_service.py b/src/core/services/analytics_service.py index 5b302f9c8..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 @@ -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: @@ -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: @@ -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: @@ -56,31 +65,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 +97,69 @@ 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 __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) 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..cf123fd5c --- /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_count: int diff --git a/src/excel_generator/builder.py b/src/excel_generator/builder.py index 158c216be..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 @@ -7,41 +8,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 +65,30 @@ 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)) + if dataclasses.is_dataclass(task): + task = astuple(task) + self.__add_row(worksheet, analytic_report_settings, 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..162844394 --- /dev/null +++ b/src/excel_generator/user_builder.py @@ -0,0 +1,117 @@ +from dataclasses import dataclass, field + +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})", + ) + + +@dataclass +class UserShiftAnalyticReportSettings: + """Конфигурация отчёта участника в разрезе смен.""" + + 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})", + ) + + +@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