form3-expense: добавил новые статьи в форму 3, возможность удаления задач #178
15
api/alembic/versions/0038_form3_expense_item_links.py
Normal file
15
api/alembic/versions/0038_form3_expense_item_links.py
Normal file
@ -0,0 +1,15 @@
|
||||
from sql_migration import run_sql_file_migration
|
||||
|
||||
|
||||
revision = "0038"
|
||||
down_revision = "0037"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
run_sql_file_migration("0038_form3_expense_item_links.sql")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
15
api/alembic/versions/0039_budget_form_delete_workflow.py
Normal file
15
api/alembic/versions/0039_budget_form_delete_workflow.py
Normal file
@ -0,0 +1,15 @@
|
||||
from sql_migration import run_sql_file_migration
|
||||
|
||||
|
||||
revision = "0039"
|
||||
down_revision = "0038"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
run_sql_file_migration("0039_budget_form_delete_workflow.sql")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
22
api/alembic/versions/sql/0038_form3_expense_item_links.sql
Normal file
22
api/alembic/versions/sql/0038_form3_expense_item_links.sql
Normal file
@ -0,0 +1,22 @@
|
||||
insert into v3.expense_item_form_type (expense_item_id, form_type_code)
|
||||
values
|
||||
(84, 'FORM_3'),
|
||||
(85, 'FORM_3'),
|
||||
(86, 'FORM_3'),
|
||||
(87, 'FORM_3'),
|
||||
(88, 'FORM_3'),
|
||||
(89, 'FORM_3'),
|
||||
(90, 'FORM_3'),
|
||||
(91, 'FORM_3'),
|
||||
(92, 'FORM_3'),
|
||||
(93, 'FORM_3'),
|
||||
(94, 'FORM_3'),
|
||||
(95, 'FORM_3'),
|
||||
(97, 'FORM_3'),
|
||||
(98, 'FORM_3'),
|
||||
(99, 'FORM_3'),
|
||||
(100, 'FORM_3'),
|
||||
(101, 'FORM_3'),
|
||||
(83, 'FORM_3'),
|
||||
(96, 'FORM_3')
|
||||
on conflict (expense_item_id, form_type_code) do nothing;
|
||||
@ -0,0 +1,75 @@
|
||||
ALTER TABLE v3.budget_form
|
||||
ADD COLUMN IF NOT EXISTS to_delete boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS deleted_by integer NULL REFERENCES v3.app_user(id),
|
||||
ADD COLUMN IF NOT EXISTS deleted_at timestamp with time zone NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_v3_budget_form_to_delete
|
||||
ON v3.budget_form (to_delete);
|
||||
|
||||
CREATE OR REPLACE FUNCTION v3.hard_delete_budget_form(p_form_id integer)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM v3.budget_form WHERE id = p_form_id) THEN
|
||||
RAISE EXCEPTION 'budget_form id=% не существует', p_form_id;
|
||||
END IF;
|
||||
|
||||
DELETE FROM v3.booking
|
||||
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
|
||||
|
||||
DELETE FROM v3.sequestration
|
||||
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
|
||||
|
||||
DELETE FROM v3.budget_line_quarter
|
||||
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
|
||||
|
||||
DELETE FROM v3.rent_detail
|
||||
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
|
||||
|
||||
DELETE FROM v3.utility_detail
|
||||
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
|
||||
|
||||
DELETE FROM v3.security_detail
|
||||
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
|
||||
|
||||
DELETE FROM v3.contract_detail
|
||||
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
|
||||
|
||||
DELETE FROM v3.contract_summary
|
||||
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
|
||||
|
||||
DELETE FROM v3.plan
|
||||
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
|
||||
|
||||
DELETE FROM v3.reserve
|
||||
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
|
||||
|
||||
DELETE FROM v3.allocation
|
||||
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
|
||||
|
||||
DELETE FROM v3.ckk
|
||||
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
|
||||
|
||||
DELETE FROM v3.collegial_approval
|
||||
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
|
||||
|
||||
DELETE FROM v3.fixed_asset_report
|
||||
WHERE budget_form_id = p_form_id;
|
||||
|
||||
DELETE FROM v3.form_limit
|
||||
WHERE budget_form_id = p_form_id;
|
||||
|
||||
DELETE FROM v3.form_phase
|
||||
WHERE budget_form_id = p_form_id;
|
||||
|
||||
DELETE FROM v3.budget_line
|
||||
WHERE budget_form_id = p_form_id;
|
||||
|
||||
DELETE FROM v3.form4_project
|
||||
WHERE form_id = p_form_id;
|
||||
|
||||
DELETE FROM v3.budget_form
|
||||
WHERE id = p_form_id;
|
||||
END;
|
||||
$function$;
|
||||
@ -8,8 +8,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.services.org_unit_service import OrgUnitService
|
||||
from src.db.models.form_type import FormTypeEnum
|
||||
from src.db.models.role import UserRoleEnum
|
||||
from src.services.sheet_service import SheetService
|
||||
from src.domain.schemas import AddLineSchema, BaseListResponse, BaseSingleResponse, BudgetFormResponse, CellPatch, CellsPatch, DirectionSchemaEnum, FormCreateSchema, SheetFormTypeResponse, SheetResponse
|
||||
from src.domain.schemas import AddLineSchema, BaseListResponse, BaseSingleResponse, ResponseBase, BudgetFormResponse, CellPatch, CellsPatch, DirectionSchemaEnum, FormCreateSchema, SheetFormTypeResponse, SheetResponse
|
||||
from src.db.models.app_user import AppUser
|
||||
from src.services.budget_form_service import BudgetFormService
|
||||
from src.api.v1.deps import get_current_active_user_with_set_db, require_admin
|
||||
@ -80,6 +81,7 @@ async def get_forms(
|
||||
org_units: str | None = None,
|
||||
form_type: Literal["FORM_1", "FORM_2", "FORM_4"] | None = None,
|
||||
year: int | None = None,
|
||||
delete_status: Literal["active", "marked", "all"] | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
||||
) -> BaseListResponse[BudgetFormResponse]:
|
||||
@ -95,6 +97,7 @@ async def get_forms(
|
||||
form_type=form_type,
|
||||
year=year,
|
||||
load_org=True,
|
||||
delete_status=delete_status,
|
||||
)
|
||||
return BaseListResponse(
|
||||
result = [
|
||||
@ -112,12 +115,15 @@ async def get_form(
|
||||
) -> BaseSingleResponse[BudgetFormResponse]:
|
||||
bf_service = BudgetFormService(db)
|
||||
form = await bf_service.get(
|
||||
user=current_user,
|
||||
user=current_user,
|
||||
budget_form_id=form_id,
|
||||
load_org=True,
|
||||
include_to_delete=current_user.role_id == UserRoleEnum.ADMIN,
|
||||
)
|
||||
if not form:
|
||||
raise HTTPException(404, f"Форма {form_id} не найдена")
|
||||
return BaseSingleResponse(
|
||||
result = BudgetFormResponse.model_validate(form),
|
||||
result=BudgetFormResponse.model_validate(form),
|
||||
)
|
||||
|
||||
|
||||
@ -464,6 +470,7 @@ async def add_form(
|
||||
form_type_code=form.form_type_code,
|
||||
year=form.year,
|
||||
org_unit_ids=org_unit_ids,
|
||||
user=current_user,
|
||||
)
|
||||
|
||||
db_ms = (time.perf_counter() - t0) * 1000
|
||||
@ -485,3 +492,36 @@ async def add_form(
|
||||
result=[BudgetFormResponse.model_validate(new_form) for new_form in new_forms],
|
||||
count=len(new_forms),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{form_id}/mark-delete")
|
||||
async def mark_form_for_deletion(
|
||||
form_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AppUser = Depends(require_admin),
|
||||
) -> BaseSingleResponse[BudgetFormResponse]:
|
||||
bf_service = BudgetFormService(db)
|
||||
form = await bf_service.mark_for_deletion(budget_form_id=form_id, user=current_user)
|
||||
return BaseSingleResponse(result=BudgetFormResponse.model_validate(form))
|
||||
|
||||
|
||||
@router.post("/{form_id}/restore")
|
||||
async def restore_form(
|
||||
form_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AppUser = Depends(require_admin),
|
||||
) -> BaseSingleResponse[BudgetFormResponse]:
|
||||
bf_service = BudgetFormService(db)
|
||||
form = await bf_service.restore(budget_form_id=form_id, user=current_user)
|
||||
return BaseSingleResponse(result=BudgetFormResponse.model_validate(form))
|
||||
|
||||
|
||||
@router.delete("/{form_id}/hard")
|
||||
async def hard_delete_form(
|
||||
form_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AppUser = Depends(require_admin),
|
||||
) -> ResponseBase:
|
||||
bf_service = BudgetFormService(db)
|
||||
await bf_service.hard_delete(budget_form_id=form_id, user=current_user)
|
||||
return ResponseBase(success=True, message="Задача полностью удалена")
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
from datetime import datetime
|
||||
import typing
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.models.form_type import FormTypeEnum
|
||||
@ -34,6 +34,9 @@ class BudgetForm(Base):
|
||||
org_unit_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("v3.org_unit.id", name="fk_v3_budget_form_org_unit")
|
||||
)
|
||||
to_delete: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
deleted_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id"))
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
form_type: Mapped["FormType"] = relationship(
|
||||
"FormType", #back_populates="budget_forms"
|
||||
|
||||
@ -330,6 +330,9 @@ class BudgetFormResponse(BaseModel):
|
||||
form_type_code: FormTypeSchemaEnum
|
||||
year: int
|
||||
org_unit_id: int
|
||||
to_delete: bool = False
|
||||
deleted_by: int | None = None
|
||||
deleted_at: datetime | None = None
|
||||
org_unit: OrgUnitSchema | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy import func, select, text, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.db.models.form_type import FormTypeEnum
|
||||
@ -6,20 +6,20 @@ from src.db.models.budget_form import BudgetForm
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
|
||||
|
||||
class BudgetFormRepository:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_list(
|
||||
self,
|
||||
offset: int | None = None,
|
||||
self,
|
||||
offset: int | None = None,
|
||||
limit: int | None = None,
|
||||
org_unit: int | list[int] | None = None,
|
||||
with_count: bool = False,
|
||||
load_org: bool = False,
|
||||
form_type: FormTypeEnum | str | None = None,
|
||||
year: int | None = None,
|
||||
delete_status: str = "active",
|
||||
) -> list[BudgetForm] | tuple[int, list[BudgetForm]]:
|
||||
if with_count:
|
||||
query = select(func.count().over().label("total_count"), BudgetForm)
|
||||
@ -38,6 +38,11 @@ class BudgetFormRepository:
|
||||
if year is not None:
|
||||
where.append(BudgetForm.year == year)
|
||||
|
||||
if delete_status == "marked":
|
||||
where.append(BudgetForm.to_delete.is_(True))
|
||||
elif delete_status != "all":
|
||||
where.append(BudgetForm.to_delete.is_(False))
|
||||
|
||||
query = query.where(*where)
|
||||
if load_org:
|
||||
query = query.options(
|
||||
@ -63,19 +68,20 @@ class BudgetFormRepository:
|
||||
return (await self.db.execute(count_query)).scalar(), []
|
||||
else:
|
||||
return (await self.db.execute(query)).scalars().all()
|
||||
|
||||
|
||||
async def get(
|
||||
self,
|
||||
self,
|
||||
budget_form_id: int | None = None,
|
||||
org_unit: int | list[int] | None = None,
|
||||
form_type_code: FormTypeEnum | str | None = None,
|
||||
year: int | None = None,
|
||||
load_form_type: bool = False,
|
||||
load_org: bool = False,
|
||||
include_to_delete: bool = False,
|
||||
) -> BudgetForm | None:
|
||||
assert budget_form_id or all((form_type_code, year, org_unit))
|
||||
query = select(BudgetForm)
|
||||
|
||||
|
||||
where = []
|
||||
if budget_form_id is not None:
|
||||
where.append(BudgetForm.id == budget_form_id)
|
||||
@ -85,16 +91,19 @@ class BudgetFormRepository:
|
||||
where.append(BudgetForm.org_unit_id == org_unit)
|
||||
else:
|
||||
where.append(BudgetForm.org_unit_id.in_(org_unit))
|
||||
|
||||
|
||||
if form_type_code is not None:
|
||||
if isinstance(form_type_code, FormTypeEnum):
|
||||
where.append(BudgetForm.form_type_code == form_type_code.value)
|
||||
else:
|
||||
where.append(BudgetForm.form_type_code == form_type_code)
|
||||
|
||||
|
||||
if year is not None:
|
||||
where.append(BudgetForm.year == year)
|
||||
|
||||
if not include_to_delete:
|
||||
where.append(BudgetForm.to_delete.is_(False))
|
||||
|
||||
query = query.where(*where)
|
||||
if load_org:
|
||||
query = query.options(
|
||||
@ -106,12 +115,13 @@ class BudgetFormRepository:
|
||||
joinedload(BudgetForm.form_type)
|
||||
)
|
||||
return (await self.db.execute(query)).scalars().first()
|
||||
|
||||
|
||||
async def create(
|
||||
self,
|
||||
self,
|
||||
form_type_code: str,
|
||||
year: int,
|
||||
year: int,
|
||||
org_unit_id: int,
|
||||
user_id: int | None = None,
|
||||
) -> BudgetForm | None:
|
||||
form_id = (
|
||||
await self.db.execute(
|
||||
@ -134,6 +144,42 @@ class BudgetFormRepository:
|
||||
).scalar_one_or_none()
|
||||
if form_id is None:
|
||||
return None
|
||||
if user_id is not None:
|
||||
await self.db.execute(
|
||||
update(BudgetForm)
|
||||
.where(BudgetForm.id == form_id)
|
||||
.values(created_by=user_id, updated_by=user_id)
|
||||
)
|
||||
return await self.get(
|
||||
budget_form_id=form_id,
|
||||
)
|
||||
|
||||
async def mark_for_deletion(self, budget_form_id: int, user_id: int) -> None:
|
||||
await self.db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE v3.budget_form
|
||||
SET to_delete = true, deleted_by = :user_id, deleted_at = now(), updated_by = :user_id, updated_at = now()
|
||||
WHERE id = :form_id
|
||||
"""
|
||||
),
|
||||
{"form_id": budget_form_id, "user_id": user_id},
|
||||
)
|
||||
|
||||
async def restore(self, budget_form_id: int, user_id: int) -> None:
|
||||
await self.db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE v3.budget_form
|
||||
SET to_delete = false, deleted_by = NULL, deleted_at = NULL, updated_by = :user_id, updated_at = now()
|
||||
WHERE id = :form_id
|
||||
"""
|
||||
),
|
||||
{"form_id": budget_form_id, "user_id": user_id},
|
||||
)
|
||||
|
||||
async def hard_delete(self, budget_form_id: int) -> None:
|
||||
await self.db.execute(
|
||||
text("SELECT v3.hard_delete_budget_form(:form_id)"),
|
||||
{"form_id": budget_form_id},
|
||||
)
|
||||
|
||||
@ -4,12 +4,15 @@ from typing import Literal
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.repository.user_repository import UserRepository
|
||||
from src.core.errors import AccessDeniedException, ValidationException
|
||||
from src.db.models.app_user import AppUser
|
||||
from src.db.models.audit_log import AuditLog
|
||||
from src.db.models.budget_form import BudgetForm
|
||||
from src.db.models.role import UserRoleEnum
|
||||
from src.repository.budget_form_repository import BudgetFormRepository
|
||||
from src.repository.user_repository import UserRepository
|
||||
|
||||
DeleteStatus = Literal["active", "marked", "all"]
|
||||
|
||||
|
||||
class BudgetFormService:
|
||||
@ -28,8 +31,15 @@ class BudgetFormService:
|
||||
org_unit: list[int] | None = None,
|
||||
form_type: Literal["FORM_1", "FORM_2", "FORM_4"] | None = None,
|
||||
year: int | None = None,
|
||||
delete_status: DeleteStatus | None = None,
|
||||
) -> list[BudgetForm] | tuple[int, list[BudgetForm]]:
|
||||
if user.role_id == UserRoleEnum.ADMIN:
|
||||
if delete_status is not None and delete_status not in {"active", "marked", "all"}:
|
||||
raise ValidationException("delete_status должен быть active, marked или all")
|
||||
|
||||
is_admin = user.role_id == UserRoleEnum.ADMIN
|
||||
effective_delete_status = (delete_status or "all") if is_admin else "active"
|
||||
|
||||
if is_admin:
|
||||
return await self.bf_repo.get_list(
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
@ -38,6 +48,7 @@ class BudgetFormService:
|
||||
org_unit=org_unit,
|
||||
form_type=form_type,
|
||||
year=year,
|
||||
delete_status=effective_delete_status,
|
||||
)
|
||||
user = await self.user_repo.get(
|
||||
user_id=user.id,
|
||||
@ -58,6 +69,7 @@ class BudgetFormService:
|
||||
load_org=load_org,
|
||||
form_type=form_type,
|
||||
year=year,
|
||||
delete_status=effective_delete_status,
|
||||
)
|
||||
|
||||
async def get(
|
||||
@ -66,12 +78,15 @@ class BudgetFormService:
|
||||
user: AppUser,
|
||||
load_form_type: bool = False,
|
||||
load_org: bool = False,
|
||||
include_to_delete: bool = False,
|
||||
) -> BudgetForm | None:
|
||||
if user.role_id == UserRoleEnum.ADMIN:
|
||||
is_admin = user.role_id == UserRoleEnum.ADMIN
|
||||
if is_admin:
|
||||
return await self.bf_repo.get(
|
||||
budget_form_id=budget_form_id,
|
||||
load_form_type=load_form_type,
|
||||
load_org=load_org,
|
||||
include_to_delete=True,
|
||||
)
|
||||
user = await self.user_repo.get(
|
||||
user_id=user.id,
|
||||
@ -82,30 +97,35 @@ class BudgetFormService:
|
||||
org_unit=[ou.id for ou in user.org_units],
|
||||
load_form_type=load_form_type,
|
||||
load_org=load_org,
|
||||
include_to_delete=False,
|
||||
)
|
||||
|
||||
async def create(
|
||||
self,
|
||||
self,
|
||||
form_type_code: str,
|
||||
year: int,
|
||||
year: int,
|
||||
org_unit_id: int,
|
||||
user: AppUser | None = None,
|
||||
) -> BudgetForm | None:
|
||||
return await self.bf_repo.create(
|
||||
form_type_code=form_type_code,
|
||||
year=year,
|
||||
org_unit_id=org_unit_id,
|
||||
user_id=user.id if user else None,
|
||||
)
|
||||
|
||||
async def create_if_not_exists(
|
||||
self,
|
||||
self,
|
||||
form_type_code: str,
|
||||
year: int,
|
||||
year: int,
|
||||
org_unit_id: int,
|
||||
user: AppUser | None = None,
|
||||
) -> BudgetForm | None:
|
||||
existing_bf = await self.bf_repo.get(
|
||||
form_type_code=form_type_code,
|
||||
year=year,
|
||||
org_unit=org_unit_id,
|
||||
include_to_delete=True,
|
||||
)
|
||||
if existing_bf:
|
||||
return existing_bf
|
||||
@ -113,13 +133,15 @@ class BudgetFormService:
|
||||
form_type_code=form_type_code,
|
||||
year=year,
|
||||
org_unit_id=org_unit_id,
|
||||
user_id=user.id if user else None,
|
||||
)
|
||||
|
||||
|
||||
async def bulk_create(
|
||||
self,
|
||||
self,
|
||||
form_type_code: str,
|
||||
year: int,
|
||||
year: int,
|
||||
org_unit_ids: list[int],
|
||||
user: AppUser | None = None,
|
||||
) -> BudgetForm | None:
|
||||
return await asyncio.gather(
|
||||
*[
|
||||
@ -127,16 +149,18 @@ class BudgetFormService:
|
||||
form_type_code=form_type_code,
|
||||
year=year,
|
||||
org_unit_id=org_unit_id,
|
||||
user=user,
|
||||
)
|
||||
for org_unit_id in org_unit_ids
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def bulk_create_if_not_exists(
|
||||
self,
|
||||
self,
|
||||
form_type_code: str,
|
||||
year: int,
|
||||
year: int,
|
||||
org_unit_ids: list[int],
|
||||
user: AppUser | None = None,
|
||||
) -> BudgetForm | None:
|
||||
return await asyncio.gather(
|
||||
*[
|
||||
@ -144,7 +168,118 @@ class BudgetFormService:
|
||||
form_type_code=form_type_code,
|
||||
year=year,
|
||||
org_unit_id=org_unit_id,
|
||||
user=user,
|
||||
)
|
||||
for org_unit_id in org_unit_ids
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
async def mark_for_deletion(self, budget_form_id: int, user: AppUser) -> BudgetForm:
|
||||
self._ensure_admin(user)
|
||||
form = await self._get_admin_form(budget_form_id)
|
||||
if form.to_delete:
|
||||
return form
|
||||
await self.bf_repo.mark_for_deletion(budget_form_id=budget_form_id, user_id=user.id)
|
||||
await self.db.refresh(form)
|
||||
self._add_audit_log(
|
||||
event="FORM_MARKED_FOR_DELETION",
|
||||
user=user,
|
||||
form=form,
|
||||
event_data={
|
||||
"entity_type": "form",
|
||||
"entity_id": form.id,
|
||||
"form_id": form.id,
|
||||
"form_type_code": self._form_type_value(form.form_type_code),
|
||||
"year": form.year,
|
||||
"org_unit_id": form.org_unit_id,
|
||||
},
|
||||
)
|
||||
return form
|
||||
|
||||
async def restore(self, budget_form_id: int, user: AppUser) -> BudgetForm:
|
||||
self._ensure_admin(user)
|
||||
form = await self._get_admin_form(budget_form_id)
|
||||
if not form.to_delete:
|
||||
return form
|
||||
snapshot = self._form_snapshot(form)
|
||||
await self.bf_repo.restore(budget_form_id=budget_form_id, user_id=user.id)
|
||||
await self.db.refresh(form)
|
||||
self._add_audit_log(
|
||||
event="FORM_RESTORED",
|
||||
user=user,
|
||||
form=form,
|
||||
event_data={
|
||||
"entity_type": "form",
|
||||
"entity_id": form.id,
|
||||
"before": snapshot,
|
||||
},
|
||||
)
|
||||
return form
|
||||
|
||||
async def hard_delete(self, budget_form_id: int, user: AppUser) -> None:
|
||||
self._ensure_admin(user)
|
||||
form = await self._get_admin_form(budget_form_id)
|
||||
if not form.to_delete:
|
||||
raise ValidationException("Задача не помечена на удаление")
|
||||
if form.deleted_by == user.id:
|
||||
raise AccessDeniedException("Полностью удалить задачу должен другой администратор")
|
||||
snapshot = self._form_snapshot(form)
|
||||
self._add_audit_log(
|
||||
event="FORM_HARD_DELETED",
|
||||
user=user,
|
||||
form=form,
|
||||
event_data={
|
||||
"entity_type": "form",
|
||||
"entity_id": form.id,
|
||||
"before": snapshot,
|
||||
},
|
||||
)
|
||||
await self.db.flush()
|
||||
await self.bf_repo.hard_delete(budget_form_id=budget_form_id)
|
||||
|
||||
def _ensure_admin(self, user: AppUser) -> None:
|
||||
if user.role_id != UserRoleEnum.ADMIN:
|
||||
raise AccessDeniedException("Недостаточно прав")
|
||||
|
||||
async def _get_admin_form(self, budget_form_id: int) -> BudgetForm:
|
||||
form = await self.bf_repo.get(budget_form_id=budget_form_id, include_to_delete=True, load_org=True)
|
||||
if not form:
|
||||
raise ValidationException("Форма не найдена")
|
||||
return form
|
||||
|
||||
def _add_audit_log(
|
||||
self,
|
||||
event: str,
|
||||
user: AppUser,
|
||||
form: BudgetForm,
|
||||
event_data: dict,
|
||||
) -> None:
|
||||
self.db.add(
|
||||
AuditLog(
|
||||
user_id=user.id,
|
||||
org_unit_id=form.org_unit_id,
|
||||
form_id=form.id,
|
||||
task_id=form.id,
|
||||
event=event,
|
||||
event_type="FORM",
|
||||
event_data=event_data,
|
||||
)
|
||||
)
|
||||
|
||||
def _form_type_value(self, form_type_code) -> str:
|
||||
return getattr(form_type_code, "value", str(form_type_code))
|
||||
|
||||
def _form_snapshot(self, form: BudgetForm) -> dict:
|
||||
return {
|
||||
"id": form.id,
|
||||
"form_type_code": self._form_type_value(form.form_type_code),
|
||||
"year": form.year,
|
||||
"org_unit_id": form.org_unit_id,
|
||||
"created_by": form.created_by,
|
||||
"created_at": form.created_at.isoformat() if form.created_at else None,
|
||||
"updated_by": form.updated_by,
|
||||
"updated_at": form.updated_at.isoformat() if form.updated_at else None,
|
||||
"to_delete": form.to_delete,
|
||||
"deleted_by": form.deleted_by,
|
||||
"deleted_at": form.deleted_at.isoformat() if form.deleted_at else None,
|
||||
}
|
||||
|
||||
@ -31,6 +31,15 @@ export const TasksApi = {
|
||||
responseType: 'blob',
|
||||
});
|
||||
},
|
||||
markForDeletion(taskId) {
|
||||
return api.post(`/form/${taskId}/mark-delete`).then((r) => r.data);
|
||||
},
|
||||
restore(taskId) {
|
||||
return api.post(`/form/${taskId}/restore`).then((r) => r.data);
|
||||
},
|
||||
hardDelete(taskId) {
|
||||
return api.delete(`/form/${taskId}/hard`).then((r) => r.data);
|
||||
},
|
||||
exportTasks(payload, params = {}) {
|
||||
return api.post(`export/bulk`, payload, {
|
||||
params,
|
||||
|
||||
@ -59,6 +59,9 @@ export const TaskCardComponent = ({
|
||||
onAddForExport,
|
||||
onExportClick,
|
||||
isLastOpened = false,
|
||||
isAdmin = false,
|
||||
onChanged,
|
||||
currentUserId = null,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@ -67,11 +70,14 @@ export const TaskCardComponent = ({
|
||||
if (isLastOpened) cardRef.current?.scrollIntoView({ block: 'nearest' });
|
||||
}, [isLastOpened]);
|
||||
const { id, title, org_unit, form_type_code, year } = taskForm;
|
||||
const canHardDelete = taskForm.to_delete && taskForm.deleted_by !== currentUserId;
|
||||
|
||||
const [isEditModalOpen, setEditModalOpen] = useState(false);
|
||||
const [editedTitle, setEditedTitle] = useState('');
|
||||
const [currentTypeCode, setCurrentTypeCode] = useState(FORM_TYPE_TRANSLATE[form_type_code] || '');
|
||||
const [isChecked, setIsChecked] = useState(false);
|
||||
const [confirmAction, setConfirmAction] = useState(null);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsChecked(false);
|
||||
@ -102,6 +108,52 @@ export const TaskCardComponent = ({
|
||||
onExportClick?.(id, title);
|
||||
};
|
||||
|
||||
const openConfirmAction = (event, action) => {
|
||||
event.stopPropagation();
|
||||
setConfirmAction(action);
|
||||
};
|
||||
|
||||
const closeConfirmAction = () => {
|
||||
if (!actionLoading) setConfirmAction(null);
|
||||
};
|
||||
|
||||
const runConfirmedAction = async () => {
|
||||
if (!confirmAction) return;
|
||||
try {
|
||||
setActionLoading(true);
|
||||
if (confirmAction === 'mark') {
|
||||
await TasksApi.markForDeletion(id);
|
||||
toast.success('Задача помечена на удаление');
|
||||
}
|
||||
if (confirmAction === 'restore') {
|
||||
await TasksApi.restore(id);
|
||||
toast.success('Задача возвращена');
|
||||
}
|
||||
if (confirmAction === 'hardDelete') {
|
||||
await TasksApi.hardDelete(id);
|
||||
toast.success('Задача полностью удалена');
|
||||
}
|
||||
setConfirmAction(null);
|
||||
onChanged?.();
|
||||
} catch (error) {
|
||||
toast.error(error?.response?.data?.detail || 'Не удалось выполнить действие');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const actionTitle = {
|
||||
mark: 'Удалить задачу',
|
||||
restore: 'Вернуть задачу',
|
||||
hardDelete: 'Полностью удалить задачу',
|
||||
}[confirmAction];
|
||||
|
||||
const actionText = {
|
||||
mark: 'Задача будет скрыта от пользователей. Админы смогут вернуть её или удалить окончательно.',
|
||||
restore: 'Задача снова станет доступна пользователям.',
|
||||
hardDelete: 'Будут удалены задача и все связанные данные. Это действие нельзя отменить.',
|
||||
}[confirmAction];
|
||||
|
||||
const updateTask = async (nextTitle) => {
|
||||
try {
|
||||
await TasksApi.editTask(id, { title: nextTitle });
|
||||
@ -147,6 +199,7 @@ export const TaskCardComponent = ({
|
||||
<TaskCard
|
||||
ref={cardRef}
|
||||
$fluidWidth={fluidWidth}
|
||||
$marked={Boolean(taskForm.to_delete)}
|
||||
onClick={(event) => {
|
||||
if (exportMode) {
|
||||
handleCheckboxClick(event);
|
||||
@ -159,7 +212,10 @@ export const TaskCardComponent = ({
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<CardHeader>
|
||||
<TaskId>ID: {id}</TaskId>
|
||||
{isLastOpened && <LastOpenedBadge sx={{ mb: 0 }}>Последняя открытая</LastOpenedBadge>}
|
||||
<Stack direction='row' gap={0.75} alignItems='center'>
|
||||
{taskForm.to_delete && <Chip sx={{ background: '#fde8e8', color: '#b42318' }}>На удаление</Chip>}
|
||||
{isLastOpened && <LastOpenedBadge sx={{ mb: 0 }}>Последняя открытая</LastOpenedBadge>}
|
||||
</Stack>
|
||||
</CardHeader>
|
||||
|
||||
<TaskTitle>{org_unit?.title || 'Подразделение не указано'}</TaskTitle>
|
||||
@ -182,10 +238,47 @@ export const TaskCardComponent = ({
|
||||
|
||||
</CardBody>
|
||||
|
||||
{!isForm && !exportMode && <ExportButton onClick={handleExportTask} />}
|
||||
{exportMode && <div>{isChecked ? <CheckBoxCheckSvg size='1.625rem' /> : <CheckBoxUncheckSvg size='1.625rem' />}</div>}
|
||||
<Stack direction='row' gap={0.75} alignItems='center' onClick={(event) => event.stopPropagation()}>
|
||||
{isAdmin && !isForm && !exportMode && !taskForm.to_delete && (
|
||||
<Button size='small' variant='text' color='error' onClick={(event) => openConfirmAction(event, 'mark')}>
|
||||
Удалить
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && !isForm && !exportMode && taskForm.to_delete && (
|
||||
<>
|
||||
<Button size='small' variant='text' onClick={(event) => openConfirmAction(event, 'restore')}>
|
||||
Вернуть
|
||||
</Button>
|
||||
{canHardDelete && (
|
||||
<Button size='small' variant='text' color='error' onClick={(event) => openConfirmAction(event, 'hardDelete')}>
|
||||
Удалить
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!isForm && !exportMode && !taskForm.to_delete && <ExportButton onClick={handleExportTask} />}
|
||||
{exportMode && <div>{isChecked ? <CheckBoxCheckSvg size='1.625rem' /> : <CheckBoxUncheckSvg size='1.625rem' />}</div>}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</TaskCard>
|
||||
|
||||
<Modal
|
||||
open={Boolean(confirmAction)}
|
||||
onClose={closeConfirmAction}
|
||||
title={actionTitle}
|
||||
size='small'
|
||||
actions={
|
||||
<>
|
||||
<Button variant='grey' onClick={closeConfirmAction} disabled={actionLoading}>
|
||||
Отменить
|
||||
</Button>
|
||||
<Button variant='contained' color={confirmAction === 'hardDelete' || confirmAction === 'mark' ? 'error' : 'primary'} onClick={runConfirmedAction} disabled={actionLoading}>
|
||||
Подтвердить
|
||||
</Button>
|
||||
</>
|
||||
}>
|
||||
{actionText}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -10,12 +10,13 @@ export const TaskCard = styled.div`
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
box-sizing: border-box;
|
||||
border: 0.06rem solid var(--Stroke, rgba(0, 0, 0, 0.1));
|
||||
border: 0.06rem solid ${({ $marked }) => ($marked ? 'rgba(211, 47, 47, 0.35)' : 'var(--Stroke, rgba(0, 0, 0, 0.1))')};
|
||||
border-radius: 1rem;
|
||||
box-shadow:
|
||||
0rem 2rem 4rem -2rem rgba(0, 0, 0, 0.02),
|
||||
0rem 4rem 6rem -1rem rgba(0, 0, 0, 0.02);
|
||||
background: var(--White, rgba(255, 255, 255, 1));
|
||||
background: ${({ $marked }) => ($marked ? '#fff7f7' : 'var(--White, rgba(255, 255, 255, 1))')};
|
||||
opacity: ${({ $marked }) => ($marked ? 0.72 : 1)};
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
@ -5,6 +5,9 @@ export const DEFAULT_ACTIONS = {
|
||||
ACCESS_WINDOW_CHANGE: 'Изменение этапа',
|
||||
USER_ROLE_CHANGE: 'Изменение роли пользователя',
|
||||
CREATE_TASK: 'Создание задачи',
|
||||
FORM_MARKED_FOR_DELETION: 'Задача отмечена на удаление',
|
||||
FORM_RESTORED: 'Задача возвращена',
|
||||
FORM_HARD_DELETED: 'Задача полностью удалена',
|
||||
// UPDATE_TASK: 'Обновление задачи', //сейчас нет события
|
||||
// DELETE_TASK: 'Удаление задачи',
|
||||
USER_CREATE: 'Создание пользователя',
|
||||
|
||||
@ -58,6 +58,21 @@ export const EVENT_CONFIGS = {
|
||||
form_type: 'Форма',
|
||||
ssp_list: 'Создана для СПП/РФ',
|
||||
},
|
||||
FORM_MARKED_FOR_DELETION: {
|
||||
entity_id: 'Задача',
|
||||
form_id: 'Задача',
|
||||
form_type_code: 'Форма',
|
||||
year: 'Год',
|
||||
org_unit_id: 'ССП/РФ',
|
||||
},
|
||||
FORM_RESTORED: {
|
||||
entity_id: 'Задача',
|
||||
before: 'Данные до возврата',
|
||||
},
|
||||
FORM_HARD_DELETED: {
|
||||
entity_id: 'Задача',
|
||||
before: 'Удалённые данные',
|
||||
},
|
||||
ROW_CREATE: {
|
||||
sheet: 'Лист',
|
||||
expense_item_id: 'Статья расходов',
|
||||
|
||||
@ -40,6 +40,7 @@ export default function TasksPage() {
|
||||
const yearFilter = searchParams.get('year') || '';
|
||||
const formTypeFilter = searchParams.get('form_type') || '';
|
||||
const orgUnitsFilter = searchParams.get('org_units') || '';
|
||||
const deleteStatusFilter = searchParams.get('delete_status') || 'all';
|
||||
const orgUnitIdsFilter = useMemo(
|
||||
() =>
|
||||
orgUnitsFilter
|
||||
@ -185,6 +186,7 @@ export default function TasksPage() {
|
||||
...(yearFilter && { year: Number(yearFilter) }),
|
||||
...(formTypeFilter && { form_type: formTypeFilter }),
|
||||
...(orgUnitIdsFilter.length > 0 && { org_units: orgUnitIdsFilter.join(',') }),
|
||||
...(user?.role_id === ROLES_NAME_ID.admin && deleteStatusFilter && { delete_status: deleteStatusFilter }),
|
||||
};
|
||||
|
||||
const res = await TasksApi.list(params);
|
||||
@ -216,7 +218,7 @@ export default function TasksPage() {
|
||||
if (currentRequest === requestId.current) setLoading(false);
|
||||
}
|
||||
},
|
||||
[yearFilter, formTypeFilter, orgUnitIdsFilter, setSearchParams],
|
||||
[yearFilter, formTypeFilter, orgUnitIdsFilter, deleteStatusFilter, user?.role_id, setSearchParams],
|
||||
);
|
||||
|
||||
const handlePageChange = (_event, value) => {
|
||||
@ -263,6 +265,7 @@ export default function TasksPage() {
|
||||
nextParams.delete('year');
|
||||
nextParams.delete('form_type');
|
||||
nextParams.delete('org_units');
|
||||
nextParams.delete('delete_status');
|
||||
return nextParams;
|
||||
});
|
||||
};
|
||||
@ -286,7 +289,18 @@ export default function TasksPage() {
|
||||
onLimitChange={handleLimitChange}
|
||||
entityName='задач'
|
||||
emptyMessage='Задачи не найдены'
|
||||
renderItem={(task, exportProps) => <TaskCardComponent key={task.id} taskForm={task} isLastOpened={String(task.id) === lastVisit?.id} fluidWidth {...exportProps} />}
|
||||
renderItem={(task, exportProps) => (
|
||||
<TaskCardComponent
|
||||
key={task.id}
|
||||
taskForm={task}
|
||||
isLastOpened={String(task.id) === lastVisit?.id}
|
||||
fluidWidth
|
||||
isAdmin={user.role_id === ROLES_NAME_ID.admin}
|
||||
currentUserId={user.id}
|
||||
onChanged={() => loadTasks(page, limit)}
|
||||
{...exportProps}
|
||||
/>
|
||||
)}
|
||||
getItemId={(task) => task.id}
|
||||
getItemTitle={(task) =>
|
||||
task.title ||
|
||||
@ -345,6 +359,19 @@ export default function TasksPage() {
|
||||
sx={{ width: 480, maxWidth: '100%' }}
|
||||
/>
|
||||
|
||||
{user.role_id === ROLES_NAME_ID.admin && (
|
||||
<Select
|
||||
size='small'
|
||||
displayEmpty
|
||||
value={deleteStatusFilter}
|
||||
onChange={(event) => handleFilterChange('delete_status', event.target.value)}
|
||||
sx={{ width: 230 }}>
|
||||
<MenuItem value='active'>Непомеченные</MenuItem>
|
||||
<MenuItem value='marked'>Помеченные</MenuItem>
|
||||
<MenuItem value='all'>Все задачи</MenuItem>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<ResetFiltersButton onClick={handleClearFilters} />
|
||||
</Box>
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user