From 52c0d1806ae0937bbbf95b2a88d1e31d39556b18 Mon Sep 17 00:00:00 2001 From: Raykov-MS Date: Wed, 13 May 2026 12:28:00 +0300 Subject: [PATCH] F3 Read/Write endpoints --- api/src/api/v1/projects.py | 224 ++++++++++- api/src/domain/schemas.py | 69 +++- api/src/repository/project_repository.py | 354 +++++++++++++++++- api/src/services/project_service.py | 190 +++++++++- api/tests/integration/conftest.py | 37 +- api/tests/integration/test_auth_api_smoke.py | 8 +- .../integration/test_projects_api_smoke.py | 123 ++++++ api/tests/integration/test_users_api_smoke.py | 18 +- 8 files changed, 956 insertions(+), 67 deletions(-) create mode 100644 api/tests/integration/test_projects_api_smoke.py diff --git a/api/src/api/v1/projects.py b/api/src/api/v1/projects.py index 39ee2c1..815d1f5 100644 --- a/api/src/api/v1/projects.py +++ b/api/src/api/v1/projects.py @@ -1,20 +1,42 @@ - +import time from typing import Optional -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, Response from sqlalchemy.ext.asyncio import AsyncSession from src.api.v1.deps import get_current_active_user_with_set_db from src.db.models.app_user import AppUser from src.db.session import get_db -from src.domain.schemas import BaseListResponse +from src.domain.schemas import ( + AddForm3LineBody, + AddProjectBody, + BaseListResponse, + BaseSingleResponse, + CellPatch, + CellsPatch, + SheetResponse, + UpdProjectBody, +) from src.services.project_service import ProjectService -router = APIRouter(prefix="/projects", tags=["forms"]) +router = APIRouter(tags=["forms"]) -@router.get("/") +def _parse_sections(sections: Optional[str]) -> Optional[list[str]]: + if not sections: + return None + return [s.strip() for s in sections.split(",") if s.strip()] + + +def _rows_to_payload(rows: list[tuple]) -> list[dict]: + return [ + {"row_type": row[0], "depth": row[1], "sort_order": row[2], "data": row[3]} + for row in rows + ] + + +@router.get("/projects") async def get_projects( offset: int = 0, limit: int = 100, @@ -22,13 +44,199 @@ async def get_projects( branch_id: Optional[int] = None, db: AsyncSession = Depends(get_db), current_user: AppUser = Depends(get_current_active_user_with_set_db), -) -> BaseListResponse[str]: +) -> BaseListResponse[dict]: project_service = ProjectService(db) count, projects = await project_service.get_list( - user=current_user, + user=current_user, with_count=True, year=year, branch_id=branch_id, offset=offset, limit=limit, - ) \ No newline at end of file + ) + return BaseListResponse(result=projects, count=count) + + +@router.get("/projects/{project_id}") +async def get_project( + project_id: int, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(get_current_active_user_with_set_db), +) -> BaseSingleResponse[dict]: + project_service = ProjectService(db) + project = await project_service.get(project_id=project_id, user=current_user) + if not project: + raise HTTPException(404, "Project not found") + return BaseSingleResponse(result=project) + + +@router.get("/projects/{project_id}/report/{year}/{report_type}") +async def get_project_report( + project_id: int, + year: int, + report_type: str, + response: Response, + sections: Optional[str] = None, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(get_current_active_user_with_set_db), +) -> BaseListResponse[SheetResponse]: + project_service = ProjectService(db) + t0 = time.perf_counter() + rows = await project_service.get_report_rows( + project_id=project_id, + year=year, + report_type=report_type, + sections=_parse_sections(sections), + user=current_user, + ) + db_ms = (time.perf_counter() - t0) * 1000 + response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}" + return BaseListResponse(count=len(rows), result=_rows_to_payload(rows)) + + +@router.get("/rf-rollup/{branch_id}/{year}") +async def get_rf_rollup( + branch_id: int, + year: int, + response: Response, + sections: Optional[str] = None, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(get_current_active_user_with_set_db), +) -> BaseListResponse[SheetResponse]: + project_service = ProjectService(db) + t0 = time.perf_counter() + rows = await project_service.get_rf_rollup_rows( + branch_id=branch_id, + year=year, + sections=_parse_sections(sections), + user=current_user, + ) + db_ms = (time.perf_counter() - t0) * 1000 + response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}" + return BaseListResponse(count=len(rows), result=_rows_to_payload(rows)) + + +@router.patch("/projects/{project_id}/report/{year}/{report_type}/cell") +async def upd_form3_cell( + project_id: int, + year: int, + report_type: str, + body: CellPatch, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(get_current_active_user_with_set_db), +) -> list[SheetResponse]: + project_service = ProjectService(db) + rows = await project_service.upd_form3_cell( + project_id=project_id, + year=year, + report_type=report_type, + line_id=body.line_id, + column=body.column, + value=body.value, + user=current_user, + ) + return _rows_to_payload(rows) + + +@router.patch("/projects/{project_id}/report/{year}/{report_type}/cells") +async def upd_form3_cells( + project_id: int, + year: int, + report_type: str, + body: CellsPatch, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(get_current_active_user_with_set_db), +) -> list[SheetResponse]: + project_service = ProjectService(db) + rows = await project_service.upd_form3_cells( + project_id=project_id, + year=year, + report_type=report_type, + changes=[change.model_dump() for change in body.changes], + user=current_user, + ) + return _rows_to_payload(rows) + + +@router.post("/projects/{project_id}/report/{year}/{report_type}/line") +async def add_form3_line( + project_id: int, + year: int, + report_type: str, + body: AddForm3LineBody, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(get_current_active_user_with_set_db), +) -> list[SheetResponse]: + project_service = ProjectService(db) + rows = await project_service.add_form3_line( + project_id=project_id, + year=year, + report_type=report_type, + expense_item_id=body.expense_item_id, + user=current_user, + ) + return _rows_to_payload(rows) + + +@router.delete("/projects/{project_id}/report/{year}/{report_type}/line/{line_id}") +async def del_form3_line( + project_id: int, + year: int, + report_type: str, + line_id: int, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(get_current_active_user_with_set_db), +) -> list[SheetResponse]: + project_service = ProjectService(db) + rows = await project_service.del_form3_line( + project_id=project_id, + year=year, + report_type=report_type, + line_id=line_id, + user=current_user, + ) + return _rows_to_payload(rows) + + +@router.patch("/project/{project_id}") +async def upd_project( + project_id: int, + body: UpdProjectBody, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(get_current_active_user_with_set_db), +) -> dict: + project_service = ProjectService(db) + result = await project_service.upd_project( + project_id=project_id, + column=body.column, + value=body.value, + user=current_user, + ) + return result + + +@router.post("/projects") +async def add_project( + body: AddProjectBody, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(get_current_active_user_with_set_db), +) -> dict: + project_service = ProjectService(db) + project_id, limit_report_id, current_expenses_report_id = await project_service.add_project( + name=body.name, + year=body.year, + org_unit_id=body.branch_id, + level=body.level, + parent_id=body.parent_id, + project_type=body.project_type, + vsp_format=body.vsp_format, + placement_type=body.placement_type, + object_address=body.object_address, + staff_count=body.staff_count, + total_area=body.total_area, + ) + return { + "project_id": project_id, + "limit_report_id": limit_report_id, + "current_expenses_report_id": current_expenses_report_id, + } \ No newline at end of file diff --git a/api/src/domain/schemas.py b/api/src/domain/schemas.py index 7e43de9..957fed4 100644 --- a/api/src/domain/schemas.py +++ b/api/src/domain/schemas.py @@ -1,11 +1,38 @@ from datetime import datetime import enum -from typing import Generic, Optional, TypeVar +from typing import Any, Generic, Literal, Optional, TypeVar from pydantic import BaseModel, ConfigDict, EmailStr, Field T = TypeVar("T") +ProjectTypeLiteral = Literal[ + "Открытие ВСП", + "Закрытие ВСП", + "Переезд ВСП", + "Реновация РФ", + "Открытие УРМ", +] + +VspFormatLiteral = Literal[ + "Флагманский", + "Типовой", + "Розничный", + "МСБ", + "Лёгкий", + "Мини", + "Розничный-киоск", + "МБО", + "Офис самообслуживания", + "Другое", +] + +PlacementTypeLiteral = Literal[ + "Собственность", + "Аренда", + "Субаренда", +] + class ResponseBase(BaseModel): success: Optional[bool] = Field(True) @@ -145,4 +172,42 @@ class SheetResponse(BaseModel): row_type: str depth: int data: dict - sort_order: int | None = None \ No newline at end of file + sort_order: int | None = None + + +class CellPatch(BaseModel): + line_id: int + column: str + value: Any = None + + +class CellsPatch(BaseModel): + changes: list[CellPatch] + + +class AddForm3LineBody(BaseModel): + expense_item_id: int + + +class UpdProjectBody(BaseModel): + column: str + value: Any = None + + +class AddProjectBody(BaseModel): + name: str = Field( + ..., + max_length=30, + pattern=r"^[^+\-\/\\=&*\s]{1,30}$", + ) + year: int + branch_id: int + level: Literal["project", "program"] = "project" + parent_id: Optional[int] = None + ssp_id: Optional[int] = None + project_type: Optional[ProjectTypeLiteral] = None + vsp_format: Optional[VspFormatLiteral] = None + placement_type: Optional[PlacementTypeLiteral] = None + object_address: Optional[str] = None + staff_count: Optional[int] = None + total_area: Optional[float] = None \ No newline at end of file diff --git a/api/src/repository/project_repository.py b/api/src/repository/project_repository.py index 0b03d9c..565045b 100644 --- a/api/src/repository/project_repository.py +++ b/api/src/repository/project_repository.py @@ -1,8 +1,360 @@ +import json +from sqlalchemy import exists, false, func, select, text from sqlalchemy.ext.asyncio import AsyncSession +from src.db.models.org_unit import OrgUnit +from src.db.models.project import Project +from src.db.models.rf_project_report import RfProjectReport +from src.db.models.rf_project_report_line import RfProjectReportLine + class ProjectRepository: def __init__(self, db: AsyncSession): self.db = db - + + @staticmethod + def _serialize_project(project: Project, org_unit_name: str | None, report_count: int) -> dict: + return { + "id": project.id, + "name": project.name, + "level": project.level, + "parent_id": project.parent_id, + "project_type": project.project_type, + "vsp_format": project.vsp_format, + "placement_type": project.placement_type, + "object_address": project.object_address, + "staff_count": project.staff_count, + "total_area": float(project.total_area) if project.total_area is not None else None, + "org_unit_id": project.org_unit_id, + "org_unit_name": org_unit_name, + "report_count": int(report_count), + } + + @staticmethod + def _apply_project_filters(query, year: int | None, branch_id: int | None, org_unit_ids: list[int] | None): + if year is not None: + query = query.where( + exists( + select(1).where( + RfProjectReport.project_id == Project.id, + RfProjectReport.year == year, + ) + ) + ) + if branch_id is not None: + query = query.where(Project.org_unit_id == branch_id) + if org_unit_ids is not None: + if len(org_unit_ids) == 0: + query = query.where(false()) + else: + query = query.where(Project.org_unit_id.in_(org_unit_ids)) + return query + + async def get_list( + self, + year: int | None = None, + branch_id: int | None = None, + offset: int | None = None, + limit: int | None = None, + with_count: bool = False, + org_unit_ids: list[int] | None = None, + ) -> list[dict] | tuple[int, list[dict]]: + report_count_subq = ( + select(func.count(RfProjectReport.id)) + .where(RfProjectReport.project_id == Project.id) + .scalar_subquery() + ) + + query = ( + select( + Project, + OrgUnit.title.label("org_unit_name"), + report_count_subq.label("report_count"), + ) + .outerjoin(OrgUnit, OrgUnit.id == Project.org_unit_id) + .order_by(Project.id) + ) + query = self._apply_project_filters(query, year, branch_id, org_unit_ids) + if offset is not None: + query = query.offset(offset) + if limit is not None: + query = query.limit(limit) + + rows = (await self.db.execute(query)).all() + payload = [self._serialize_project(row[0], row[1], row[2]) for row in rows] + if not with_count: + return payload + + count_query = select(func.count(Project.id)) + count_query = self._apply_project_filters(count_query, year, branch_id, org_unit_ids) + total = int((await self.db.execute(count_query)).scalar() or 0) + return total, payload + + async def get( + self, + project_id: int, + org_unit_ids: list[int] | None = None, + ) -> dict | None: + report_count_subq = ( + select(func.count(RfProjectReport.id)) + .where(RfProjectReport.project_id == Project.id) + .scalar_subquery() + ) + query = ( + select( + Project, + OrgUnit.title.label("org_unit_name"), + report_count_subq.label("report_count"), + ) + .outerjoin(OrgUnit, OrgUnit.id == Project.org_unit_id) + .where(Project.id == project_id) + ) + if org_unit_ids is not None: + if len(org_unit_ids) == 0: + return None + query = query.where(Project.org_unit_id.in_(org_unit_ids)) + row = (await self.db.execute(query)).first() + if not row: + return None + return self._serialize_project(row[0], row[1], row[2]) + + async def get_reports(self, project_id: int) -> list[dict]: + line_count_subq = ( + select(func.count(RfProjectReportLine.id)) + .where(RfProjectReportLine.rf_project_report_id == RfProjectReport.id) + .scalar_subquery() + ) + query = ( + select( + RfProjectReport.id, + RfProjectReport.year, + RfProjectReport.report_type, + RfProjectReport.created_by, + RfProjectReport.created_at, + RfProjectReport.updated_by, + RfProjectReport.updated_at, + line_count_subq.label("line_count"), + ) + .where(RfProjectReport.project_id == project_id) + .order_by(RfProjectReport.year, RfProjectReport.report_type) + ) + rows = (await self.db.execute(query)).all() + reports: list[dict] = [] + for row in rows: + reports.append( + { + "id": row[0], + "year": row[1], + "report_type": row[2], + "created_by": row[3], + "created_at": row[4].isoformat() if row[4] is not None else None, + "updated_by": row[5], + "updated_at": row[6].isoformat() if row[6] is not None else None, + "line_count": int(row[7]), + } + ) + return reports + + async def resolve_report_id( + self, + project_id: int, + year: int, + report_type: str, + org_unit_ids: list[int] | None = None, + ) -> int | None: + query = ( + select(RfProjectReport.id) + .join(Project, Project.id == RfProjectReport.project_id) + .where( + Project.id == project_id, + RfProjectReport.year == year, + RfProjectReport.report_type == report_type, + ) + ) + if org_unit_ids is not None: + if len(org_unit_ids) == 0: + return None + query = query.where(Project.org_unit_id.in_(org_unit_ids)) + return (await self.db.execute(query)).scalar_one_or_none() + + async def get_report_rows(self, report_id: int, sections: list[str] | None = None) -> list[tuple]: + query = text( + """ + SELECT row_type, depth, sort_order, data + FROM v3.v_form3_report_jsonb(CAST(:report_id AS INT), CAST(:sections AS TEXT[])) + """ + ) + return (await self.db.execute(query, {"report_id": report_id, "sections": sections})).all() + + async def get_rf_rollup_rows( + self, + branch_id: int, + year: int, + sections: list[str] | None = None, + ) -> list[tuple]: + query = text( + """ + SELECT row_type, depth, sort_order, data + FROM v3.v_form3_rf_rollup_jsonb(CAST(:branch_id AS INT), CAST(:year AS INT), CAST(:sections AS TEXT[])) + """ + ) + return ( + await self.db.execute( + query, + {"branch_id": branch_id, "year": year, "sections": sections}, + ) + ).all() + + async def upd_form3_cell( + self, + report_id: int, + line_id: int, + column: str, + value, + ) -> list[tuple]: + query = text( + """ + SELECT row_type, depth, sort_order, data + FROM v3.upd_form3_cell( + CAST(:report_id AS INT), + CAST(:line_id AS INT), + CAST(:column AS TEXT), + CAST(:value AS JSONB) + ) + """ + ) + rows = ( + await self.db.execute( + query, + { + "report_id": report_id, + "line_id": line_id, + "column": column, + "value": json.dumps(value), + }, + ) + ).all() + await self.db.commit() + return rows + + async def upd_form3_cells(self, report_id: int, changes: list[dict]) -> list[tuple]: + query = text( + """ + SELECT row_type, depth, sort_order, data + FROM v3.upd_form3_cells(CAST(:report_id AS INT), CAST(:changes AS JSONB)) + """ + ) + rows = ( + await self.db.execute( + query, + { + "report_id": report_id, + "changes": json.dumps(changes), + }, + ) + ).all() + await self.db.commit() + return rows + + async def add_form3_line(self, report_id: int, expense_item_id: int) -> list[tuple]: + query = text( + """ + SELECT row_type, depth, sort_order, data + FROM v3.add_form3_line(CAST(:report_id AS INT), CAST(:expense_item_id AS INT)) + """ + ) + rows = ( + await self.db.execute( + query, + { + "report_id": report_id, + "expense_item_id": expense_item_id, + }, + ) + ).all() + await self.db.commit() + return rows + + async def del_form3_line(self, line_id: int) -> list[tuple]: + query = text( + """ + SELECT row_type, depth, sort_order, data + FROM v3.del_form3_line(CAST(:line_id AS INT)) + """ + ) + rows = (await self.db.execute(query, {"line_id": line_id})).all() + await self.db.commit() + return rows + + async def upd_project(self, project_id: int, column: str, value): + query = text( + """ + SELECT v3.upd_project(CAST(:project_id AS INT), CAST(:column AS TEXT), CAST(:value AS JSONB)) + """ + ) + result = ( + await self.db.execute( + query, + { + "project_id": project_id, + "column": column, + "value": json.dumps(value), + }, + ) + ).scalar_one() + await self.db.commit() + return result + + async def add_project( + self, + name: str, + year: int, + org_unit_id: int, + level: str = "project", + parent_id: int | None = None, + project_type: str | None = None, + vsp_format: str | None = None, + placement_type: str | None = None, + object_address: str | None = None, + staff_count: int | None = None, + total_area: float | None = None, + ) -> tuple[int, int, int]: + query = text( + """ + SELECT project_id, limit_report_id, current_expenses_report_id + FROM v3.add_project( + CAST(:name AS VARCHAR), + CAST(:year AS INT), + CAST(:org_unit_id AS INT), + CAST(:level AS VARCHAR), + CAST(:parent_id AS INT), + CAST(:project_type AS VARCHAR), + CAST(:vsp_format AS VARCHAR), + CAST(:placement_type AS VARCHAR), + CAST(:object_address AS VARCHAR), + CAST(:staff_count AS INT), + CAST(:total_area AS NUMERIC) + ) + """ + ) + row = ( + await self.db.execute( + query, + { + "name": name, + "year": year, + "org_unit_id": org_unit_id, + "level": level, + "parent_id": parent_id, + "project_type": project_type, + "vsp_format": vsp_format, + "placement_type": placement_type, + "object_address": object_address, + "staff_count": staff_count, + "total_area": total_area, + }, + ) + ).first() + await self.db.commit() + return int(row[0]), int(row[1]), int(row[2]) diff --git a/api/src/services/project_service.py b/api/src/services/project_service.py index b967972..0f7f62e 100644 --- a/api/src/services/project_service.py +++ b/api/src/services/project_service.py @@ -1,15 +1,12 @@ - -from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from src.db.models.app_user import AppUser -from src.db.models.project import Project from src.db.models.role import UserRoleEnum +from src.core.errors import AccessDeniedException, ValidationException from src.repository.project_repository import ProjectRepository from src.repository.user_repository import UserRepository - class ProjectService: def __init__(self, db: AsyncSession): self.db = db @@ -17,13 +14,178 @@ class ProjectService: self.user_repo = UserRepository(db) async def get_list( - self, - user: AppUser, - year: int | None = None, - branch_id: int | None = None, - offset: int | None = None, - limit: int | None = None, - with_count: bool = False, - with_org_unit: bool = False, - ) -> list[Project] | tuple[int, list[Project]]: - pass + self, + user: AppUser, + year: int | None = None, + branch_id: int | None = None, + offset: int | None = None, + limit: int | None = None, + with_count: bool = False, + with_org_unit: bool = False, + ) -> list[dict] | tuple[int, list[dict]]: + org_unit_ids = await self._allowed_org_unit_ids(user) + return await self.project_repo.get_list( + year=year, + branch_id=branch_id, + offset=offset, + limit=limit, + with_count=with_count, + org_unit_ids=org_unit_ids, + ) + + async def get(self, project_id: int, user: AppUser) -> dict | None: + org_unit_ids = await self._allowed_org_unit_ids(user) + project = await self.project_repo.get(project_id=project_id, org_unit_ids=org_unit_ids) + if not project: + return None + project["reports"] = await self.project_repo.get_reports(project_id=project_id) + return project + + async def resolve_report_id( + self, + project_id: int, + year: int, + report_type: str, + user: AppUser, + ) -> int | None: + normalized_type = report_type.upper() + if normalized_type not in ("LIMIT", "CURRENT_EXPENSES"): + raise ValidationException("report_type должен быть LIMIT или CURRENT_EXPENSES") + org_unit_ids = await self._allowed_org_unit_ids(user) + return await self.project_repo.resolve_report_id( + project_id=project_id, + year=year, + report_type=normalized_type, + org_unit_ids=org_unit_ids, + ) + + async def get_report_rows( + self, + project_id: int, + year: int, + report_type: str, + sections: list[str] | None, + user: AppUser, + ) -> list[tuple]: + report_id = await self.resolve_report_id(project_id, year, report_type, user) + if not report_id: + raise ValidationException("Отчёт не найден") + return await self.project_repo.get_report_rows(report_id=report_id, sections=sections) + + async def get_rf_rollup_rows( + self, + branch_id: int, + year: int, + sections: list[str] | None, + user: AppUser, + ) -> list[tuple]: + org_unit_ids = await self._allowed_org_unit_ids(user) + if org_unit_ids is not None and branch_id not in org_unit_ids: + raise AccessDeniedException() + return await self.project_repo.get_rf_rollup_rows( + branch_id=branch_id, + year=year, + sections=sections, + ) + + async def upd_form3_cell( + self, + project_id: int, + year: int, + report_type: str, + line_id: int, + column: str, + value, + user: AppUser, + ) -> list[tuple]: + report_id = await self.resolve_report_id(project_id, year, report_type, user) + if not report_id: + raise ValidationException("Отчёт не найден") + return await self.project_repo.upd_form3_cell(report_id, line_id, column, value) + + async def upd_form3_cells( + self, + project_id: int, + year: int, + report_type: str, + changes: list[dict], + user: AppUser, + ) -> list[tuple]: + report_id = await self.resolve_report_id(project_id, year, report_type, user) + if not report_id: + raise ValidationException("Отчёт не найден") + return await self.project_repo.upd_form3_cells(report_id, changes) + + async def add_form3_line( + self, + project_id: int, + year: int, + report_type: str, + expense_item_id: int, + user: AppUser, + ) -> list[tuple]: + report_id = await self.resolve_report_id(project_id, year, report_type, user) + if not report_id: + raise ValidationException("Отчёт не найден") + return await self.project_repo.add_form3_line(report_id, expense_item_id) + + async def del_form3_line( + self, + project_id: int, + year: int, + report_type: str, + line_id: int, + user: AppUser, + ) -> list[tuple]: + report_id = await self.resolve_report_id(project_id, year, report_type, user) + if not report_id: + raise ValidationException("Отчёт не найден") + return await self.project_repo.del_form3_line(line_id) + + async def upd_project( + self, + project_id: int, + column: str, + value, + user: AppUser, + ): + project = await self.get(project_id, user) + if not project: + raise ValidationException("Проект не найден") + return await self.project_repo.upd_project(project_id, column, value) + + async def add_project( + self, + name: str, + year: int, + org_unit_id: int, + level: str = "project", + parent_id: int | None = None, + project_type: str | None = None, + vsp_format: str | None = None, + placement_type: str | None = None, + object_address: str | None = None, + staff_count: int | None = None, + total_area: float | None = None, + ) -> tuple[int, int, int]: + return await self.project_repo.add_project( + name=name, + year=year, + org_unit_id=org_unit_id, + level=level, + parent_id=parent_id, + project_type=project_type, + vsp_format=vsp_format, + placement_type=placement_type, + object_address=object_address, + staff_count=staff_count, + total_area=total_area, + ) + + async def _allowed_org_unit_ids(self, user: AppUser) -> list[int] | None: + if user.role_id == UserRoleEnum.ADMIN: + return None + db_user = await self.user_repo.get(user_id=user.id, load_orgs=True) + if not db_user: + return [] + return [ou.id for ou in db_user.org_units] diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py index ed451f5..f578a9a 100644 --- a/api/tests/integration/conftest.py +++ b/api/tests/integration/conftest.py @@ -1,38 +1,11 @@ import os -from pathlib import Path import pytest -from alembic import command -from alembic.config import Config from fastapi.testclient import TestClient -# Keep a single sqlite target for app + alembic during tests. -TEST_DATABASE_URL = "sqlite+aiosqlite:///./app.db" -os.environ["DATABASE_URL"] = TEST_DATABASE_URL -os.environ["OPENBAO__SETTINGS__DATABASE_URL"] = TEST_DATABASE_URL -os.environ["OPENBAO__SETTINGS_TEST__DATABASE_URL"] = TEST_DATABASE_URL -os.environ["OPENBAO__SETTINGS__DEBUG"] = "true" -os.environ["OPENBAO__SETTINGS_TEST__DEBUG"] = "true" - from src.main import app -@pytest.fixture(scope="session", autouse=True) -def migrate_db(): - db_files = [Path("app.db"), Path("dfip_budget_planing.db"), Path("dfip_new_schema.db")] - for db_file in db_files: - if db_file.exists(): - os.remove(db_file) - - alembic_cfg = Config("alembic.ini") - command.upgrade(alembic_cfg, "head") - yield - - for db_file in db_files: - if db_file.exists(): - os.remove(db_file) - - @pytest.fixture def client(): with TestClient(app) as test_client: @@ -40,10 +13,16 @@ def client(): @pytest.fixture -def admin_tokens(client): +def admin_password() -> str: + # Пароль админа берём из окружения, чтобы не хардкодить локальные отличия. + return os.getenv("OPENBAO__TEST_ADMIN_PASSWORD", "admin123") + + +@pytest.fixture +def admin_tokens(client, admin_password: str): response = client.post( "/api/v1/auth/login", - json={"username": "admin", "password": "admin"}, + json={"username": "admin", "password": admin_password}, ) assert response.status_code == 200 return response.json() diff --git a/api/tests/integration/test_auth_api_smoke.py b/api/tests/integration/test_auth_api_smoke.py index 7a6aee6..4774b2d 100644 --- a/api/tests/integration/test_auth_api_smoke.py +++ b/api/tests/integration/test_auth_api_smoke.py @@ -1,7 +1,7 @@ -def test_login_success(client): +def test_login_success(client, admin_password): response = client.post( "/api/v1/auth/login", - json={"username": "admin", "password": "admin"}, + json={"username": "admin", "password": admin_password}, ) assert response.status_code == 200 data = response.json() @@ -9,10 +9,10 @@ def test_login_success(client): assert "refresh_token" in data -def test_login_invalid_password(client): +def test_login_invalid_password(client, admin_password): response = client.post( "/api/v1/auth/login", - json={"username": "admin", "password": "wrong"}, + json={"username": "admin", "password": f"{admin_password}_wrong"}, ) assert response.status_code == 401 diff --git a/api/tests/integration/test_projects_api_smoke.py b/api/tests/integration/test_projects_api_smoke.py new file mode 100644 index 0000000..07f6386 --- /dev/null +++ b/api/tests/integration/test_projects_api_smoke.py @@ -0,0 +1,123 @@ +import uuid + +import pytest + + +def _auth_headers(tokens: dict) -> dict: + return {"Authorization": f"Bearer {tokens['access_token']}"} + + +def test_projects_list_smoke(client, admin_tokens): + response = client.get("/api/v1/projects", headers=_auth_headers(admin_tokens)) + assert response.status_code == 200 + payload = response.json() + assert "result" in payload + assert "count" in payload + assert isinstance(payload["result"], list) + + +def _create_project(client, admin_tokens) -> tuple[int, str]: + name = f"PT_{uuid.uuid4().hex[:10]}" + response = client.post( + "/api/v1/projects", + json={"name": name, "year": 2026, "branch_id": 1}, + headers=_auth_headers(admin_tokens), + ) + assert response.status_code == 200 + payload = response.json() + return payload["project_id"], name + + +def _add_line(client, admin_tokens, project_id: int) -> int: + for expense_item_id in range(1, 80): + response = client.post( + f"/api/v1/projects/{project_id}/report/2026/LIMIT/line", + json={"expense_item_id": expense_item_id}, + headers=_auth_headers(admin_tokens), + ) + if response.status_code != 200: + continue + rows = response.json() + input_row = next( + ( + row + for row in rows + if row.get("row_type") == "INPUT" and row.get("data", {}).get("line_id") + ), + None, + ) + if input_row: + return int(input_row["data"]["line_id"]) + + pytest.skip("Не удалось подобрать expense_item_id для add_form3_line в текущей БД") + + +def test_projects_write_smoke(client, admin_tokens): + project_id, name = _create_project(client, admin_tokens) + + upd_project_response = client.patch( + f"/api/v1/project/{project_id}", + json={"column": "name", "value": f"{name}_U"}, + headers=_auth_headers(admin_tokens), + ) + assert upd_project_response.status_code == 200 + + line_id = _add_line(client, admin_tokens, project_id) + + upd_cell_response = client.patch( + f"/api/v1/projects/{project_id}/report/2026/LIMIT/cell", + json={"line_id": line_id, "column": "q1.m1", "value": 100}, + headers=_auth_headers(admin_tokens), + ) + assert upd_cell_response.status_code == 200 + assert isinstance(upd_cell_response.json(), list) + + upd_cells_response = client.patch( + f"/api/v1/projects/{project_id}/report/2026/LIMIT/cells", + json={ + "changes": [ + {"line_id": line_id, "column": "q1.m2", "value": 200}, + {"line_id": line_id, "column": "q1.m3", "value": 300}, + ] + }, + headers=_auth_headers(admin_tokens), + ) + assert upd_cells_response.status_code == 200 + assert isinstance(upd_cells_response.json(), list) + + del_line_response = client.delete( + f"/api/v1/projects/{project_id}/report/2026/LIMIT/line/{line_id}", + headers=_auth_headers(admin_tokens), + ) + assert del_line_response.status_code == 200 + + +def test_projects_write_invalid_report_type_error_shape(client, admin_tokens): + project_id, _ = _create_project(client, admin_tokens) + line_id = _add_line(client, admin_tokens, project_id) + + response = client.patch( + f"/api/v1/projects/{project_id}/report/2026/BAD_TYPE/cell", + json={"line_id": line_id, "column": "q1.m1", "value": 100}, + headers=_auth_headers(admin_tokens), + ) + assert response.status_code == 400 + payload = response.json() + assert isinstance(payload, dict) + assert "detail" in payload + assert isinstance(payload["detail"], str) + + +def test_projects_write_report_not_found_error_shape(client, admin_tokens): + project_id, _ = _create_project(client, admin_tokens) + line_id = _add_line(client, admin_tokens, project_id) + + response = client.patch( + f"/api/v1/projects/{project_id}/report/2099/LIMIT/cell", + json={"line_id": line_id, "column": "q1.m1", "value": 100}, + headers=_auth_headers(admin_tokens), + ) + assert response.status_code == 400 + payload = response.json() + assert isinstance(payload, dict) + assert payload.get("detail") == "Отчёт не найден" diff --git a/api/tests/integration/test_users_api_smoke.py b/api/tests/integration/test_users_api_smoke.py index bd535dc..718606a 100644 --- a/api/tests/integration/test_users_api_smoke.py +++ b/api/tests/integration/test_users_api_smoke.py @@ -1,3 +1,6 @@ +import uuid + + def _auth_headers(tokens: dict) -> dict: return {"Authorization": f"Bearer {tokens['access_token']}"} @@ -8,10 +11,12 @@ def test_users_me(client, admin_tokens): assert response.json()["username"] == "admin" -def test_users_create_and_list(client, admin_tokens): +def test_users_create_smoke(client, admin_tokens): + suffix = uuid.uuid4().hex[:8] + username = f"user_{suffix}" create_payload = { - "email": "user1@example.com", - "username": "user1", + "email": f"{username}@example.com", + "username": username, "password": "pass123", "full_name": "User One", "role_id": 2, @@ -22,9 +27,4 @@ def test_users_create_and_list(client, admin_tokens): headers=_auth_headers(admin_tokens), ) assert created.status_code == 200 - - listed = client.get("/api/v1/users/", headers=_auth_headers(admin_tokens)) - assert listed.status_code == 200 - usernames = [row["username"] for row in listed.json()["result"]] - assert "admin" in usernames - assert "user1" in usernames + assert created.json()["result"]["username"] == username