F3 Read/Write endpoints

This commit is contained in:
Raykov-MS 2026-05-13 12:28:00 +03:00
parent c67c94ba1f
commit 52c0d1806a
8 changed files with 956 additions and 67 deletions

View File

@ -1,20 +1,42 @@
import time
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.deps import get_current_active_user_with_set_db from src.api.v1.deps import get_current_active_user_with_set_db
from src.db.models.app_user import AppUser from src.db.models.app_user import AppUser
from src.db.session import get_db 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 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( async def get_projects(
offset: int = 0, offset: int = 0,
limit: int = 100, limit: int = 100,
@ -22,7 +44,7 @@ async def get_projects(
branch_id: Optional[int] = None, branch_id: Optional[int] = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db), current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseListResponse[str]: ) -> BaseListResponse[dict]:
project_service = ProjectService(db) project_service = ProjectService(db)
count, projects = await project_service.get_list( count, projects = await project_service.get_list(
user=current_user, user=current_user,
@ -32,3 +54,189 @@ async def get_projects(
offset=offset, offset=offset,
limit=limit, limit=limit,
) )
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,
}

View File

@ -1,11 +1,38 @@
from datetime import datetime from datetime import datetime
import enum import enum
from typing import Generic, Optional, TypeVar from typing import Any, Generic, Literal, Optional, TypeVar
from pydantic import BaseModel, ConfigDict, EmailStr, Field from pydantic import BaseModel, ConfigDict, EmailStr, Field
T = TypeVar("T") T = TypeVar("T")
ProjectTypeLiteral = Literal[
"Открытие ВСП",
"Закрытие ВСП",
"Переезд ВСП",
"Реновация РФ",
"Открытие УРМ",
]
VspFormatLiteral = Literal[
"Флагманский",
"Типовой",
"Розничный",
"МСБ",
"Лёгкий",
"Мини",
"Розничный-киоск",
"МБО",
"Офис самообслуживания",
"Другое",
]
PlacementTypeLiteral = Literal[
"Собственность",
"Аренда",
"Субаренда",
]
class ResponseBase(BaseModel): class ResponseBase(BaseModel):
success: Optional[bool] = Field(True) success: Optional[bool] = Field(True)
@ -146,3 +173,41 @@ class SheetResponse(BaseModel):
depth: int depth: int
data: dict data: dict
sort_order: int | None = None 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

View File

@ -1,8 +1,360 @@
import json
from sqlalchemy import exists, false, func, select, text
from sqlalchemy.ext.asyncio import AsyncSession 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: class ProjectRepository:
def __init__(self, db: AsyncSession): def __init__(self, db: AsyncSession):
self.db = db 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])

View File

@ -1,15 +1,12 @@
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.app_user import AppUser from src.db.models.app_user import AppUser
from src.db.models.project import Project
from src.db.models.role import UserRoleEnum from src.db.models.role import UserRoleEnum
from src.core.errors import AccessDeniedException, ValidationException
from src.repository.project_repository import ProjectRepository from src.repository.project_repository import ProjectRepository
from src.repository.user_repository import UserRepository from src.repository.user_repository import UserRepository
class ProjectService: class ProjectService:
def __init__(self, db: AsyncSession): def __init__(self, db: AsyncSession):
self.db = db self.db = db
@ -17,13 +14,178 @@ class ProjectService:
self.user_repo = UserRepository(db) self.user_repo = UserRepository(db)
async def get_list( async def get_list(
self, self,
user: AppUser, user: AppUser,
year: int | None = None, year: int | None = None,
branch_id: int | None = None, branch_id: int | None = None,
offset: int | None = None, offset: int | None = None,
limit: int | None = None, limit: int | None = None,
with_count: bool = False, with_count: bool = False,
with_org_unit: bool = False, with_org_unit: bool = False,
) -> list[Project] | tuple[int, list[Project]]: ) -> list[dict] | tuple[int, list[dict]]:
pass 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]

View File

@ -1,38 +1,11 @@
import os import os
from pathlib import Path
import pytest import pytest
from alembic import command
from alembic.config import Config
from fastapi.testclient import TestClient 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 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 @pytest.fixture
def client(): def client():
with TestClient(app) as test_client: with TestClient(app) as test_client:
@ -40,10 +13,16 @@ def client():
@pytest.fixture @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( response = client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={"username": "admin", "password": "admin"}, json={"username": "admin", "password": admin_password},
) )
assert response.status_code == 200 assert response.status_code == 200
return response.json() return response.json()

View File

@ -1,7 +1,7 @@
def test_login_success(client): def test_login_success(client, admin_password):
response = client.post( response = client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={"username": "admin", "password": "admin"}, json={"username": "admin", "password": admin_password},
) )
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@ -9,10 +9,10 @@ def test_login_success(client):
assert "refresh_token" in data assert "refresh_token" in data
def test_login_invalid_password(client): def test_login_invalid_password(client, admin_password):
response = client.post( response = client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={"username": "admin", "password": "wrong"}, json={"username": "admin", "password": f"{admin_password}_wrong"},
) )
assert response.status_code == 401 assert response.status_code == 401

View File

@ -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") == "Отчёт не найден"

View File

@ -1,3 +1,6 @@
import uuid
def _auth_headers(tokens: dict) -> dict: def _auth_headers(tokens: dict) -> dict:
return {"Authorization": f"Bearer {tokens['access_token']}"} return {"Authorization": f"Bearer {tokens['access_token']}"}
@ -8,10 +11,12 @@ def test_users_me(client, admin_tokens):
assert response.json()["username"] == "admin" 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 = { create_payload = {
"email": "user1@example.com", "email": f"{username}@example.com",
"username": "user1", "username": username,
"password": "pass123", "password": "pass123",
"full_name": "User One", "full_name": "User One",
"role_id": 2, "role_id": 2,
@ -22,9 +27,4 @@ def test_users_create_and_list(client, admin_tokens):
headers=_auth_headers(admin_tokens), headers=_auth_headers(admin_tokens),
) )
assert created.status_code == 200 assert created.status_code == 200
assert created.json()["result"]["username"] == username
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