Merge pull request 'delete-projects: удаление проектов, фикс редактируемых полей в форме 4, нельзя удалить самого себя, нельзя выбрать неактивный ссп для экспорта вс…' (#58) from delete-projects into test

Reviewed-on: #58
Reviewed-by: PotapovaA <potapovaanna113@mail.ru>
This commit is contained in:
tsygankoviva 2026-07-30 16:04:55 +03:00
commit 32fd38ccac
11 changed files with 2752 additions and 59 deletions

View File

@ -0,0 +1,84 @@
import os
import re
import time
from alembic import op
import sqlalchemy as sa
revision = "0012"
down_revision = "0011"
branch_labels = None
depends_on = None
_DOLLAR_TAG_RE = re.compile(r"\$\w+\$")
def _find_dollar_tag(line: str) -> str | None:
m = _DOLLAR_TAG_RE.search(line.strip())
return m.group(0) if m else None
def _split_statements(sql: str) -> list[str]:
statements: list[str] = []
current: list[str] = []
in_dollar = False
dollar_tag: str | None = None
for line in sql.split("\n"):
stripped = line.strip()
if stripped.startswith("--"):
continue
if not in_dollar:
tag = _find_dollar_tag(stripped)
if tag and tag.endswith("$") and tag.startswith("$"):
dollar_tag = tag
in_dollar = True
current.append(line)
continue
if in_dollar and dollar_tag and stripped.startswith(dollar_tag):
after = stripped[len(dollar_tag):].strip()
if after == ";" or after == "":
in_dollar = False
dollar_tag = None
if after == ";":
current.append(line)
statements.append("\n".join(current))
current = []
continue
if not in_dollar and stripped.rstrip().endswith(";"):
current.append(line)
statements.append("\n".join(current))
current = []
continue
current.append(line)
remaining = "\n".join(current).strip()
if remaining:
statements.append(remaining)
return statements
def upgrade() -> None:
ddl_path = os.path.join(os.path.dirname(__file__), "sql", "0012_del_project.sql")
with open(ddl_path) as f:
content = f.read()
statements = _split_statements(content)
for stmt in statements:
stripped = stmt.strip().rstrip(";").strip()
if not stripped:
continue
if all(l.strip().startswith("--") or not l.strip() for l in stripped.split("\n")):
continue
op.execute(stripped)
def downgrade() -> None:
pass

File diff suppressed because one or more lines are too long

View File

@ -15,6 +15,7 @@ from src.domain.schemas import (
BaseSingleResponse,
CellPatch,
CellsPatch,
ResponseBase,
SheetResponse,
UpdProjectBody,
)
@ -251,6 +252,17 @@ async def upd_project(
return result
@router.delete("/projects/{project_id}")
async def delete_project(
project_id: int,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> ResponseBase:
project_service = ProjectService(db)
await project_service.delete_project(project_id=project_id, user=current_user)
return ResponseBase(success=True, message="Проект удалён")
@router.post("/projects")
async def add_project(
body: AddProjectBody,

View File

@ -707,10 +707,10 @@ async def login(websocket: WebSocket, **kwargs) -> int:
def resolve_cell_key(event_data: dict) -> dict | None:
data = event_data.get("data")
if not data or "line_id" not in data or "column" not in data:
if not data or ("line_id" not in data and "line_id_code" not in data) or "column" not in data:
return None
return {
"line_id": data.get("line_id_code", data["line_id"]),
"line_id": data.get("line_id_code") or data.get("line_id"),
"column": data["column"],
}

View File

@ -1,6 +1,8 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, Numeric, String, text
from datetime import datetime
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, Integer, Numeric, String, func, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
@ -41,6 +43,7 @@ class Project(Base):
staff_count: Mapped[int | None] = mapped_column(Integer)
total_area: Mapped[float | None] = mapped_column(Numeric)
org_unit_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.org_unit.id"))
created_at: Mapped[datetime | None] = mapped_column(DateTime, server_default=func.now())
# parent = relationship("Project", remote_side="Project.id", back_populates="children")
# children: Mapped[list["Project"]] = relationship("Project", back_populates="parent") # type: ignore

View File

@ -28,6 +28,7 @@ class ProjectRepository:
"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,
"created_at": project.created_at.isoformat() if project.created_at else None,
"report_count": int(report_count),
"years": years,
}
@ -316,6 +317,15 @@ class ProjectRepository:
rows = (await self.db.execute(query, {"line_id": line_id})).all()
return [tuple(r) for r in rows]
async def del_project(self, project_id: int):
query = text(
"""
SELECT v3.del_project(CAST(:project_id AS INT))
"""
)
result = (await self.db.execute(query, {"project_id": project_id})).scalar_one()
return result
async def upd_project(self, project_id: int, column: str, value):
query = text(
"""

View File

@ -147,6 +147,12 @@ class ProjectService:
raise ValidationException("Отчёт не найден")
return await self.project_repo.del_form3_line(line_id)
async def delete_project(self, project_id: int, user: AppUser):
project = await self.get(project_id, user)
if not project:
raise ValidationException("Проект не найден")
return await self.project_repo.del_project(project_id)
async def upd_project(
self,
project_id: int,

View File

@ -85,6 +85,11 @@ class UserService:
)
update_payload["role_id"] = role.value
if user.id == user_id and not update_payload.get('is_active', True):
raise ValidationException(
"Нельзя удалить себя", field="role_id"
)
if "username" in update_payload and update_payload["username"] != target.username:
conflict = await self.user_repo.get_by_username(update_payload["username"])
if conflict:
@ -122,6 +127,10 @@ class UserService:
async def delete_user(self, user_id: int, user: AppUser):
if user.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException()
if user.id == user_id:
raise ValidationException(
"Нельзя удалить себя", field="role_id"
)
deleted = await self.user_repo.logical_delete(user_id)
if not deleted:
raise UserNotFoundException()

View File

@ -1,3 +1,4 @@
from datetime import datetime
import uuid
import pytest
@ -12,11 +13,11 @@ def test_projects_list_smoke(client, admin_tokens, auth_headers):
assert isinstance(payload["result"], list)
def _create_project(client, admin_tokens, auth_headers) -> tuple[int, str]:
def _create_project(client, admin_tokens, auth_headers, year=2026, branch_id=1) -> tuple[int, str]:
name = f"PT_{uuid.uuid4().hex[:10]}"
response = client.post(
"/api/v1/projects",
json={"name": name, "year": 2026, "branch_id": 1},
json={"name": name, "year": year, "branch_id": branch_id},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
@ -24,10 +25,10 @@ def _create_project(client, admin_tokens, auth_headers) -> tuple[int, str]:
return payload["project_id"], name
def _add_line(client, admin_tokens, auth_headers, project_id: int) -> int:
def _add_line(client, admin_tokens, auth_headers, project_id: int, year: int = 2026) -> int:
for expense_item_id in range(1, 80):
response = client.post(
f"/api/v1/projects/{project_id}/report/2026/LIMIT/line",
f"/api/v1/projects/{project_id}/report/{year}/LIMIT/line",
json={"expense_item_id": expense_item_id},
headers=auth_headers(admin_tokens),
)
@ -143,3 +144,107 @@ def test_projects_write_report_not_found_error_shape(client, admin_tokens, auth_
payload = response.json()
assert isinstance(payload, dict)
assert payload.get("message") == "Отчёт не найден"
def test_delete_project_smoke(client, admin_tokens, auth_headers):
project_id, _ = _create_project(client, admin_tokens, auth_headers, year=datetime.now().year)
response = client.delete(
f"/api/v1/projects/{project_id}",
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert payload["success"] is True
assert payload["message"] == "Проект удалён"
def test_delete_project_verify_gone(client, admin_tokens, auth_headers):
project_id, _ = _create_project(client, admin_tokens, auth_headers, year=datetime.now().year)
del_response = client.delete(
f"/api/v1/projects/{project_id}",
headers=auth_headers(admin_tokens),
)
assert del_response.status_code == 200
get_response = client.get(
f"/api/v1/projects/{project_id}",
headers=auth_headers(admin_tokens),
)
assert get_response.status_code == 404
def test_delete_project_not_found(client, admin_tokens, auth_headers):
response = client.delete(
"/api/v1/projects/99999",
headers=auth_headers(admin_tokens),
)
assert response.status_code == 400
payload = response.json()
assert isinstance(payload, dict)
assert "message" in payload
def test_delete_project_unauthorized(client):
response = client.delete("/api/v1/projects/1")
assert response.status_code == 403
#ToDo: вернуть, когда пофиксим смещение фаз в зависимости от года
# def test_delete_project_with_lines_blocked(client, admin_tokens, auth_headers):
# """Проект со строками отчёта нельзя удалить: can_delete_project → false → RAISE EXCEPTION."""
# year = datetime.now().year - 1
# project_id, _ = _create_project(client, admin_tokens, auth_headers, year=year)
# _add_line(client, admin_tokens, auth_headers, project_id, year=year)
# response = client.delete(
# f"/api/v1/projects/{project_id}",
# headers=auth_headers(admin_tokens),
# )
# assert response.status_code == 400
# payload = response.json()
# assert isinstance(payload, dict)
# assert "code" in payload
# assert "message" in payload
# assert "невозможно удалить" in payload["message"]
def test_delete_project_executor_from_other_org(client, admin_tokens, auth_headers, isp_tokens):
"""ISP1 (привязан к РФ id=2) не может удалить проект на ССП id=1 — ACL."""
project_id, _ = _create_project(client, admin_tokens, auth_headers, branch_id=1, year=datetime.now().year)
response = client.delete(
f"/api/v1/projects/{project_id}",
headers=auth_headers(isp_tokens),
)
assert response.status_code == 400
payload = response.json()
assert "message" in payload
assert payload["message"] == "Проект не найден"
#ToDo: вернуть, когда пофиксим смещение фаз в зависимости от года
# def test_delete_project_executor_from_same_org(client, isp_tokens, auth_headers):
# """ISP1 может удалить проект на своём РФ (id=2), если нет строк отчёта."""
# project_id = _create_project(client, isp_tokens, auth_headers, branch_id=2, year=datetime.now().year)
# response = client.delete(
# f"/api/v1/projects/{project_id}",
# headers=auth_headers(isp_tokens),
# )
# assert response.status_code == 200
# payload = response.json()
# assert payload["success"] is True
# assert payload["message"] == "Проект удалён"
def test_delete_seed_project_smoke(client, admin_tokens, auth_headers):
"""Проект из фикстуры (id=2) удаляется: у него нет строк отчёта, can_delete_project → true."""
response = client.delete(
"/api/v1/projects/2",
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert payload["success"] is True
assert payload["message"] == "Проект удалён"

View File

@ -46,9 +46,9 @@
"color_type":greenColumn,
"accessorKey":"data.plan.comment"
},
"data.contract_summary.amount":{
"data.contract_summary.total":{
"color_type":greenColumn,
"accessorKey":"data.contract_summary.amount"
"accessorKey":"data.contract_summary.total"
},
"data.contract_summary.reference":{
"color_type":greenColumn,
@ -66,17 +66,17 @@
"color_type":greenColumn,
"accessorKey":"data.contract_summary.comment"
},
"data.contract_summary.future_payments_y1":{
"data.contract_summary.future_y1":{
"color_type":greenColumn,
"accessorKey":"data.contract_summary.future_payments_y1"
"accessorKey":"data.contract_summary.future_y1"
},
"data.contract_summary.future_payments_y2":{
"data.contract_summary.future_y2":{
"color_type":greenColumn,
"accessorKey":"data.contract_summary.future_payments_y2"
"accessorKey":"data.contract_summary.future_y2"
},
"data.contract_summary.other_ssp_amount":{
"data.contract_summary.other_ssp":{
"color_type":greenColumn,
"accessorKey":"data.contract_summary.other_ssp_amount"
"accessorKey":"data.contract_summary.other_ssp"
},
"data.contract_summary.centralized_flag":{
"color_type":greenColumn,
@ -158,13 +158,13 @@
"color_type":greenColumn,
"accessorKey":"data.approved.year"
},
"data.collegial.amount":{
"data.collegial.approved":{
"color_type":orangeColumn,
"accessorKey":"data.collegial.amount"
"accessorKey":"data.collegial.approved"
},
"data.collegial.protocol_reference":{
"data.collegial.protocol":{
"color_type":orangeColumn,
"accessorKey":"data.collegial.protocol_reference"
"accessorKey":"data.collegial.protocol"
},
"field_kollegialnye_organy_field":{
"color_type":orangeColumn,
@ -182,37 +182,37 @@
"color_type":orangeColumn,
"accessorKey":"data.ckk.ceiling"
},
"data.ckk.expenses_q1":{
"data.ckk.q1":{
"color_type":orangeColumn,
"accessorKey":"data.ckk.expenses_q1"
"accessorKey":"data.ckk.q1"
},
"data.ckk.expenses_q2":{
"data.ckk.q2":{
"color_type":orangeColumn,
"accessorKey":"data.ckk.expenses_q2"
"accessorKey":"data.ckk.q2"
},
"data.ckk.expenses_q3":{
"data.ckk.q3":{
"color_type":orangeColumn,
"accessorKey":"data.ckk.expenses_q3"
"accessorKey":"data.ckk.q3"
},
"data.ckk.expenses_q4":{
"data.ckk.q4":{
"color_type":orangeColumn,
"accessorKey":"data.ckk.expenses_q4"
"accessorKey":"data.ckk.q4"
},
"data.ckk.rf_schedule":{
"color_type":orangeColumn,
"accessorKey":"data.ckk.rf_schedule"
},
"data.ckk.delivery_deadline":{
"data.ckk.deadline":{
"color_type":orangeColumn,
"accessorKey":"data.ckk.delivery_deadline"
"accessorKey":"data.ckk.deadline"
},
"data.ckk.procurement_plan":{
"data.ckk.proc_plan":{
"color_type":orangeColumn,
"accessorKey":"data.ckk.procurement_plan"
"accessorKey":"data.ckk.proc_plan"
},
"data.ckk.procurement_method":{
"data.ckk.proc_method":{
"color_type":orangeColumn,
"accessorKey":"data.ckk.procurement_method"
"accessorKey":"data.ckk.proc_method"
},
"data.ckk.comment":{
"color_type":orangeColumn,
@ -242,21 +242,21 @@
"color_type":orangeColumn,
"accessorKey":"data.contract.ceiling"
},
"data.contract.expenses_q1":{
"data.contract.q1":{
"color_type":orangeColumn,
"accessorKey":"data.contract.expenses_q1"
"accessorKey":"data.contract.q1"
},
"data.contract.expenses_q2":{
"data.contract.q2":{
"color_type":orangeColumn,
"accessorKey":"data.contract.expenses_q2"
"accessorKey":"data.contract.q2"
},
"data.contract.expenses_q3":{
"data.contract.q3":{
"color_type":orangeColumn,
"accessorKey":"data.contract.expenses_q3"
"accessorKey":"data.contract.q3"
},
"data.contract.expenses_q4":{
"data.contract.q4":{
"color_type":orangeColumn,
"accessorKey":"data.contract.expenses_q4"
"accessorKey":"data.contract.q4"
},
"data.contract.rf_schedule":{
"color_type":orangeColumn,
@ -955,7 +955,7 @@
"columns":[
{
"header":"Сумма",
"accessorKey":"data.contract_summary.amount",
"accessorKey":"data.contract_summary.total",
"columnLetter":"L",
"size":150,
"filterFn":"contains",
@ -1020,7 +1020,7 @@
},
{
"header":"Платежив 2027 году",
"accessorKey":"data.contract_summary.future_payments_y1",
"accessorKey":"data.contract_summary.future_y1",
"columnLetter":"Q",
"size":190,
"filterFn":"contains",
@ -1033,7 +1033,7 @@
},
{
"header":"Платежив 2028 году",
"accessorKey":"data.contract_summary.future_payments_y2",
"accessorKey":"data.contract_summary.future_y2",
"columnLetter":"R",
"size":190,
"filterFn":"contains",
@ -1046,7 +1046,7 @@
},
{
"header":"Сумма в других ССП",
"accessorKey":"data.contract_summary.other_ssp_amount",
"accessorKey":"data.contract_summary.other_ssp",
"columnLetter":"S",
"size":180,
"filterFn":"contains",
@ -1475,7 +1475,7 @@
"columns":[
{
"header":"Сумма (без НДС)",
"accessorKey":"data.collegial.amount",
"accessorKey":"data.collegial.approved",
"columnLetter":"AS",
"size":150,
"filterFn":"contains",
@ -1488,7 +1488,7 @@
},
{
"header":"Реквизиты протокола (дата, номер)",
"accessorKey":"data.collegial.protocol_reference",
"accessorKey":"data.collegial.protocol",
"columnLetter":"AT",
"size":330,
"filterFn":"contains",
@ -1541,7 +1541,7 @@
},
{
"header":"Расходы 1 кв. 2026 г. тыс. руб. (без НДС)",
"accessorKey":"data.ckk.expenses_q1",
"accessorKey":"data.ckk.q1",
"columnLetter":"AY",
"size":420,
"filterFn":"contains",
@ -1554,7 +1554,7 @@
},
{
"header":"Расходы 2 кв. 2026 г. тыс. руб. (без НДС)",
"accessorKey":"data.ckk.expenses_q2",
"accessorKey":"data.ckk.q2",
"columnLetter":"AZ",
"size":420,
"filterFn":"contains",
@ -1567,7 +1567,7 @@
},
{
"header":"Расходы 3 кв. 2026 г. тыс. руб. (без НДС)",
"accessorKey":"data.ckk.expenses_q3",
"accessorKey":"data.ckk.q3",
"columnLetter":"BA",
"size":420,
"filterFn":"contains",
@ -1580,7 +1580,7 @@
},
{
"header":"Расходы 4 кв. 2026 г. тыс. руб. (без НДС)",
"accessorKey":"data.ckk.expenses_q4",
"accessorKey":"data.ckk.q4",
"columnLetter":"BB",
"size":420,
"filterFn":"contains",
@ -1606,7 +1606,7 @@
},
{
"header":"Срок поставки",
"accessorKey":"data.ckk.delivery_deadline",
"accessorKey":"data.ckk.deadline",
"columnLetter":"BD",
"size":150,
"filterFn":"contains",
@ -1619,7 +1619,7 @@
},
{
"header":"План закупок",
"accessorKey":"data.ckk.procurement_plan",
"accessorKey":"data.ckk.proc_plan",
"columnLetter":"BE",
"size":150,
"filterFn":"contains",
@ -1632,7 +1632,7 @@
},
{
"header":"Способ закупки",
"accessorKey":"data.ckk.procurement_method",
"accessorKey":"data.ckk.proc_method",
"columnLetter":"BF",
"size":150,
"filterFn":"contains",
@ -1748,7 +1748,7 @@
},
{
"header":"Расходы 1 кв. 2026 г. тыс. руб. (без НДС)",
"accessorKey":"data.contract.expenses_q1",
"accessorKey":"data.contract.q1",
"columnLetter":"BO",
"size":420,
"filterFn":"contains",
@ -1761,7 +1761,7 @@
},
{
"header":"Расходы 2 кв. 2026 г. тыс. руб. (без НДС)",
"accessorKey":"data.contract.expenses_q2",
"accessorKey":"data.contract.q2",
"columnLetter":"BP",
"size":420,
"filterFn":"contains",
@ -1774,7 +1774,7 @@
},
{
"header":"Расходы 3 кв. 2026 г. тыс. руб. (без НДС)",
"accessorKey":"data.contract.expenses_q3",
"accessorKey":"data.contract.q3",
"columnLetter":"BQ",
"size":420,
"filterFn":"contains",
@ -1787,7 +1787,7 @@
},
{
"header":"Расходы 4 кв. 2026 г. тыс. руб. (без НДС)",
"accessorKey":"data.contract.expenses_q4",
"accessorKey":"data.contract.q4",
"columnLetter":"BR",
"size":420,
"filterFn":"contains",

View File

@ -45,7 +45,7 @@ function DictVspPage() {
const isAdmin = user.role_id == ROLES_NAME_ID.admin;
const [vspResponse, sspResponse] = await Promise.all([
DictVspApi.get(),
isAdmin ? SspApi.getAll() : Promise.resolve(null),
isAdmin ? SspApi.getAll({is_active: true}) : Promise.resolve(null),
]);
if (!vspResponse.success) {