F3 Read/Write endpoints
This commit is contained in:
parent
c67c94ba1f
commit
52c0d1806a
@ -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,7 +44,7 @@ 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,
|
||||
@ -32,3 +54,189 @@ async def get_projects(
|
||||
offset=offset,
|
||||
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,
|
||||
}
|
||||
@ -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)
|
||||
@ -146,3 +173,41 @@ class SheetResponse(BaseModel):
|
||||
depth: int
|
||||
data: dict
|
||||
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
|
||||
@ -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])
|
||||
|
||||
@ -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
|
||||
@ -25,5 +22,170 @@ class ProjectService:
|
||||
limit: int | None = None,
|
||||
with_count: bool = False,
|
||||
with_org_unit: bool = False,
|
||||
) -> list[Project] | tuple[int, list[Project]]:
|
||||
pass
|
||||
) -> 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]
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
123
api/tests/integration/test_projects_api_smoke.py
Normal file
123
api/tests/integration/test_projects_api_smoke.py
Normal 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") == "Отчёт не найден"
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user