diff --git a/README.md b/README.md index 3f95c46..f6dea8b 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ SMS_PROVIDER=smsaero SMSAERO_EMAIL=your_email@example.com SMSAERO_API_KEY=your_api_key OTP_SMS_TEMPLATE=Your verification code: {code} +OTP_DEV_MODE=false ``` For local/dev mock mode: @@ -39,5 +40,11 @@ SMS_PROVIDER=dummy ``` In this mode OTP code is printed to backend logs. +You can also force mock mode with real provider settings: +```bash +OTP_DEV_MODE=true +``` +When enabled, real SMS sending is disabled and OTP code is printed to backend logs. + Admin health-check endpoint (no SMS send): `GET /api/admin/system/sms-provider-health` diff --git a/app/api/public/requests.py b/app/api/public/requests.py index b161490..f0f8830 100644 --- a/app/api/public/requests.py +++ b/app/api/public/requests.py @@ -7,6 +7,7 @@ from uuid import uuid4 from fastapi import APIRouter, Depends, HTTPException, Response from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session +from sqlalchemy.exc import SQLAlchemyError from app.core.config import settings from app.core.deps import get_public_session @@ -33,6 +34,7 @@ from app.services.notifications import ( ) from app.services.request_read_markers import clear_unread_for_client from app.services.request_templates import validate_required_topic_fields_or_400 +from app.api.admin.requests_modules.status_flow import get_request_status_route_service from app.schemas.public import ( PublicAttachmentRead, PublicMessageCreate, @@ -263,6 +265,30 @@ def get_request_by_track( session: dict = Depends(get_public_session), ): req = _request_for_track_or_404(db, session, track_number) + topic_name = None + if str(req.topic_code or "").strip(): + try: + topic = db.query(Topic).filter(Topic.code == req.topic_code).first() + topic_name = topic.name if topic and topic.name else None + except SQLAlchemyError: + topic_name = None + lawyer_name = None + lawyer_phone = None + lawyer_id = str(req.assigned_lawyer_id or "").strip() + if lawyer_id: + try: + lawyer_uuid = UUID(lawyer_id) + except ValueError: + lawyer_uuid = None + if lawyer_uuid is not None: + try: + lawyer = db.get(AdminUser, lawyer_uuid) + except SQLAlchemyError: + lawyer = None + if lawyer is not None: + lawyer_name = lawyer.name + lawyer_phone = lawyer.phone + markers_cleared = clear_unread_for_client(req) notifications_cleared = mark_client_notifications_read( db, @@ -282,10 +308,17 @@ def get_request_by_track( "client_name": req.client_name, "client_phone": req.client_phone, "topic_code": req.topic_code, + "topic_name": topic_name, "status_code": req.status_code, + "important_date_at": _to_iso(req.important_date_at), "description": req.description, "extra_fields": req.extra_fields, "assigned_lawyer_id": req.assigned_lawyer_id, + "assigned_lawyer_name": lawyer_name or req.assigned_lawyer_id, + "assigned_lawyer_phone": lawyer_phone, + "request_cost": float(req.request_cost) if req.request_cost is not None else None, + "effective_rate": float(req.effective_rate) if req.effective_rate is not None else None, + "paid_at": _to_iso(req.paid_at), "client_has_unread_updates": req.client_has_unread_updates, "client_unread_event_type": req.client_unread_event_type, "lawyer_has_unread_updates": req.lawyer_has_unread_updates, @@ -295,6 +328,59 @@ def get_request_by_track( } +@router.get("/{track_number}/status-route") +def get_status_route_by_track( + track_number: str, + db: Session = Depends(get_db), + session: dict = Depends(get_public_session), +): + req = _request_for_track_or_404(db, session, track_number) + try: + payload = get_request_status_route_service( + str(req.id), + db, + {"role": "ADMIN", "sub": "", "email": "Клиент"}, + ) + payload["available_statuses"] = [] + return payload + except Exception: + current = str(req.status_code or "").strip() + changed_at = _to_iso(req.updated_at or req.created_at) + return { + "request_id": str(req.id), + "track_number": req.track_number, + "topic_code": req.topic_code, + "current_status": current or None, + "current_important_date_at": _to_iso(req.important_date_at), + "available_statuses": [], + "history": [ + { + "id": "current", + "from_status": None, + "to_status": current or None, + "to_status_name": current or None, + "changed_at": changed_at, + "important_date_at": _to_iso(req.important_date_at), + "comment": None, + "duration_seconds": None, + } + ], + "nodes": [ + { + "code": current or "", + "name": current or "-", + "kind": "DEFAULT", + "state": "current", + "changed_at": changed_at, + "sla_hours": None, + "note": "Текущий этап обработки заявки", + } + ] + if current + else [], + } + + @router.get("/{track_number}/messages", response_model=list[PublicMessageRead]) def list_messages_by_track( track_number: str, diff --git a/app/api/public/uploads.py b/app/api/public/uploads.py index 1f16996..d85ea95 100644 --- a/app/api/public/uploads.py +++ b/app/api/public/uploads.py @@ -12,6 +12,7 @@ from app.core.config import settings from app.core.deps import get_public_session from app.db.session import get_db from app.models.attachment import Attachment +from app.models.message import Message from app.models.request import Request from app.schemas.uploads import UploadCompletePayload, UploadCompleteResponse, UploadInitPayload, UploadInitResponse, UploadScope from app.services.notifications import EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT, notify_request_event @@ -189,9 +190,18 @@ def upload_complete( if int(request.total_attachments_bytes or 0) + actual_size > _max_case_bytes(): raise HTTPException(status_code=400, detail=f"Превышен лимит вложений заявки ({settings.MAX_CASE_MB} МБ)") + message_uuid = None + if payload.message_id: + message_uuid = _uuid_or_400(payload.message_id, "message_id") + message = db.get(Message, message_uuid) + if message is None or message.request_id != request.id: + raise HTTPException(status_code=400, detail="Сообщение не найдено для указанной заявки") + if bool(message.immutable): + raise HTTPException(status_code=400, detail="Нельзя прикрепить файл к зафиксированному сообщению") + row = Attachment( request_id=request.id, - message_id=None, + message_id=message_uuid, file_name=payload.file_name, mime_type=payload.mime_type, size_bytes=actual_size, diff --git a/app/core/config.py b/app/core/config.py index 4822b4f..6649297 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -35,6 +35,7 @@ class Settings(BaseSettings): OTP_RATE_LIMIT_WINDOW_SECONDS: int = 300 OTP_SEND_RATE_LIMIT: int = 8 OTP_VERIFY_RATE_LIMIT: int = 20 + OTP_DEV_MODE: bool = False ADMIN_BOOTSTRAP_ENABLED: bool = True ADMIN_BOOTSTRAP_EMAIL: str = "admin@example.com" ADMIN_BOOTSTRAP_PASSWORD: str = "admin123" diff --git a/app/services/sms_service.py b/app/services/sms_service.py index e81b9c1..19e3d09 100644 --- a/app/services/sms_service.py +++ b/app/services/sms_service.py @@ -11,6 +11,10 @@ class SmsDeliveryError(Exception): pass +def _otp_dev_mode_enabled() -> bool: + return bool(getattr(settings, "OTP_DEV_MODE", False)) + + def _module_available(module_name: str) -> bool: return importlib.util.find_spec(module_name) is not None @@ -111,11 +115,26 @@ def _get_sms_aero_balance() -> tuple[float | None, dict[str, Any] | None, str | def sms_provider_health() -> dict[str, Any]: provider = str(settings.SMS_PROVIDER or "dummy").strip().lower() + if _otp_dev_mode_enabled(): + return { + "provider": provider or "dummy", + "effective_provider": "mock_sms", + "status": "ok", + "mode": "mock", + "dev_mode": True, + "can_send": True, + "balance_available": False, + "balance_amount": None, + "balance_currency": "RUB", + "checks": {"otp_dev_mode": True}, + "issues": ["OTP_DEV_MODE включен: реальная SMS-рассылка отключена"], + } if provider in {"", "dummy", "mock", "console"}: return { "provider": "dummy", "status": "ok", "mode": "mock", + "dev_mode": False, "can_send": True, "balance_available": False, "balance_amount": None, @@ -156,6 +175,7 @@ def sms_provider_health() -> dict[str, Any]: "provider": "smsaero", "status": "ok" if can_send and balance_available else "degraded", "mode": "real", + "dev_mode": False, "can_send": can_send, "balance_available": balance_available, "balance_amount": balance_amount, @@ -169,6 +189,7 @@ def sms_provider_health() -> dict[str, Any]: "provider": provider, "status": "error", "mode": "unknown", + "dev_mode": False, "can_send": False, "balance_available": False, "balance_amount": None, @@ -179,6 +200,11 @@ def sms_provider_health() -> dict[str, Any]: def send_otp_message(*, phone: str, code: str, purpose: str, track_number: str | None = None) -> dict[str, Any]: + if _otp_dev_mode_enabled(): + payload = _mock_sms_send(phone=phone, code=code, purpose=purpose, track_number=track_number) + payload["dev_mode"] = True + return payload + provider = str(settings.SMS_PROVIDER or "dummy").strip().lower() if provider in {"", "dummy", "mock", "console"}: return _mock_sms_send(phone=phone, code=code, purpose=purpose, track_number=track_number) diff --git a/app/web/admin/features/requests/RequestWorkspace.jsx b/app/web/admin/features/requests/RequestWorkspace.jsx index 0a3af79..db76820 100644 --- a/app/web/admin/features/requests/RequestWorkspace.jsx +++ b/app/web/admin/features/requests/RequestWorkspace.jsx @@ -38,8 +38,11 @@ export function RequestWorkspace({ onLoadRequestDataTemplateDetails, onSaveRequestDataTemplate, onSaveRequestDataBatch, + onSaveRequestDataValues, + onUploadRequestAttachment, onChangeStatus, onConsumePendingStatusChangePreset, + domIds, AttachmentPreviewModalComponent, StatusLineComponent, }) { @@ -85,8 +88,33 @@ export function RequestWorkspace({ templateStatus: "", error: "", }); + const [clientDataModal, setClientDataModal] = useState({ + open: false, + loading: false, + saving: false, + messageId: "", + items: [], + status: "", + error: "", + }); const fileInputRef = useRef(null); const statusChangeFileInputRef = useRef(null); + const idMap = useMemo( + () => ({ + messagesList: "request-modal-messages", + filesList: "request-modal-files", + messageBody: "request-modal-message-body", + sendButton: "request-modal-message-send", + fileInput: "request-modal-file-input", + fileUploadButton: "", + dataRequestOverlay: "data-request-overlay", + dataRequestItems: "data-request-items", + dataRequestStatus: "data-request-status", + dataRequestSave: "data-request-save", + ...(domIds || {}), + }), + [domIds] + ); const requestDataTypeOptions = useMemo( () => [ { value: "string", label: "Строка" }, @@ -130,6 +158,7 @@ export function RequestWorkspace({ const finance = financeSummary && typeof financeSummary === "object" ? financeSummary : null; const viewerRoleCode = String(viewerRole || "").toUpperCase(); const canRequestData = viewerRoleCode === "LAWYER" || viewerRoleCode === "ADMIN"; + const canFillRequestData = viewerRoleCode === "CLIENT"; const canSeeRate = viewerRoleCode !== "CLIENT"; const safeMessages = Array.isArray(messages) ? messages : []; const safeAttachments = Array.isArray(attachments) ? attachments : []; @@ -142,6 +171,7 @@ export function RequestWorkspace({ const lawyerPhone = String(row?.assigned_lawyer_phone || "").trim(); const clientHasPhone = Boolean(clientPhone); const lawyerHasPhone = Boolean(lawyerPhone); + const messagePlaceholder = canFillRequestData ? "Введите сообщение для юриста" : "Введите сообщение для клиента"; const selectedRequestTemplateCandidate = useMemo( () => @@ -692,6 +722,123 @@ export function RequestWorkspace({ } }; + const closeClientDataModal = () => { + setClientDataModal({ + open: false, + loading: false, + saving: false, + messageId: "", + items: [], + status: "", + error: "", + }); + }; + + const openClientDataRequestModal = async (messageId) => { + if (!canFillRequestData || typeof onLoadRequestDataBatch !== "function" || !messageId) return; + setClientDataModal({ + open: true, + loading: true, + saving: false, + messageId: String(messageId), + items: [], + status: "", + error: "", + }); + try { + const data = await onLoadRequestDataBatch(String(messageId)); + const items = Array.isArray(data?.items) + ? data.items + .slice() + .sort((a, b) => Number(a?.sort_order || 0) - Number(b?.sort_order || 0)) + .map((item, index) => ({ + localId: "client-data-" + String(item?.id || item?.key || index), + id: String(item?.id || ""), + key: String(item?.key || ""), + label: String(item?.label || item?.key || "Поле"), + field_type: String(item?.field_type || "string").toLowerCase(), + value_text: item?.value_text == null ? "" : String(item.value_text), + value_file: item?.value_file || null, + pendingFile: null, + })) + : []; + setClientDataModal((prev) => ({ ...prev, loading: false, items })); + } catch (error) { + setClientDataModal((prev) => ({ ...prev, loading: false, error: error?.message || "Не удалось открыть запрос данных" })); + } + }; + + const updateClientDataItem = (localId, patch) => { + setClientDataModal((prev) => ({ + ...prev, + status: "", + error: "", + items: (prev.items || []).map((item) => (item.localId === localId ? { ...item, ...(patch || {}) } : item)), + })); + }; + + const submitClientDataModal = async (event) => { + if (event && typeof event.preventDefault === "function") event.preventDefault(); + if (!canFillRequestData || typeof onSaveRequestDataValues !== "function") return; + const currentMessageId = String(clientDataModal.messageId || "").trim(); + if (!currentMessageId) return; + setClientDataModal((prev) => ({ ...prev, saving: true, status: "", error: "" })); + try { + const payloadItems = []; + for (const item of clientDataModal.items || []) { + const fieldType = String(item?.field_type || "string").toLowerCase(); + if (fieldType === "file") { + let attachmentId = String(item?.value_text || "").trim(); + if (item?.pendingFile) { + if (typeof onUploadRequestAttachment !== "function") { + throw new Error("Загрузка файла для поля недоступна"); + } + const uploadResult = await onUploadRequestAttachment(item.pendingFile, { + source: "data_request", + message_id: currentMessageId, + key: String(item?.key || ""), + }); + attachmentId = String( + (uploadResult && (uploadResult.attachment_id || uploadResult.id || uploadResult.value || uploadResult)) || "" + ).trim(); + if (!attachmentId) throw new Error("Не удалось сохранить файл для поля запроса"); + } + payloadItems.push({ + id: String(item?.id || ""), + key: String(item?.key || ""), + attachment_id: attachmentId || "", + value_text: attachmentId || "", + }); + continue; + } + payloadItems.push({ + id: String(item?.id || ""), + key: String(item?.key || ""), + value_text: String(item?.value_text || ""), + }); + } + await onSaveRequestDataValues({ + message_id: currentMessageId, + items: payloadItems, + }); + setClientDataModal((prev) => ({ + ...prev, + saving: false, + status: "Данные сохранены.", + items: (prev.items || []).map((item) => ({ + ...item, + pendingFile: null, + })), + })); + } catch (error) { + setClientDataModal((prev) => ({ + ...prev, + saving: false, + error: error?.message || "Не удалось сохранить данные", + })); + } + }; + const handleRequestRowDragStart = (event, rowItem, rowLocked) => { if (rowLocked || dataRequestModal.loading || dataRequestModal.saving || dataRequestModal.savingTemplate) { event.preventDefault(); @@ -1032,7 +1179,7 @@ export function RequestWorkspace({ -