Law/app/api/admin/crud_modules/service.py
2026-04-03 14:28:36 +03:00

632 lines
29 KiB
Python

from __future__ import annotations
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import HTTPException
from sqlalchemy import or_
from sqlalchemy.exc import DataError, IntegrityError
from sqlalchemy.orm import Session
from app.models.admin_user import AdminUser
from app.models.attachment import Attachment
from app.models.message import Message
from app.models.request import Request
from app.models.request_service_request import RequestServiceRequest
from app.models.table_availability import TableAvailability
from app.schemas.universal import UniversalQuery
from app.services.billing_flow import apply_billing_transition_effects
from app.services.notifications import (
EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT,
EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE,
EVENT_STATUS as NOTIFICATION_EVENT_STATUS,
mark_admin_notifications_read,
notify_request_event,
)
from app.services.request_assignment_events import apply_assignment_change
from app.services.request_read_markers import (
EVENT_ATTACHMENT,
EVENT_MESSAGE,
EVENT_STATUS,
clear_unread_for_lawyer,
mark_unread_for_client,
mark_unread_for_lawyer,
)
from app.services.request_deadline import initial_important_date_at
from app.services.request_finance_validation import (
normalize_request_financial_payload_or_400,
request_financial_data_error_or_400,
)
from app.services.request_status import apply_status_change_effects
from app.services.request_templates import validate_required_topic_fields_or_400
from app.services.status_flow import transition_allowed_for_topic
from app.services.status_transition_requirements import validate_transition_requirements_or_400
from app.services.universal_query import apply_universal_query
from .access import (
REQUEST_FINANCIAL_FIELDS,
_ensure_lawyer_can_manage_request_or_403,
_ensure_lawyer_can_view_request_or_403,
_is_lawyer,
_lawyer_actor_id_or_401,
_request_for_related_row_or_404,
_require_table_action,
_resolve_table_model,
)
from .audit import _actor_role, _append_audit, _integrity_error, _resolve_responsible, _strip_hidden_fields
from .meta import (
_columns_map,
_meta_tables_payload,
_row_to_dict,
_serialize_value,
_table_availability_map,
)
from .payloads import (
_active_lawyer_or_400,
_apply_admin_user_fields_for_update,
_apply_admin_user_topics_fields,
_apply_auto_fields_for_create,
_apply_request_data_requirements_fields,
_apply_request_data_template_items_fields,
_apply_request_data_templates_fields,
_apply_status_fields,
_apply_topic_data_templates_fields,
_apply_topic_required_fields_fields,
_apply_topic_status_transitions_fields,
_load_row_or_404,
_parse_uuid_or_400,
_prepare_create_payload,
_request_for_uuid_or_400,
_sanitize_payload,
_upsert_client_or_400,
)
def _ensure_lawyer_owns_admin_user_row_or_403(admin: dict, row_id: str) -> None:
if not _is_lawyer(admin):
return
actor_id = _lawyer_actor_id_or_401(admin).strip().lower()
target_id = str(row_id or "").strip().lower()
if not actor_id or not target_id or actor_id != target_id:
raise HTTPException(status_code=403, detail="Недостаточно прав")
def _apply_create_side_effects(db: Session, *, table_name: str, row: Any, admin: dict) -> None:
if table_name == "messages" and isinstance(row, Message):
req = db.get(Request, row.request_id)
if req is None:
return
author_type = str(row.author_type or "").strip().upper()
if author_type == "CLIENT":
mark_unread_for_lawyer(req, EVENT_MESSAGE)
responsible = "Клиент"
actor_role = "CLIENT"
actor_admin_user_id = None
else:
mark_unread_for_client(req, EVENT_MESSAGE)
responsible = _resolve_responsible(admin)
actor_role = _actor_role(admin)
actor_admin_user_id = admin.get("sub")
req.responsible = responsible
db.add(req)
notify_request_event(
db,
request=req,
event_type=NOTIFICATION_EVENT_MESSAGE,
actor_role=actor_role,
actor_admin_user_id=actor_admin_user_id,
body=str(row.body or "").strip() or None,
responsible=responsible,
)
return
if table_name == "attachments" and isinstance(row, Attachment):
req = db.get(Request, row.request_id)
if req is None:
return
mark_unread_for_client(req, EVENT_ATTACHMENT)
responsible = _resolve_responsible(admin)
req.responsible = responsible
db.add(req)
notify_request_event(
db,
request=req,
event_type=NOTIFICATION_EVENT_ATTACHMENT,
actor_role=_actor_role(admin),
actor_admin_user_id=admin.get("sub"),
body=f"Файл: {row.file_name}",
responsible=responsible,
)
return
def list_tables_meta_service(db: Session, admin: dict) -> dict[str, Any]:
role = str(admin.get("role") or "").upper()
if role != "ADMIN":
raise HTTPException(status_code=403, detail="Недостаточно прав")
return {"tables": _meta_tables_payload(db, role=role, include_inactive_dictionaries=False)}
def list_available_tables_service(db: Session, admin: dict) -> dict[str, Any]:
role = str(admin.get("role") or "").upper()
if role != "ADMIN":
raise HTTPException(status_code=403, detail="Недостаточно прав")
availability = _table_availability_map(db)
rows = []
for item in _meta_tables_payload(db, role=role, include_inactive_dictionaries=True):
table_name = str(item.get("table") or "")
state = availability.get(table_name)
rows.append(
{
"table": table_name,
"label": item.get("label"),
"section": item.get("section"),
"is_active": bool(item.get("is_active")),
"responsible": state.responsible if state is not None else None,
"updated_at": _serialize_value(state.updated_at) if state is not None else None,
}
)
return {"rows": rows, "total": len(rows)}
def update_available_table_service(table_name: str, is_active: bool, db: Session, admin: dict) -> dict[str, Any]:
role = str(admin.get("role") or "").upper()
if role != "ADMIN":
raise HTTPException(status_code=403, detail="Недостаточно прав")
normalized, _ = _resolve_table_model(table_name)
row = db.query(TableAvailability).filter(TableAvailability.table_name == normalized).first()
responsible = _resolve_responsible(admin)
next_is_active = bool(is_active)
if row is None:
row = TableAvailability(
table_name=normalized,
is_active=next_is_active,
responsible=responsible,
)
db.add(row)
else:
row.is_active = next_is_active
row.updated_at = datetime.now(timezone.utc)
row.responsible = responsible
db.add(row)
db.commit()
db.refresh(row)
return {
"table": normalized,
"is_active": bool(row.is_active),
"responsible": row.responsible,
"updated_at": _serialize_value(row.updated_at),
}
def query_table_service(table_name: str, uq: UniversalQuery, db: Session, admin: dict) -> dict[str, Any]:
normalized, model = _resolve_table_model(table_name)
_require_table_action(admin, normalized, "query")
base_query = db.query(model)
if normalized == "requests" and _is_lawyer(admin):
actor_id = _lawyer_actor_id_or_401(admin)
base_query = base_query.filter(
or_(
Request.assigned_lawyer_id == actor_id,
Request.assigned_lawyer_id.is_(None),
)
)
if normalized == "messages" and _is_lawyer(admin):
actor_id = _lawyer_actor_id_or_401(admin)
base_query = base_query.join(Request, Request.id == Message.request_id).filter(
or_(
Request.assigned_lawyer_id == actor_id,
Request.assigned_lawyer_id.is_(None),
)
)
if normalized == "attachments" and _is_lawyer(admin):
actor_id = _lawyer_actor_id_or_401(admin)
base_query = base_query.join(Request, Request.id == Attachment.request_id).filter(
or_(
Request.assigned_lawyer_id == actor_id,
Request.assigned_lawyer_id.is_(None),
)
)
if normalized == "request_service_requests" and _is_lawyer(admin):
actor_id = _lawyer_actor_id_or_401(admin)
base_query = base_query.filter(
RequestServiceRequest.type == "CURATOR_CONTACT",
RequestServiceRequest.assigned_lawyer_id == actor_id,
)
query = apply_universal_query(base_query, model, uq)
total = query.count()
rows = query.offset(uq.page.offset).limit(uq.page.limit).all()
return {"rows": [_strip_hidden_fields(normalized, _row_to_dict(row)) for row in rows], "total": total}
def get_row_service(table_name: str, row_id: str, db: Session, admin: dict) -> dict[str, Any]:
normalized, model = _resolve_table_model(table_name)
_require_table_action(admin, normalized, "read")
if normalized == "admin_users":
_ensure_lawyer_owns_admin_user_row_or_403(admin, row_id)
row = _load_row_or_404(db, model, row_id)
if normalized == "requests":
req = row if isinstance(row, Request) else None
if req is not None:
_ensure_lawyer_can_view_request_or_403(admin, req)
changed = False
if _is_lawyer(admin) and clear_unread_for_lawyer(req):
changed = True
db.add(req)
read_count = mark_admin_notifications_read(
db,
admin_user_id=admin.get("sub"),
request_id=req.id,
responsible=_resolve_responsible(admin),
)
if read_count:
changed = True
if changed:
db.commit()
db.refresh(req)
row = req
if normalized == "messages" and isinstance(row, Message):
req = _request_for_related_row_or_404(db, row)
_ensure_lawyer_can_view_request_or_403(admin, req)
if normalized == "attachments" and isinstance(row, Attachment):
req = _request_for_related_row_or_404(db, row)
_ensure_lawyer_can_view_request_or_403(admin, req)
if normalized == "request_service_requests" and _is_lawyer(admin):
actor_id = _lawyer_actor_id_or_401(admin)
row_type = str(getattr(row, "type", "") or "").strip().upper()
assigned = str(getattr(row, "assigned_lawyer_id", "") or "").strip()
if row_type != "CURATOR_CONTACT" or not assigned or assigned != actor_id:
raise HTTPException(status_code=403, detail="Недостаточно прав")
payload = _strip_hidden_fields(normalized, _row_to_dict(row))
if normalized == "requests" and isinstance(row, Request):
assigned_lawyer_id = str(row.assigned_lawyer_id or "").strip()
if assigned_lawyer_id:
try:
lawyer_uuid = uuid.UUID(assigned_lawyer_id)
except ValueError:
lawyer_uuid = None
if lawyer_uuid is not None:
lawyer = db.get(AdminUser, lawyer_uuid)
if lawyer is not None:
payload["assigned_lawyer_name"] = lawyer.name or lawyer.email or assigned_lawyer_id
payload["assigned_lawyer_phone"] = _serialize_value(getattr(lawyer, "phone", None))
return payload
def create_row_service(table_name: str, payload: dict[str, Any], db: Session, admin: dict) -> dict[str, Any]:
normalized, model = _resolve_table_model(table_name)
_require_table_action(admin, normalized, "create")
responsible = _resolve_responsible(admin)
resolved_request_client_id: uuid.UUID | None = None
resolved_invoice_client_id: uuid.UUID | None = None
if normalized == "requests" and _is_lawyer(admin) and isinstance(payload, dict):
assigned_lawyer_id = payload.get("assigned_lawyer_id")
if str(assigned_lawyer_id or "").strip():
raise HTTPException(status_code=403, detail='Юрист не может назначать заявку при создании')
forbidden_fields = sorted(REQUEST_FINANCIAL_FIELDS.intersection(set(payload.keys())))
if forbidden_fields:
raise HTTPException(status_code=403, detail="Юрист не может изменять финансовые поля заявки")
prepared = _prepare_create_payload(normalized, payload)
if normalized == "messages":
request_uuid = _parse_uuid_or_400(prepared.get("request_id"), "request_id")
req = db.get(Request, request_uuid)
if req is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
if _is_lawyer(admin):
_ensure_lawyer_can_manage_request_or_403(admin, req)
prepared["author_type"] = "LAWYER"
prepared["author_name"] = str(admin.get("email") or "").strip() or "Юрист"
prepared["immutable"] = False
prepared["request_id"] = request_uuid
if normalized == "requests":
prepared = normalize_request_financial_payload_or_400(prepared)
validate_required_topic_fields_or_400(db, prepared.get("topic_code"), prepared.get("extra_fields"))
client = _upsert_client_or_400(
db,
full_name=prepared.get("client_name"),
phone=prepared.get("client_phone"),
responsible=responsible,
)
resolved_request_client_id = client.id
prepared["client_name"] = client.full_name
prepared["client_phone"] = client.phone
if not _is_lawyer(admin):
assigned_raw = prepared.get("assigned_lawyer_id")
if assigned_raw is None or not str(assigned_raw).strip():
if "assigned_lawyer_id" in prepared:
prepared["assigned_lawyer_id"] = None
else:
assigned_lawyer = _active_lawyer_or_400(db, assigned_raw)
prepared["assigned_lawyer_id"] = str(assigned_lawyer.id)
if prepared.get("effective_rate") is None:
prepared["effective_rate"] = assigned_lawyer.default_rate
important_raw = prepared.get("important_date_at")
if important_raw is None or not str(important_raw).strip():
prepared["important_date_at"] = initial_important_date_at()
if normalized == "invoices":
req = _request_for_uuid_or_400(db, prepared.get("request_id"))
prepared["request_id"] = req.id
resolved_invoice_client_id = req.client_id
prepared = _apply_auto_fields_for_create(db, model, normalized, prepared)
clean_payload = _sanitize_payload(
model,
normalized,
prepared,
is_update=False,
allow_protected_fields={"password_hash"} if normalized == "admin_users" else None,
)
if normalized == "admin_user_topics":
clean_payload = _apply_admin_user_topics_fields(db, clean_payload)
if normalized == "topic_required_fields":
clean_payload = _apply_topic_required_fields_fields(db, clean_payload)
if normalized == "topic_data_templates":
clean_payload = _apply_topic_data_templates_fields(db, clean_payload)
if normalized == "request_data_templates":
clean_payload = _apply_request_data_templates_fields(db, clean_payload)
if normalized == "request_data_template_items":
clean_payload = _apply_request_data_template_items_fields(db, clean_payload)
if normalized == "request_data_requirements":
clean_payload = _apply_request_data_requirements_fields(db, clean_payload)
if normalized == "topic_status_transitions":
clean_payload = _apply_topic_status_transitions_fields(db, clean_payload)
if normalized == "statuses":
clean_payload = _apply_status_fields(db, clean_payload)
if normalized == "requests":
clean_payload["client_id"] = resolved_request_client_id
if normalized == "invoices":
clean_payload["client_id"] = resolved_invoice_client_id
if "responsible" in _columns_map(model):
clean_payload["responsible"] = responsible
row = model(**clean_payload)
try:
db.add(row)
db.flush()
_apply_create_side_effects(db, table_name=normalized, row=row, admin=admin)
snapshot = _row_to_dict(row)
_append_audit(db, admin, normalized, str(snapshot.get("id") or ""), "CREATE", {"after": snapshot})
db.commit()
db.refresh(row)
except DataError:
db.rollback()
raise request_financial_data_error_or_400()
except IntegrityError:
db.rollback()
raise _integrity_error()
if normalized == "requests" and isinstance(row, Request):
assigned_lawyer_id = str(row.assigned_lawyer_id or "").strip()
if assigned_lawyer_id:
apply_assignment_change(
db,
request=row,
old_lawyer_id=None,
new_lawyer_id=assigned_lawyer_id,
actor_role=_actor_role(admin),
actor_admin_user_id=admin.get("sub"),
responsible=responsible,
actor_name=str(admin.get("email") or "").strip() or "Администратор системы",
)
db.add(row)
db.commit()
db.refresh(row)
return _strip_hidden_fields(normalized, _row_to_dict(row))
def update_row_service(table_name: str, row_id: str, payload: dict[str, Any], db: Session, admin: dict) -> dict[str, Any]:
normalized, model = _resolve_table_model(table_name)
_require_table_action(admin, normalized, "update")
responsible = _resolve_responsible(admin)
if normalized == "admin_users" and _is_lawyer(admin):
_ensure_lawyer_owns_admin_user_row_or_403(admin, row_id)
allowed_fields = {"name", "email", "phone", "password", "avatar_url"}
forbidden_fields = sorted(set(payload.keys()) - allowed_fields)
if forbidden_fields:
raise HTTPException(status_code=403, detail="Недостаточно прав")
if normalized == "requests" and _is_lawyer(admin) and isinstance(payload, dict):
if "assigned_lawyer_id" in payload:
raise HTTPException(status_code=403, detail='Назначение доступно только через действие "Взять в работу"')
forbidden_fields = sorted(REQUEST_FINANCIAL_FIELDS.intersection(set(payload.keys())))
if forbidden_fields:
raise HTTPException(status_code=403, detail="Юрист не может изменять финансовые поля заявки")
row = _load_row_or_404(db, model, row_id)
if normalized == "requests" and isinstance(row, Request):
_ensure_lawyer_can_manage_request_or_403(admin, row)
if normalized in {"messages", "attachments"} and bool(getattr(row, "immutable", False)):
raise HTTPException(status_code=400, detail="Запись зафиксирована и недоступна для редактирования")
prepared = dict(payload)
if normalized == "admin_users":
prepared = _apply_admin_user_fields_for_update(prepared)
clean_payload = _sanitize_payload(
model,
normalized,
prepared,
is_update=True,
allow_protected_fields={"password_hash"} if normalized == "admin_users" else None,
)
if normalized == "admin_users" and "email" in clean_payload:
new_email = str(clean_payload.get("email") or "").strip().lower()
if new_email:
duplicate = db.query(AdminUser).filter(
AdminUser.email == new_email,
AdminUser.id != row.id,
).first()
if duplicate:
raise HTTPException(status_code=400, detail="Этот email уже используется другим пользователем")
if normalized == "admin_user_topics":
clean_payload = _apply_admin_user_topics_fields(db, clean_payload)
if normalized == "topic_required_fields":
clean_payload = _apply_topic_required_fields_fields(db, clean_payload)
if normalized == "topic_data_templates":
clean_payload = _apply_topic_data_templates_fields(db, clean_payload)
if normalized == "request_data_templates":
clean_payload = _apply_request_data_templates_fields(db, clean_payload)
if normalized == "request_data_template_items":
clean_payload = _apply_request_data_template_items_fields(db, clean_payload)
if normalized == "request_data_requirements":
clean_payload = _apply_request_data_requirements_fields(db, clean_payload)
if normalized == "topic_status_transitions":
clean_payload = _apply_topic_status_transitions_fields(db, clean_payload)
if normalized == "statuses":
clean_payload = _apply_status_fields(db, clean_payload)
if normalized == "requests" and isinstance(row, Request):
clean_payload = normalize_request_financial_payload_or_400(clean_payload)
if {"client_name", "client_phone"}.intersection(set(clean_payload.keys())) or row.client_id is None:
client = _upsert_client_or_400(
db,
full_name=clean_payload.get("client_name", row.client_name),
phone=clean_payload.get("client_phone", row.client_phone),
responsible=responsible,
)
clean_payload["client_id"] = client.id
clean_payload["client_name"] = client.full_name
clean_payload["client_phone"] = client.phone
if normalized == "invoices":
if "request_id" in clean_payload:
req = _request_for_uuid_or_400(db, clean_payload.get("request_id"))
clean_payload["request_id"] = req.id
clean_payload["client_id"] = req.client_id
elif getattr(row, "client_id", None) is None:
req = db.get(Request, getattr(row, "request_id", None))
if req is not None:
clean_payload["client_id"] = req.client_id
if normalized == "requests" and not _is_lawyer(admin) and "assigned_lawyer_id" in clean_payload:
assigned_raw = clean_payload.get("assigned_lawyer_id")
if assigned_raw is None or not str(assigned_raw).strip():
clean_payload["assigned_lawyer_id"] = None
else:
assigned_lawyer = _active_lawyer_or_400(db, assigned_raw)
clean_payload["assigned_lawyer_id"] = str(assigned_lawyer.id)
if isinstance(row, Request) and row.effective_rate is None and "effective_rate" not in clean_payload:
clean_payload["effective_rate"] = assigned_lawyer.default_rate
if "responsible" in _columns_map(model):
clean_payload["responsible"] = responsible
before = _row_to_dict(row)
before_assigned_lawyer_id = str(before.get("assigned_lawyer_id") or "").strip() if normalized == "requests" else ""
if normalized == "topic_status_transitions":
next_from = str(clean_payload.get("from_status", before.get("from_status") or "")).strip()
next_to = str(clean_payload.get("to_status", before.get("to_status") or "")).strip()
if next_from and next_to and next_from == next_to:
raise HTTPException(status_code=400, detail='Поля "from_status" и "to_status" не должны совпадать')
if normalized == "requests" and "status_code" in clean_payload:
before_status = str(before.get("status_code") or "")
after_status = str(clean_payload.get("status_code") or "")
if before_status != after_status and isinstance(row, Request):
if not transition_allowed_for_topic(
db,
str(row.topic_code or "").strip() or None,
before_status,
after_status,
):
raise HTTPException(status_code=400, detail="Переход статуса не разрешен для выбранной темы")
extra_fields_override = clean_payload.get("extra_fields")
validate_transition_requirements_or_400(
db,
row,
before_status,
after_status,
extra_fields_override=extra_fields_override if isinstance(extra_fields_override, dict) else None,
)
if "important_date_at" not in clean_payload or clean_payload.get("important_date_at") is None:
clean_payload["important_date_at"] = datetime.now(timezone.utc) + timedelta(days=3)
billing_note = apply_billing_transition_effects(
db,
req=row,
from_status=before_status,
to_status=after_status,
admin=admin,
responsible=responsible,
)
mark_unread_for_client(row, EVENT_STATUS)
apply_status_change_effects(
db,
row,
from_status=before_status,
to_status=after_status,
admin=admin,
important_date_at=clean_payload.get("important_date_at"),
responsible=responsible,
)
notify_request_event(
db,
request=row,
event_type=NOTIFICATION_EVENT_STATUS,
actor_role=_actor_role(admin),
actor_admin_user_id=admin.get("sub"),
body=(
f"{before_status} -> {after_status}"
+ (
f"\nВажная дата: {clean_payload.get('important_date_at').isoformat()}"
if isinstance(clean_payload.get("important_date_at"), datetime)
else ""
)
+ (f"\n{billing_note}" if billing_note else "")
),
responsible=responsible,
)
assignment_old_lawyer_id = ""
assignment_new_lawyer_id = ""
if normalized == "requests" and not _is_lawyer(admin):
after_assigned_candidate = clean_payload.get("assigned_lawyer_id", before_assigned_lawyer_id or None)
after_assigned_lawyer_id = str(after_assigned_candidate or "").strip()
if after_assigned_lawyer_id and after_assigned_lawyer_id != before_assigned_lawyer_id:
assignment_old_lawyer_id = before_assigned_lawyer_id
assignment_new_lawyer_id = after_assigned_lawyer_id
for key, value in clean_payload.items():
setattr(row, key, value)
if assignment_new_lawyer_id and isinstance(row, Request):
apply_assignment_change(
db,
request=row,
old_lawyer_id=assignment_old_lawyer_id,
new_lawyer_id=assignment_new_lawyer_id,
actor_role=_actor_role(admin),
actor_admin_user_id=admin.get("sub"),
responsible=responsible,
actor_name=str(admin.get("email") or "").strip() or "Администратор системы",
)
try:
db.add(row)
db.flush()
after = _row_to_dict(row)
_append_audit(db, admin, normalized, str(after.get("id") or row_id), "UPDATE", {"before": before, "after": after})
db.commit()
db.refresh(row)
except DataError:
db.rollback()
raise request_financial_data_error_or_400()
except IntegrityError:
db.rollback()
raise _integrity_error()
return _strip_hidden_fields(normalized, _row_to_dict(row))
def delete_row_service(table_name: str, row_id: str, db: Session, admin: dict) -> dict[str, Any]:
normalized, model = _resolve_table_model(table_name)
_require_table_action(admin, normalized, "delete")
if normalized == "admin_users" and str(admin.get("sub") or "") == str(row_id):
raise HTTPException(status_code=400, detail="Нельзя удалить собственную учетную запись")
row = _load_row_or_404(db, model, row_id)
if normalized == "requests" and isinstance(row, Request):
_ensure_lawyer_can_manage_request_or_403(admin, row)
if normalized in {"messages", "attachments"} and bool(getattr(row, "immutable", False)):
raise HTTPException(status_code=400, detail="Запись зафиксирована и недоступна для удаления")
before = _row_to_dict(row)
entity_id = str(before.get("id") or row_id)
try:
db.delete(row)
_append_audit(db, admin, normalized, entity_id, "DELETE", {"before": before})
db.commit()
except IntegrityError:
db.rollback()
raise _integrity_error("Невозможно удалить запись из-за ограничений связанных данных")
return {"status": "удалено", "id": entity_id}