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}