from fastapi import FastAPI, HTTPException, BackgroundTasks, Request, Query from fastapi.responses import JSONResponse, Response, StreamingResponse from starlette.staticfiles import StaticFiles from starlette.requests import Request as StarletteRequest from pydantic import BaseModel, Field import unicodedata from typing import Dict, List, Optional, Any, Literal from datetime import datetime, timezone from fastapi import File, UploadFile, Form, Header, Body # type: ignore from src.auth_jwt import decode_access_token_user_id, decode_bearer_token from src.admin_audit_routes import router as admin_audit_router from src.admin_user_profile_routes import router as admin_user_profile_router from src.template_routes import router as template_router from src.research_routes import router as research_router from src.imagehub_routes import router as imagehub_router from fastapi.middleware.cors import CORSMiddleware # type: ignore from src.utils import initialize_a_logger import ollama import numpy as np from src.internal_control.it_governance.document_io import DocumentIO np.random.seed(42) from pathlib import Path import uuid import json import hashlib import asyncio from enum import Enum from pydantic import BaseModel, Field, validator import os import subprocess import sys import yaml # Import the workflow (assuming it's in rm_workflow.py) from langgraph.graph import StateGraph, START, END from typing import TypedDict, Literal import numpy as np from src.compliance_verifier import Compliance_Verifier, ComplianceRequest, PromptRequest from src.chat_assistant import ChatAssistant, ChatRequest, ChatResponse, get_chat_assistant from src.auth_api import router as auth_api_router # Re-define the state and workflow components for FastAPI class RMIntegrationState(TypedDict): current_phase: str phase_number: int checklist_items: List[dict] completed_items: List[int] pending_approvals: List[str] records_officer_involved: bool project_status: str comments: dict validation_results: dict next_phase_ready: bool # Pydantic models for API requests/responses class TaskStatus(str, Enum): PENDING = "pending" IN_PROGRESS = "in_progress" COMPLETED = "completed" BLOCKED = "blocked" class WorkflowInitRequest(BaseModel): project_name: str = Field(..., description="Name of the project") project_description: Optional[str] = Field(None, description="Project description") records_officer_email: Optional[str] = Field(None, description="Records officer contact") class UpdateItemRequest(BaseModel): item_id: int = Field(..., description="ID of the checklist item to update") status: TaskStatus = Field(..., description="New status of the item") comment: Optional[str] = Field(None, description="Comment about the update") updated_by: Optional[str] = Field(None, description="Who updated the item") class ApprovalRequest(BaseModel): approval_type: str = Field(..., description="Type of approval") approved: bool = Field(..., description="Whether approved or rejected") approver: str = Field(..., description="Who provided the approval") comment: Optional[str] = Field(None, description="Approval comment") class WorkflowResponse(BaseModel): workflow_id: str current_phase: str phase_number: int project_status: str completion_percentage: float pending_approvals: List[str] next_phase_ready: bool timestamp: str class StatusReport(BaseModel): workflow_id: str current_phase: str phase_number: int completion_percentage: float completed_items: int total_items: int pending_approvals: List[str] validation_results: Dict[str, str] project_status: str checklist_items: List[dict] timestamp: str # Chat Assistant Request Models class VerifyContentRequest(BaseModel): """Request model for content verification.""" field_name: str content: str verification_criteria: Optional[str] = None class PolicyQuestionRequest(BaseModel): """Request model for policy questions.""" question: str policy_context: Optional[str] = None # In-memory storage for workflows (in production, use a database) workflows_storage: Dict[str, Dict[str, Any]] = {} # try: # logger = initialize_a_logger() # logger.info("Logger initialized successfully") # Test it immediately # except Exception as e: # import logging # logging.basicConfig(level=logging.INFO) # logger = logging.getLogger() # logger.error(f"Logger initialization failed: {e}", exc_info=True) logger = initialize_a_logger('./logs/main.log') # FastAPI app initialization app = FastAPI( title="RM Integration SDLC Workflow API", description="API for managing Records Management integration into System Development Life Cycle", version="1.0.0" ) app.include_router(auth_api_router, prefix="/api/v1") app.include_router(admin_audit_router, prefix="/api/v1") app.include_router(admin_user_profile_router, prefix="/api/v1") app.include_router(template_router, prefix="/api/v1") app.include_router(research_router, prefix="/api/v1") app.include_router(imagehub_router, prefix="/api/v1") APP_ROOT_DIR = Path(__file__).resolve().parent def _resolved_frontend_public_dir() -> Path: """`assets` at repo root locally, or `/app/assets` when mounted in Docker.""" env = os.getenv("APPLICATION_REPORT_EXPORT_DIR") if env: return Path(env) app_assets = APP_ROOT_DIR / "assets" if app_assets.is_dir(): return app_assets.resolve() return (APP_ROOT_DIR.parent / "assets").resolve() def _resolved_application_draft_dir() -> Path: """ fe0/public/application-drafts when running from repo; `/app/assets/application-drafts` in Docker (see docker-compose `./assets:/app/assets`). Using APP_ROOT_DIR.parent/fe0 breaks inside Docker (parent is `/`, producing `/fe0/...`). """ env = os.getenv("APPLICATION_DRAFT_DIR") if env: return Path(env) fe0 = APP_ROOT_DIR.parent / "fe0" if fe0.is_dir(): return (fe0 / "public" / "application-drafts").resolve() return (APP_ROOT_DIR / "assets" / "application-drafts").resolve() FRONTEND_PUBLIC_DIR = _resolved_frontend_public_dir() APPLICATION_DRAFT_DIR = _resolved_application_draft_dir() def _load_application_draft_yaml(case_id: str) -> Optional[Dict[str, Any]]: """Load draft from disk; try current path and legacy Docker mis-resolved path.""" name = f"{case_id}.yml" candidates = [ APPLICATION_DRAFT_DIR / name, APP_ROOT_DIR.parent / "fe0" / "public" / "application-drafts" / name, Path("/fe0/public/application-drafts") / name, ] seen: set[str] = set() for path in candidates: try: key = str(path.resolve(strict=False)) except (OSError, RuntimeError): key = str(path) if key in seen: continue seen.add(key) if path.is_file(): with open(path, "r", encoding="utf-8") as handle: data = yaml.safe_load(handle) or {} if isinstance(data, dict): return data return None def _empty_applicant_draft_bundle(case_id: str) -> Dict[str, Any]: """ Client-generated case IDs can exist in sessionStorage before any POST save. DB reset / no YAML yet must not 404 — return the same shape as save/load. """ return { "caseId": case_id, "updatedAt": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), "tabs": {}, } class ApplicationDraftSaveRequest(BaseModel): caseId: Optional[str] = None tab: str data: Dict[str, Any] class ReviewDocumentSaveRequest(BaseModel): caseId: str = Field(..., min_length=1, max_length=128) officialBieuMau: Dict[str, Any] = Field(default_factory=dict) templateData: Optional[Dict[str, Any]] = None fullBundle: Optional[Dict[str, Any]] = None class ReviewDocumentUpdateRequest(BaseModel): officialBieuMau: Dict[str, Any] = Field(default_factory=dict) templateData: Optional[Dict[str, Any]] = None fullBundle: Optional[Dict[str, Any]] = None class PdfLayoutEditPayload(BaseModel): id: str = Field(default="", max_length=128) text: str = Field(default="", max_length=20_000) page: int = Field(default=1, ge=1, le=300) x: float = 0 y: float = 0 fontSize: float = Field(default=12, ge=1, le=256) lineHeight: float = Field(default=14, ge=1, le=512) boxWidth: float = Field(default=240, ge=1, le=5_000) letterSpacing: float = Field(default=0, ge=-50, le=200) textAlign: Literal["left", "center", "right"] = "left" fontName: Literal["TimesRoman", "TimesRomanBold", "Helvetica", "HelveticaBold"] = "TimesRoman" colorHex: str = Field(default="#111827", max_length=16) class UpdateSubmittedApplicationBody(BaseModel): """Applicant history panel: edit name + submission date (mirrors fe0 ApplicantHistoryCrudDialog).""" name: str = Field(..., min_length=1, max_length=500) submittedDate: str = Field(..., min_length=4, max_length=40) class CreateSubmittedApplicationBody(BaseModel): """Create a new shell record for applicant and immediately allocate `applicationId`.""" name: Optional[str] = Field(default=None, max_length=500) def _cors_allow_origins() -> List[str]: """Localhost defaults plus optional comma-separated `CORS_ORIGINS` (e.g. http://VM_IP:8081).""" base = [ "http://localhost:8081", "http://localhost:8080", "http://localhost:3000", "http://127.0.0.1:8081", "http://127.0.0.1:8080", "http://127.0.0.1:3000", ] extra = os.getenv("CORS_ORIGINS", "").strip() if not extra: return base merged = list(base) for part in extra.split(","): o = part.strip() if o and o not in merged: merged.append(o) return merged CORS_ALLOW_ORIGINS = _cors_allow_origins() if "*" in CORS_ALLOW_ORIGINS: raise RuntimeError("CORS_ORIGINS must not include '*' when allow_credentials=True") # document_parser = DocumentIO() app.add_middleware( CORSMiddleware, allow_origins=CORS_ALLOW_ORIGINS, allow_credentials=True, allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], allow_headers=["*"], expose_headers=["*"], ) @app.middleware("http") async def _credential_version_middleware(request: StarletteRequest, call_next): from src.auth_credential_middleware import auth_credential_version_middleware return await auth_credential_version_middleware(request, call_next) @app.middleware("http") async def _security_headers_middleware(request: StarletteRequest, call_next): response = await call_next(request) response.headers.setdefault("X-Content-Type-Options", "nosniff") response.headers.setdefault("X-Frame-Options", "DENY") response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") if os.getenv("ENVIRONMENT", "").lower() == "production": response.headers.setdefault( "Strict-Transport-Security", "max-age=31536000; includeSubDomains" ) return response @app.on_event("startup") async def _initiative_db_startup(): from src.initiative_db.engine import init_engine, is_postgres_enabled if is_postgres_enabled(): await init_engine() logger.info("Initiative PostgreSQL persistence enabled") mig_script = APP_ROOT_DIR / "scripts" / "apply_initiative_migrations.py" if mig_script.is_file(): try: proc = await asyncio.create_subprocess_exec( sys.executable, str(mig_script), cwd=str(APP_ROOT_DIR), env=os.environ.copy(), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) out, err = await asyncio.wait_for(proc.communicate(), timeout=120) if out.strip(): logger.info( "apply_initiative_migrations: %s", out.decode("utf-8", errors="replace").strip(), ) if err.strip(): logger.warning( "apply_initiative_migrations stderr: %s", err.decode("utf-8", errors="replace").strip(), ) if proc.returncode != 0: logger.warning( "apply_initiative_migrations exited code %s (e.g. missing registration_otp_codes)", proc.returncode, ) except Exception as exc: logger.warning("apply_initiative_migrations could not run: %s", exc) try: from src.minio.storage import S3Storage, settings as _s3s await S3Storage(_s3s).ensure_buckets_exist() logger.info("MinIO/S3 buckets ensured (attachments/exports/quarantine).") except Exception as exc: logger.warning("MinIO/S3 bucket init skipped (configure S3_* env to enable evidence uploads): %s", exc) @app.on_event("shutdown") async def _initiative_db_shutdown(): from src.initiative_db.engine import dispose_engine, is_postgres_enabled if is_postgres_enabled(): await dispose_engine() logger.info(f"parser start") Compliance_Verifier = Compliance_Verifier() Chat_Assistant = get_chat_assistant() # Import or redefine the workflow functions from the previous artifact def phase1_concept_development(state: RMIntegrationState) -> RMIntegrationState: """Phase 1: Concept Development - Initial records planning""" phase1_checklist = [ { "id": 1, "task": "Include Records Officer in system design process", "status": "pending", "requires_approval": True, "approver": "Records Officer" }, { "id": 2, "task": "Identify records that support the business process", "status": "pending", "requires_approval": False, "approver": None }, { "id": 3, "task": "Evaluate current record schedules applicability", "status": "pending", "requires_approval": False, "approver": None }, { "id": 4, "task": "Determine if new record schedule is required", "status": "pending", "requires_approval": False, "approver": None }, { "id": 5, "task": "Obtain Records Officer signature on Investment Summary Proposal", "status": "pending", "requires_approval": True, "approver": "Records Officer" } ] state["current_phase"] = "Concept Development" state["phase_number"] = 1 state["checklist_items"] = phase1_checklist state["pending_approvals"] = ["Records Officer - System Design", "Records Officer - Investment Summary"] return state def create_rm_integration_workflow(): """Simplified workflow creation for FastAPI""" # This would include all the phase functions from the previous artifact # For brevity, I'm showing the structure workflow = StateGraph(RMIntegrationState) # Add all nodes and edges as in the previous implementation return workflow @app.get("/") async def root(): """Root endpoint with API information""" return { "message": "RM Integration SDLC Workflow API", "version": "1.0.0", "endpoints": { "POST /workflows": "Create new workflow", "GET /workflows/{workflow_id}": "Get workflow status", "PUT /workflows/{workflow_id}/items": "Update checklist item", "POST /workflows/{workflow_id}/approvals": "Submit approval", "GET /workflows/{workflow_id}/report": "Get detailed status report", "POST /workflows/{workflow_id}/advance": "Advance to next phase", "GET /workflows": "List all workflows" } } @app.post("/workflows", response_model=WorkflowResponse) async def create_workflow(request: WorkflowInitRequest): """Create a new RM integration workflow""" workflow_id = str(uuid.uuid4()) # Initialize workflow state initial_state = { "current_phase": "", "phase_number": 0, "checklist_items": [], "completed_items": [], "pending_approvals": [], "records_officer_involved": False, "project_status": "Starting RM Integration Process", "comments": {}, "validation_results": {}, "next_phase_ready": False } # Start with Phase 1 state = phase1_concept_development(initial_state) # Store workflow workflows_storage[workflow_id] = { "state": state, "metadata": { "project_name": request.project_name, "project_description": request.project_description, "records_officer_email": request.records_officer_email, "created_at": datetime.now().isoformat(), "last_updated": datetime.now().isoformat() } } completed_count = len([item for item in state["checklist_items"] if item["status"] == "completed"]) total_count = len(state["checklist_items"]) completion_percentage = (completed_count / total_count * 100) if total_count > 0 else 0 return WorkflowResponse( workflow_id=workflow_id, current_phase=state["current_phase"], phase_number=state["phase_number"], project_status=state["project_status"], completion_percentage=completion_percentage, pending_approvals=state["pending_approvals"], next_phase_ready=state["next_phase_ready"], timestamp=datetime.now().isoformat() ) @app.get("/workflows/{workflow_id}", response_model=WorkflowResponse) async def get_workflow_status(workflow_id: str): """Get current workflow status""" if workflow_id not in workflows_storage: raise HTTPException(status_code=404, detail="Workflow not found") state = workflows_storage[workflow_id]["state"] completed_count = len([item for item in state["checklist_items"] if item["status"] == "completed"]) total_count = len(state["checklist_items"]) completion_percentage = (completed_count / total_count * 100) if total_count > 0 else 0 return WorkflowResponse( workflow_id=workflow_id, current_phase=state["current_phase"], phase_number=state["phase_number"], project_status=state["project_status"], completion_percentage=completion_percentage, pending_approvals=state["pending_approvals"], next_phase_ready=state["next_phase_ready"], timestamp=datetime.now().isoformat() ) @app.put("/workflows/{workflow_id}/items") async def update_checklist_item(workflow_id: str, request: UpdateItemRequest): """Update a checklist item status""" if workflow_id not in workflows_storage: raise HTTPException(status_code=404, detail="Workflow not found") state = workflows_storage[workflow_id]["state"] # Find and update the item item_found = False for item in state["checklist_items"]: if item["id"] == request.item_id: item["status"] = request.status.value if request.status == TaskStatus.COMPLETED and request.item_id not in state["completed_items"]: state["completed_items"].append(request.item_id) item_found = True break if not item_found: raise HTTPException(status_code=404, detail="Checklist item not found") # Add comment if provided if request.comment: state["comments"][request.item_id] = { "comment": request.comment, "updated_by": request.updated_by, "timestamp": datetime.now().isoformat() } # Update metadata workflows_storage[workflow_id]["metadata"]["last_updated"] = datetime.now().isoformat() return {"message": f"Item {request.item_id} updated successfully", "status": request.status.value} @app.post("/workflows/{workflow_id}/approvals") async def submit_approval(workflow_id: str, request: ApprovalRequest): """Submit an approval for the workflow""" if workflow_id not in workflows_storage: raise HTTPException(status_code=404, detail="Workflow not found") state = workflows_storage[workflow_id]["state"] # Remove approval from pending if approved if request.approved and request.approval_type in state["pending_approvals"]: state["pending_approvals"].remove(request.approval_type) # Log the approval approval_key = f"approval_{len(state.get('approval_log', []))}" if "approval_log" not in state: state["approval_log"] = [] state["approval_log"].append({ "approval_type": request.approval_type, "approved": request.approved, "approver": request.approver, "comment": request.comment, "timestamp": datetime.now().isoformat() }) # Update metadata workflows_storage[workflow_id]["metadata"]["last_updated"] = datetime.now().isoformat() return { "message": f"Approval {'granted' if request.approved else 'rejected'} for {request.approval_type}", "pending_approvals": state["pending_approvals"] } @app.get("/workflows/{workflow_id}/report", response_model=StatusReport) async def get_detailed_report(workflow_id: str): """Get detailed status report for a workflow""" if workflow_id not in workflows_storage: raise HTTPException(status_code=404, detail="Workflow not found") state = workflows_storage[workflow_id]["state"] metadata = workflows_storage[workflow_id]["metadata"] completed_count = len([item for item in state["checklist_items"] if item["status"] == "completed"]) total_count = len(state["checklist_items"]) completion_percentage = (completed_count / total_count * 100) if total_count > 0 else 0 return StatusReport( workflow_id=workflow_id, current_phase=state["current_phase"], phase_number=state["phase_number"], completion_percentage=completion_percentage, completed_items=completed_count, total_items=total_count, pending_approvals=state["pending_approvals"], validation_results=state["validation_results"], project_status=state["project_status"], checklist_items=state["checklist_items"], timestamp=datetime.now().isoformat() ) @app.post("/workflows/{workflow_id}/advance") async def advance_workflow(workflow_id: str): """Attempt to advance workflow to next phase""" if workflow_id not in workflows_storage: raise HTTPException(status_code=404, detail="Workflow not found") state = workflows_storage[workflow_id]["state"] # Validate current phase completion all_completed = True validation_results = {} for item in state["checklist_items"]: if item["status"] != "completed": all_completed = False validation_results[str(item["id"])] = f"Item {item['id']} not completed: {item['task']}" if state["pending_approvals"]: all_completed = False validation_results["approvals"] = f"Pending approvals: {', '.join(state['pending_approvals'])}" if not all_completed: state["validation_results"] = validation_results state["next_phase_ready"] = False return { "success": False, "message": "Cannot advance: Phase requirements not met", "validation_results": validation_results } # Advance to next phase current_phase = state["phase_number"] if current_phase >= 8: return { "success": False, "message": "Workflow already at final phase", "current_phase": state["current_phase"] } # Here you would call the appropriate next phase function # For now, just incrementing phase number as example state["phase_number"] += 1 state["current_phase"] = f"Phase {state['phase_number']}" state["project_status"] = f"Advanced to {state['current_phase']}" state["validation_results"] = {} state["next_phase_ready"] = True # Update metadata workflows_storage[workflow_id]["metadata"]["last_updated"] = datetime.now().isoformat() return { "success": True, "message": f"Advanced to {state['current_phase']}", "current_phase": state["current_phase"], "phase_number": state["phase_number"] } @app.get("/workflows") async def list_workflows(): """List all workflows with basic information""" workflows = [] for workflow_id, data in workflows_storage.items(): state = data["state"] metadata = data["metadata"] completed_count = len([item for item in state["checklist_items"] if item["status"] == "completed"]) total_count = len(state["checklist_items"]) completion_percentage = (completed_count / total_count * 100) if total_count > 0 else 0 workflows.append({ "workflow_id": workflow_id, "project_name": metadata["project_name"], "current_phase": state["current_phase"], "phase_number": state["phase_number"], "completion_percentage": completion_percentage, "created_at": metadata["created_at"], "last_updated": metadata["last_updated"] }) return {"workflows": workflows, "total_count": len(workflows)} @app.delete("/workflows/{workflow_id}") async def delete_workflow(workflow_id: str): """Delete a workflow""" if workflow_id not in workflows_storage: raise HTTPException(status_code=404, detail="Workflow not found") del workflows_storage[workflow_id] return {"message": f"Workflow {workflow_id} deleted successfully"} # Health check endpoint @app.get("/health") async def health_check(): """Health check endpoint""" # Check Ollama connectivity ollama_status = "unknown" try: import ollama models = ollama.list() ollama_status = "connected" available_models = [m.get("name", "") for m in models.get("models", [])] except Exception as e: ollama_status = f"error: {str(e)}" available_models = [] return { "status": "healthy", "timestamp": datetime.now().isoformat(), "active_workflows": len(workflows_storage), "ollama": { "status": ollama_status, "available_models": available_models } } # Test endpoint for connectivity @app.get("/api/v1/test") async def test_endpoint(): """Simple test endpoint to verify connectivity""" return { "message": "Backend is reachable", "timestamp": datetime.now().isoformat(), "status": "ok" } # Error handlers @app.exception_handler(ValueError) async def value_error_handler(request, exc): return JSONResponse( status_code=400, content={"detail": str(exc)} ) @app.post("/test_ollama") async def test_ollama(req: PromptRequest, authorization: Optional[str] = Header(None)): _require_admin_user(authorization) try: response = ollama.chat( model="qwen2.5:3b", messages=[{'role': 'user', 'content': req.prompt}], options={"temperature": 0.0}, ) return {"oss_json": response["message"]["content"]} except HTTPException as e: return {"oss_json": str(e)} except HTTPException as e: return {"oss_json": str(e)} @app.post("/test_ollama_1") async def test_ollama_1(req: PromptRequest, authorization: Optional[str] = Header(None)): _require_admin_user(authorization) result = await Compliance_Verifier.generate_text(req) return result @app.post("/test_ollama_similarity") async def vectorize_requirement(req: PromptRequest, authorization: Optional[str] = Header(None)): _require_admin_user(authorization) result = await Compliance_Verifier.vectorize_requirement(req) return result @app.post("/analyze_compliance") async def semantic_similarity( data: ComplianceRequest, authorization: Optional[str] = Header(None) ) -> Dict[str, Any]: _require_authenticated_user(authorization) result = await Compliance_Verifier.semantic_similarity(data) return result @app.post("/analyze_structure") async def structure_similarity( data: ComplianceRequest, authorization: Optional[str] = Header(None) ) -> Dict[str, Any]: _require_authenticated_user(authorization) result = await Compliance_Verifier.structural_similarity(data) return result # Chat Assistant Endpoints @app.options("/api/v1/chat") async def chat_options(request: StarletteRequest): """Handle CORS preflight for chat endpoint""" origin = request.headers.get("origin", "http://localhost:8080") allow_origin = origin if origin in CORS_ALLOW_ORIGINS else "http://localhost:8080" return Response( status_code=200, headers={ "Access-Control-Allow-Origin": allow_origin, "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization", "Access-Control-Allow-Credentials": "true", } ) @app.post("/api/v1/chat", response_model=ChatResponse) async def chat_endpoint(request: ChatRequest, authorization: Optional[str] = Header(None)): """ Chat endpoint for conversational AI assistant. Handles policy questions and general compliance queries. """ _require_authenticated_user(authorization) try: logger.info(f"Chat endpoint called with message: {request.message[:50] if request.message else 'Empty'}...") logger.debug(f"Full request: message={request.message}, has_history={bool(request.conversation_history)}, context={request.context}") # Validate request if not request.message or not request.message.strip(): raise HTTPException(status_code=400, detail="Message cannot be empty") response = await Chat_Assistant.chat(request) logger.info(f"Chat response generated successfully: {response.message[:50] if response.message else 'Empty'}...") return response except HTTPException as he: logger.error(f"HTTPException in chat endpoint: {he.status_code} - {he.detail}") raise except Exception as e: logger.error(f"Unexpected error in chat endpoint: {e}", exc_info=True) import traceback logger.error(f"Full traceback: {traceback.format_exc()}") raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") @app.post("/api/v1/chat/verify", response_model=ChatResponse) async def verify_content_endpoint( request: VerifyContentRequest, authorization: Optional[str] = Header(None) ): """ Verify content against compliance requirements. """ _require_authenticated_user(authorization) try: response = await Chat_Assistant.verify_content( field_name=request.field_name, content=request.content, verification_criteria=request.verification_criteria ) return response except HTTPException: raise except Exception as e: logger.error(f"Error in verify endpoint: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @app.post("/api/v1/chat/question", response_model=ChatResponse) async def answer_policy_question( request: PolicyQuestionRequest, authorization: Optional[str] = Header(None) ): """ Answer a policy or compliance question. """ _require_authenticated_user(authorization) try: response = await Chat_Assistant.answer_policy_question( question=request.question, policy_context=request.policy_context ) return response except HTTPException: raise except Exception as e: logger.error(f"Error in question endpoint: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) # Idea Management Endpoints class IdeaRequest(BaseModel): title: str = Field(..., description="Title of the idea") description: str = Field(..., description="Description of the idea") category: Optional[str] = Field(None, description="Category of the idea") class IdeaSearchRequest(BaseModel): query: str = Field(..., description="Search query text") limit: Optional[int] = Field(5, description="Maximum number of results") score_threshold: Optional[float] = Field(0.5, description="Minimum similarity score") # Initialize Qdrant collection on first API call (lazy initialization) @app.post("/api/v1/ideas") async def add_idea(request: IdeaRequest, authorization: Optional[str] = Header(None)): """Add a new idea to the vector database""" _require_admin_user(authorization) try: from src.infrastructure.vector_db.qdrant_service import get_qdrant_service qdrant_service = get_qdrant_service() # Ensure collection is initialized await qdrant_service.initialize_collection() result = await qdrant_service.add_idea( title=request.title, description=request.description, category=request.category ) return result except Exception as e: logger.error(f"Error adding idea: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @app.post("/api/v1/ideas/search") async def search_ideas(request: IdeaSearchRequest, authorization: Optional[str] = Header(None)): """Search for similar ideas using vector similarity""" _require_authenticated_user(authorization) try: from src.infrastructure.vector_db.qdrant_service import get_qdrant_service qdrant_service = get_qdrant_service() # Ensure collection is initialized await qdrant_service.initialize_collection() results = await qdrant_service.search_similar_ideas( query_text=request.query, limit=request.limit or 5, score_threshold=request.score_threshold or 0.5 ) return {"results": results, "count": len(results)} except Exception as e: logger.error(f"Error searching ideas: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @app.get("/api/v1/ideas") async def get_all_ideas(limit: int = 100, authorization: Optional[str] = Header(None)): """Get all ideas from the database""" _require_admin_user(authorization) try: from src.infrastructure.vector_db.qdrant_service import get_qdrant_service qdrant_service = get_qdrant_service() ideas = await qdrant_service.get_all_ideas(limit=limit) return {"ideas": ideas, "count": len(ideas)} except Exception as e: logger.error(f"Error getting ideas: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @app.delete("/api/v1/ideas/{idea_id}") async def delete_idea(idea_id: str, authorization: Optional[str] = Header(None)): """Delete an idea from the database""" _require_admin_user(authorization) try: from src.infrastructure.vector_db.qdrant_service import get_qdrant_service qdrant_service = get_qdrant_service() success = await qdrant_service.delete_idea(idea_id) if success: return {"message": "Idea deleted successfully", "id": idea_id} else: raise HTTPException(status_code=404, detail="Idea not found") except HTTPException: raise except Exception as e: logger.error(f"Error deleting idea: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @app.post("/api/v1/ideas/bulk-add") async def bulk_add_ideas(ideas: List[IdeaRequest], authorization: Optional[str] = Header(None)): """Add multiple ideas at once""" _require_admin_user(authorization) if len(ideas) > 50: raise HTTPException(status_code=422, detail="Tối đa 50 ý tưởng mỗi lần.") try: from src.infrastructure.vector_db.qdrant_service import get_qdrant_service qdrant_service = get_qdrant_service() # Ensure collection is initialized await qdrant_service.initialize_collection() results = [] for idea in ideas: result = await qdrant_service.add_idea( title=idea.title, description=idea.description, category=idea.category ) results.append(result) return {"results": results, "count": len(results)} except Exception as e: logger.error(f"Error bulk adding ideas: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @app.post("/api/v1/ideas/initialize-ump") async def initialize_ump_ideas(authorization: Optional[str] = Header(None)): """Initialize database with the 10 UMP innovation ideas""" _require_admin_user(authorization) ump_ideas = [ IdeaRequest( title="Nền tảng Trợ lý AI học tập lâm sàng (Clinical AI Tutor)", description="Ứng dụng AI đóng vai trò trợ giảng cho sinh viên y, hỗ trợ phân tích ca bệnh giả lập, giải thích cận lâm sàng, và gợi ý chẩn đoán theo phác đồ Việt Nam.", category="Giáo dục - AI" ), IdeaRequest( title="Hệ thống bệnh án điện tử học thuật (Academic EMR Sandbox)", description="Môi trường EMR mô phỏng cho đào tạo và nghiên cứu, cho phép sinh viên và giảng viên thực hành nhập – phân tích – khai thác dữ liệu y khoa mà không ảnh hưởng dữ liệu bệnh nhân thật.", category="Giáo dục - Chuyển đổi số" ), IdeaRequest( title="Trung tâm mô phỏng y khoa bằng AR/VR & Digital Twin", description="Xây dựng phòng lab mô phỏng phẫu thuật, cấp cứu, và quy trình điều trị bằng AR/VR, kết hợp mô hình \"digital twin\" của cơ thể người phục vụ đào tạo nâng cao.", category="Giáo dục - AR/VR" ), IdeaRequest( title="Chương trình Y tế cộng đồng số cho vùng sâu vùng xa", description="Kết hợp telehealth, trợ lý ảo y tế (agentic care) và AI sàng lọc sớm bệnh không lây (NCD) cho người dân vùng nông thôn, miền núi và hải đảo.", category="Tác động xã hội - Telehealth" ), IdeaRequest( title="Nền tảng nghiên cứu AI y sinh dùng chung (UMP AI Research Hub)", description="Cung cấp hạ tầng GPU, kho dữ liệu y khoa ẩn danh, và công cụ phân tích AI cho giảng viên – nghiên cứu sinh – startup hợp tác nghiên cứu.", category="Nghiên cứu - AI" ), IdeaRequest( title="Hệ thống theo dõi và dự báo sức khỏe sinh viên & nhân viên y tế", description="Ứng dụng phân tích dữ liệu và AI để phát hiện sớm stress, burnout, và vấn đề sức khỏe tâm thần trong cộng đồng sinh viên và nhân viên y tế.", category="Tác động xã hội - Sức khỏe" ), IdeaRequest( title="Vườn ươm khởi nghiệp công nghệ y sinh (MedTech Incubator)", description="Hỗ trợ sinh viên, bác sĩ và giảng viên phát triển startup MedTech, HealthTech, AI y tế thông qua mentoring, quỹ seed và kết nối bệnh viện – doanh nghiệp.", category="Khởi nghiệp - MedTech" ), IdeaRequest( title="Hệ thống quản lý chất lượng đào tạo và kiểm định số", description="Số hóa toàn bộ quy trình đảm bảo chất lượng nội bộ (IQA), đánh giá chương trình đào tạo, và chuẩn hóa theo tiêu chuẩn quốc tế (WFME, AUN-QA).", category="Giáo dục - Quản lý chất lượng" ), IdeaRequest( title="Nền tảng dữ liệu lớn phòng chống dịch và bệnh không lây", description="Phân tích dữ liệu dịch tễ, môi trường, và hành vi để dự báo dịch bệnh, hỗ trợ Sở Y tế và Bộ Y tế trong ra quyết định chính sách.", category="Nghiên cứu - Dịch tễ học" ), IdeaRequest( title="Học viện Y học chính xác & Y học cá thể hóa", description="Kết hợp dữ liệu gen, hình ảnh y khoa, lối sống và AI để nghiên cứu và ứng dụng điều trị cá thể hóa cho bệnh ung thư, tim mạch và bệnh mạn tính.", category="Nghiên cứu - Y học chính xác" ), ] try: from src.infrastructure.vector_db.qdrant_service import get_qdrant_service qdrant_service = get_qdrant_service() # Ensure collection is initialized await qdrant_service.initialize_collection() results = [] for idea in ump_ideas: result = await qdrant_service.add_idea( title=idea.title, description=idea.description, category=idea.category ) results.append(result) return {"results": results, "count": len(results), "message": f"Successfully added {len(results)} UMP ideas"} except Exception as e: logger.error(f"Error initializing UMP ideas: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @app.post("/api/v1/application-reports/excel") async def save_application_report_excel(caseId: str = Form(...), file: UploadFile = File(...)): """ Save uploaded application Excel file to shared assets folder so admin can review it. """ if not caseId.strip(): raise HTTPException(status_code=400, detail="caseId is required") FRONTEND_PUBLIC_DIR.mkdir(parents=True, exist_ok=True) safe_case_id = "".join(ch for ch in caseId if ch.isalnum() or ch in ("-", "_")) if not safe_case_id: raise HTTPException(status_code=400, detail="Invalid caseId") target_name = f"{safe_case_id}.xlsx" target_path = FRONTEND_PUBLIC_DIR / target_name content = await file.read() with open(target_path, "wb") as output: output.write(content) return { "caseId": safe_case_id, "fileName": target_name, "savedPath": str(target_path), "publicUrl": f"/assets/{target_name}", } @app.get("/api/v1/application-reports") async def list_application_reports(): """ List saved report files from shared assets folder. """ FRONTEND_PUBLIC_DIR.mkdir(parents=True, exist_ok=True) files = [] for path in sorted(FRONTEND_PUBLIC_DIR.glob("*.xlsx"), key=lambda p: p.stat().st_mtime, reverse=True): stat = path.stat() files.append( { "fileName": path.name, "publicUrl": f"/assets/{path.name}", "sizeBytes": stat.st_size, "updatedAt": datetime.fromtimestamp(stat.st_mtime).isoformat(), } ) return {"files": files} def _normalize_case_id(case_id: Optional[str]) -> str: raw = case_id or f"CASE-{int(datetime.now().timestamp() * 1000)}" safe = "".join(ch for ch in raw if ch.isalnum() or ch in ("-", "_")) if not safe: raise HTTPException(status_code=400, detail="Invalid caseId") return safe def _draft_path(case_id: str) -> Path: APPLICATION_DRAFT_DIR.mkdir(parents=True, exist_ok=True) return APPLICATION_DRAFT_DIR / f"{case_id}.yml" @app.post("/api/v1/application-drafts") async def save_application_draft( request: ApplicationDraftSaveRequest, authorization: Optional[str] = Header(None), ): from src.initiative_db.engine import get_session, is_postgres_enabled from src.initiative_db.drafts import save_application_draft_tab case_id = _normalize_case_id(request.caseId) owner_uid = decode_access_token_user_id(authorization) if is_postgres_enabled(): try: async with get_session() as session: return await save_application_draft_tab( session, case_id, request.tab, request.data, owner_id=owner_uid ) except HTTPException: raise except Exception as e: logger.exception("application draft save (PostgreSQL) failed") raise HTTPException(status_code=500, detail="Failed to persist draft") from e target = _draft_path(case_id) current: Dict[str, Any] = { "caseId": case_id, "updatedAt": datetime.now().isoformat(), "tabs": {}, } if target.exists(): with open(target, "r", encoding="utf-8") as handle: loaded = yaml.safe_load(handle) or {} if isinstance(loaded, dict): current.update(loaded) current["tabs"] = dict(loaded.get("tabs") or {}) current["caseId"] = case_id current["updatedAt"] = datetime.now().isoformat() current["tabs"][request.tab] = request.data with open(target, "w", encoding="utf-8") as handle: yaml.safe_dump(current, handle, allow_unicode=True, sort_keys=False) return { "caseId": case_id, "updatedAt": current["updatedAt"], "tabs": current["tabs"], "publicUrl": f"/application-drafts/{case_id}.yml", } @app.get("/api/v1/application-drafts/{case_id}") async def get_application_draft(case_id: str): from sqlalchemy.exc import IntegrityError from src.initiative_db.drafts import ( get_application_draft_document, insert_initiative_draft_if_missing, ) from src.initiative_db.engine import get_session, is_postgres_enabled from src.initiative_db.submissions import merge_application_draft_document_with_snapshot_if_needed safe_case_id = _normalize_case_id(case_id) if is_postgres_enabled(): try: async with get_session() as session: from src.initiative_db.submissions import resolve_initiative_for_draft_case_key ini_res = await resolve_initiative_for_draft_case_key(session, case_id) if ini_res is not None: safe_case_id = ini_res.case_code except Exception: logger.exception("resolve initiative for application draft GET failed; using path case id") yaml_fallback = _load_application_draft_yaml(safe_case_id) if is_postgres_enabled(): try: async with get_session() as session: try: doc = await get_application_draft_document(session, safe_case_id) return await merge_application_draft_document_with_snapshot_if_needed(session, safe_case_id, doc) except KeyError: pass if yaml_fallback is None: return _empty_applicant_draft_bundle(safe_case_id) try: await insert_initiative_draft_if_missing(session, safe_case_id, yaml_fallback) except IntegrityError: await session.rollback() async with get_session() as session: try: doc = await get_application_draft_document(session, safe_case_id) return await merge_application_draft_document_with_snapshot_if_needed(session, safe_case_id, doc) except KeyError: return yaml_fallback except HTTPException: raise except Exception as e: logger.exception("application draft load (PostgreSQL) failed") raise HTTPException(status_code=500, detail="Failed to load draft") from e if yaml_fallback is not None: return yaml_fallback return _empty_applicant_draft_bundle(safe_case_id) def _evidence_kind_to_role(kind: object) -> Optional[str]: """ Map API kind query/form value → storage role. Accepts str or list (duplicate ``kind=`` keys); strips BOM / ZWSP; NFKC-normalizes Unicode so lookalike Latin cannot bypass the allow-list. """ if kind is None: return None if isinstance(kind, (list, tuple)): candidates = [str(x) for x in kind if x is not None and str(x).strip() != ""] else: candidates = [str(kind)] for c in candidates: k = unicodedata.normalize("NFKC", (c or "").strip()) k = k.replace("\ufeff", "").replace("\u200b", "").strip().lower() if k == "research": return "research_evidence" if k == "textbook": return "textbook_evidence" if k == "technical": return "technical_evidence" return None def _evidence_role_to_api_kind(role: str) -> str: if role == "research_evidence": return "research" if role == "textbook_evidence": return "textbook" if role == "technical_evidence": return "technical" return "research" def _evidence_row_looks_like_pdf(row, default_name: str) -> bool: """True when the stored artifact should be shown with an inline PDF presign.""" mt = (row.mime_type or "").lower() if "pdf" in mt: return True dn = (default_name or "").lower() return dn.endswith(".pdf") def _jwt_role_strings(authorization: Optional[str]) -> list[str]: p = decode_bearer_token(authorization) if not p: return [] r = p.get("roles") if isinstance(r, list): return [str(x) for x in r] return [] def _is_staff_reviewer(authorization: Optional[str]) -> bool: roles = _jwt_role_strings(authorization) return "admin" in roles or "editor" in roles def _require_admin_user(authorization: Optional[str]) -> uuid.UUID: """JWT must be valid and include role ``admin``.""" uid = decode_access_token_user_id(authorization) if uid is None: raise HTTPException(status_code=401, detail="Đăng nhập để thực hiện thao tác.") roles = _jwt_role_strings(authorization) if "admin" not in roles: raise HTTPException(status_code=403, detail="Chỉ tài khoản quản trị mới thực hiện được.") return uid def _require_authenticated_user(authorization: Optional[str]) -> uuid.UUID: uid = decode_access_token_user_id(authorization) if uid is None: raise HTTPException(status_code=401, detail="Đăng nhập để thực hiện thao tác.") return uid def _require_staff_reviewer(authorization: Optional[str]) -> uuid.UUID: uid = _require_authenticated_user(authorization) if not _is_staff_reviewer(authorization): raise HTTPException(status_code=403, detail="Không có quyền truy cập.") return uid async def _assert_initiative_case_access( session: Any, case_id: str, uid: uuid.UUID, authorization: Optional[str], ) -> None: """Allow staff or initiative owner for the normalized case code.""" from sqlalchemy import select from src.initiative_db.models import Initiative normalized = _normalize_case_id(case_id) ini = (await session.execute(select(Initiative).where(Initiative.case_code == normalized))).scalar_one_or_none() if ini is None: return if ini.owner_id == uid or _is_staff_reviewer(authorization): return raise HTTPException(status_code=403, detail="Không có quyền xem hồ sơ này.") async def _assert_review_document_access( session: Any, review_document_id: str, uid: uuid.UUID, authorization: Optional[str], ) -> None: from src.initiative_db.models import ApplicationReviewDocument, Initiative try: rid = uuid.UUID(str(review_document_id)) except ValueError as exc: raise HTTPException(status_code=404, detail="Không tìm thấy review document") from exc doc = await session.get(ApplicationReviewDocument, rid) if doc is None: raise HTTPException(status_code=404, detail="Không tìm thấy review document") ini = await session.get(Initiative, doc.initiative_id) if ini is not None and ini.owner_id != uid and not _is_staff_reviewer(authorization): raise HTTPException(status_code=403, detail="Không có quyền xem hồ sơ này.") class AdminApplicationResultBody(BaseModel): decision: Literal["approved", "rejected"] feedback: str = Field(default="", max_length=50_000) rationale: Optional[str] = Field(default=None, max_length=50_000) def _initiative_allows_owner_evidence_edit(status: str) -> bool: s = (status or "").strip().lower() return s not in ("approved", "rejected") def _normalize_pdf_layout_edits(raw: Any) -> list[Dict[str, Any]]: if not isinstance(raw, list): raise HTTPException(status_code=422, detail="layoutEdits phải là mảng.") if len(raw) > 200: raise HTTPException(status_code=422, detail="Tối đa 200 mục chỉnh bố cục.") out: list[Dict[str, Any]] = [] for idx, item in enumerate(raw): if not isinstance(item, dict): raise HTTPException(status_code=422, detail=f"layoutEdits[{idx}] không hợp lệ.") try: parsed = PdfLayoutEditPayload(**item) except Exception as exc: raise HTTPException(status_code=422, detail=f"layoutEdits[{idx}] lỗi định dạng: {exc}") from exc row = parsed.model_dump() if not str(row.get("text") or "").strip(): continue out.append(row) return out def _load_minio_for_evidence(): """Returns (storage, bucket_name, err_msg). On failure, storage is None.""" try: from src.minio.storage import S3Storage, settings as s3settings return S3Storage(), s3settings.s3_bucket_attachments, None except Exception as exc: logger.warning("MinIO / S3 not available for evidence upload: %s", exc) return None, None, str(exc) def _load_minio_for_exports(): """Returns (storage, bucket_name, err_msg). On failure, storage is None.""" try: from src.minio.storage import S3Storage, settings as s3settings return S3Storage(), s3settings.s3_bucket_exports, None except Exception as exc: logger.warning("MinIO / S3 not available for export upload: %s", exc) return None, None, str(exc) @app.post("/api/v1/application-drafts/{case_id}/evidence") async def upload_application_draft_evidence( case_id: str, kind: str = Form(..., description="research | textbook | technical (Minh chứng 2.1 / 2.2 / kỹ thuật)"), file: UploadFile = File(...), authorization: Optional[str] = Header(None), ): """ Tải minh chứng (PDF, hình, Word, Excel, …) lên MinIO; chủ sở hữu, trừ hồ sơ đã approved/rejected. """ from src.initiative_db.application_storage import ( get_evidence_artifact_row, upsert_evidence_artifact, ) from src.initiative_db.engine import get_session, is_postgres_enabled from src.minio.storage import ALLOWED_MIME_TYPES, StorageError role = _evidence_kind_to_role(kind) if role is None: raise HTTPException(status_code=400, detail="kind phải là research, textbook hoặc technical") uid = decode_access_token_user_id(authorization) if uid is None: raise HTTPException(status_code=401, detail="Đăng nhập để tải minh chứng.") if not is_postgres_enabled(): raise HTTPException(status_code=503, detail="Cần PostgreSQL để lưu minh chứng trên máy chủ.") s3, bucket, _cfg_err = _load_minio_for_evidence() if s3 is None or bucket is None: raise HTTPException( status_code=503, detail="Lưu tệp MinIO chưa cấu hình. Đặt biến môi trường S3/MinIO hoặc xem tài liệu triển khai.", ) import mimetypes filename_l = (file.filename or "").lower() guessed, _ = mimetypes.guess_type(filename_l) content_type = (file.content_type or "").split(";")[0].strip() or "application/octet-stream" if content_type not in ALLOWED_MIME_TYPES and guessed in ALLOWED_MIME_TYPES: content_type = guessed if content_type not in ALLOWED_MIME_TYPES: raise HTTPException(status_code=422, detail=f"Loại tệp không được phép: {content_type}") _max_evidence_bytes = 50 * 1024 * 1024 if file.size is not None and int(file.size) > _max_evidence_bytes: raise HTTPException( status_code=413, detail="Tệp vượt quá 50 MB. Hãy nén hoặc chia nhỏ tệp trước khi tải lên.", ) async with get_session() as session: from src.initiative_db.submissions import resolve_initiative_for_draft_case_key ini = await resolve_initiative_for_draft_case_key(session, case_id) if ini is None: raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ (hãy lưu bản nháp trước).") canonical_case = ini.case_code if ini.owner_id != uid: raise HTTPException(status_code=403, detail="Chỉ chủ sở hữu mới tải được minh chứng.") st = str(ini.status or "") if not _initiative_allows_owner_evidence_edit(st): raise HTTPException( status_code=422, detail="Hồ sơ đã kết thúc duyệt — không cập nhật minh chứng.", ) old_row = await get_evidence_artifact_row(session, initiative_id=ini.id, role=role) prior_storage_key = ( (old_row.storage_uri or "").strip() or None if old_row is not None else None ) object_key = s3.build_key_for_initiative(ini.id, file.filename or "evidence.pdf") try: result = await s3.upload( bucket=bucket, key=object_key, fileobj=file.file, mime_type=content_type, metadata={"uploaded_by": str(uid), "case_code": canonical_case, "role": role}, ) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc except StorageError as exc: raise HTTPException(status_code=502, detail=f"Không tải được lên kho: {exc}") from exc await upsert_evidence_artifact( session, initiative_id=ini.id, role=role, storage_uri=object_key, original_name=file.filename, byte_size=result.get("size"), sha256_hex=result.get("sha256"), uploaded_by=uid, mime_type=content_type, ) from src.auth_jwt import decode_bearer_token from src.audit import AuditAction, jwt_payload_actor_email, record_audit ae, ar = jwt_payload_actor_email(decode_bearer_token(authorization)) ev_action = AuditAction.update if old_row is not None else AuditAction.create await record_audit( session, actor_user_id=uid, actor_email=ae, actor_role=ar, action=ev_action, entity_type="application_evidence", entity_id=f"{canonical_case}:{role}", before=( {"storageKey": prior_storage_key} if prior_storage_key else None ), after={ "storageKey": object_key, "originalName": file.filename, "mimeType": content_type, }, metadata={"minioBucket": bucket, "caseId": canonical_case, "evidenceRole": role}, ) await session.commit() if prior_storage_key and prior_storage_key != object_key: try: await s3.delete(bucket, prior_storage_key) except StorageError as exc: logger.warning( "MinIO delete of replaced evidence object failed (DB already points to new key): %s", exc, ) k_label = _evidence_role_to_api_kind(role) return { "ok": True, "caseId": canonical_case, "kind": k_label, "storageKey": object_key, "originalName": file.filename, "byteSize": result.get("size"), "uploadedAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), } @app.get("/api/v1/application-drafts/{case_id}/evidence") async def get_application_draft_evidence( case_id: str, authorization: Optional[str] = Header(None), ): """Metadata (và link tải có thời hạn) cho minh chứng 2.1 / 2.2. Chủ hồ sơ hoặc admin/hội đồng.""" from src.initiative_db.application_storage import ( get_evidence_artifact_row, ) from src.initiative_db.engine import get_session, is_postgres_enabled uid = decode_access_token_user_id(authorization) if uid is None: raise HTTPException(status_code=401, detail="Đăng nhập để xem minh chứng.") if not is_postgres_enabled(): return {"research": None, "textbook": None, "technical": None} s3, bucket, _ = _load_minio_for_exports() async with get_session() as session: from src.initiative_db.submissions import resolve_initiative_for_draft_case_key ini = await resolve_initiative_for_draft_case_key(session, case_id) if ini is None: return {"research": None, "textbook": None, "technical": None} is_staff = _is_staff_reviewer(authorization) if ini.owner_id != uid and not is_staff: raise HTTPException(status_code=403, detail="Không có quyền xem minh chứng hồ sơ này.") r_row = await get_evidence_artifact_row(session, initiative_id=ini.id, role="research_evidence") t_row = await get_evidence_artifact_row(session, initiative_id=ini.id, role="textbook_evidence") tech_row = await get_evidence_artifact_row(session, initiative_id=ini.id, role="technical_evidence") async def pack(row, kind_key: str): if row is None: return None default_name = row.original_name or "evidence" if not default_name.lower().endswith((".pdf", ".png", ".jpg", ".jpeg", ".docx", ".xlsx")): mt = (row.mime_type or "").lower() if "pdf" in mt: default_name = f"{default_name}.pdf" elif "word" in mt or "document" in mt: default_name = f"{default_name}.docx" out = { "kind": kind_key, "originalName": row.original_name, "byteSize": row.byte_size, "mimeType": row.mime_type, "uploadedAt": row.uploaded_at.isoformat() if row.uploaded_at else None, "storageKey": row.storage_uri, "reviewStatus": row.review_status, "reviewedAt": row.reviewed_at.isoformat() if row.reviewed_at else None, } if s3 and bucket and row.storage_uri: try: from src.minio.storage import settings as s3s out["downloadUrl"] = await s3.get_download_url( bucket, row.storage_uri, ttl=s3s.s3_signed_url_ttl, filename=default_name, inline=False, ) if _evidence_row_looks_like_pdf(row, default_name): out["viewUrl"] = await s3.get_download_url( bucket, row.storage_uri, ttl=s3s.s3_signed_url_ttl, filename=default_name, inline=True, response_content_type="application/pdf", ) else: out["viewUrl"] = None except Exception: out["downloadUrl"] = None out["viewUrl"] = None return out return { "research": await pack(r_row, "research"), "textbook": await pack(t_row, "textbook"), "technical": await pack(tech_row, "technical"), } @app.get("/api/v1/application-drafts/{case_id}/evidence/content") async def stream_application_draft_evidence_content( case_id: str, request: Request, kind: str = Query(..., description="research | textbook | technical"), attachment: bool = Query( False, description="If true, Content-Disposition: attachment (download). Otherwise inline for embedding.", ), authorization: Optional[str] = Header(None), ): """ Stream evidence bytes through the API so browsers on HTTPS avoid mixed-content iframes (presigned MinIO URLs are often http://HOST:MINIO_PORT). Same ACL as GET …/evidence. """ from src.initiative_db.application_storage import ( get_evidence_artifact_row, ) from src.initiative_db.engine import get_session, is_postgres_enabled from src.minio.storage import StorageError, _sanitize_filename as minio_safe_fn kinds = request.query_params.getlist("kind") if len(kinds) > 1: effective_kind: object = kinds elif len(kinds) == 1: effective_kind = kinds[0] else: effective_kind = kind role = _evidence_kind_to_role(effective_kind) if role is None: raise HTTPException(status_code=400, detail="kind phải là research, textbook hoặc technical") uid = decode_access_token_user_id(authorization) if uid is None: raise HTTPException(status_code=401, detail="Đăng nhập để xem minh chứng.") if not is_postgres_enabled(): raise HTTPException(status_code=503, detail="Cần PostgreSQL để lấy minh chứng.") s3, bucket, _ = _load_minio_for_evidence() if s3 is None or bucket is None: raise HTTPException( status_code=503, detail="Lưu tệp MinIO chưa cấu hình. Đặt biến môi trường S3/MinIO hoặc xem tài liệu triển khai.", ) async with get_session() as session: from src.initiative_db.submissions import resolve_initiative_for_draft_case_key ini = await resolve_initiative_for_draft_case_key(session, case_id) if ini is None: raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ.") if ini.owner_id != uid and not _is_staff_reviewer(authorization): raise HTTPException(status_code=403, detail="Không có quyền xem minh chứng hồ sơ này.") row = await get_evidence_artifact_row(session, initiative_id=ini.id, role=role) if row is None or not (row.storage_uri or "").strip(): raise HTTPException(status_code=404, detail="Không có tệp minh chứng cho loại này.") default_name = row.original_name or "evidence" if not default_name.lower().endswith((".pdf", ".png", ".jpg", ".jpeg", ".docx", ".xlsx")): mtguess = (row.mime_type or "").lower() if "pdf" in mtguess: default_name = f"{default_name}.pdf" elif "word" in mtguess or "document" in mtguess: default_name = f"{default_name}.docx" safe_fn = minio_safe_fn(default_name) or "file" disp = "attachment" if attachment else "inline" media_type = (row.mime_type or "").strip() or "application/octet-stream" try: async def body(): async for chunk in s3.download_stream(bucket, row.storage_uri): yield chunk return StreamingResponse( body(), media_type=media_type, headers={ "Content-Disposition": f'{disp}; filename="{safe_fn}"', "Cache-Control": "private, no-store", }, ) except FileNotFoundError: raise HTTPException(status_code=404, detail="Tệp không còn trên kho lưu trữ.") from None except StorageError as exc: raise HTTPException(status_code=502, detail=f"Không đọc được tệp từ kho: {exc}") from exc @app.delete("/api/v1/application-drafts/{case_id}/evidence") async def delete_application_draft_evidence( case_id: str, request: Request, kind: str = Query(..., description="research | textbook | technical"), authorization: Optional[str] = Header(None), ): from src.initiative_db.application_storage import ( delete_evidence_artifact_row, ) from src.initiative_db.engine import get_session, is_postgres_enabled from src.minio.storage import StorageError kinds = request.query_params.getlist("kind") if len(kinds) > 1: effective_kind: object = kinds elif len(kinds) == 1: effective_kind = kinds[0] else: effective_kind = kind role = _evidence_kind_to_role(effective_kind) if role is None: raise HTTPException(status_code=400, detail="kind phải là research, textbook hoặc technical") uid = decode_access_token_user_id(authorization) if uid is None: raise HTTPException(status_code=401, detail="Đăng nhập để xóa minh chứng.") if not is_postgres_enabled(): raise HTTPException(status_code=503, detail="Cần PostgreSQL.") s3, bucket, _ = _load_minio_for_evidence() async with get_session() as session: from src.initiative_db.submissions import resolve_initiative_for_draft_case_key ini = await resolve_initiative_for_draft_case_key(session, case_id) if ini is None: raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ.") if ini.owner_id != uid: raise HTTPException(status_code=403, detail="Chỉ chủ sở hữu mới xóa được minh chứng.") st = str(ini.status or "") if not _initiative_allows_owner_evidence_edit(st): raise HTTPException( status_code=422, detail="Hồ sơ đã kết thúc duyệt — không xóa minh chứng.", ) old = await delete_evidence_artifact_row(session, initiative_id=ini.id, role=role) from src.auth_jwt import decode_bearer_token from src.audit import AuditAction, jwt_payload_actor_email, record_audit if old is not None: ae, ar = jwt_payload_actor_email(decode_bearer_token(authorization)) await record_audit( session, actor_user_id=uid, actor_email=ae, actor_role=ar, action=AuditAction.delete, entity_type="application_evidence", entity_id=f"{ini.case_code}:{role}", before={ "storageKey": old.storage_uri, "originalName": old.original_name, }, metadata={"caseId": ini.case_code, "minioBucket": bucket}, ) if old and old.storage_uri and s3 and bucket: try: await s3.delete(bucket, old.storage_uri) except StorageError as exc: logger.warning("MinIO delete failed (continuing with DB row removed): %s", exc) await session.commit() return {"ok": True, "kind": _evidence_role_to_api_kind(role)} @app.get("/api/v1/application-drafts/{case_id}/official-form-layout") async def get_application_draft_official_form_layout( case_id: str, authorization: Optional[str] = Header(None), ): from src.initiative_db.engine import get_session, is_postgres_enabled from src.initiative_db.drafts import get_official_form_layout_payload uid = decode_access_token_user_id(authorization) if uid is None: raise HTTPException(status_code=401, detail="Đăng nhập để xem bố cục PDF.") if not is_postgres_enabled(): return {"caseId": _normalize_case_id(case_id), "layoutEdits": [], "pdf": None} s3, bucket, _ = _load_minio_for_evidence() safe_case_id = _normalize_case_id(case_id) async with get_session() as session: from src.initiative_db.submissions import resolve_initiative_for_draft_case_key ini = await resolve_initiative_for_draft_case_key(session, safe_case_id) if ini is None: return {"caseId": safe_case_id, "layoutEdits": [], "pdf": None} if ini.owner_id != uid and not _is_staff_reviewer(authorization): raise HTTPException(status_code=403, detail="Không có quyền xem bố cục hồ sơ này.") payload = await get_official_form_layout_payload(session, ini.case_code) if not payload: return {"caseId": safe_case_id, "layoutEdits": [], "pdf": None} edits = payload.get("layoutEdits") if not isinstance(edits, list): edits = [] pdf_meta: Optional[Dict[str, Any]] = None storage_key = str(payload.get("storageKey") or "").strip() if storage_key: pdf_meta = { "storageKey": storage_key, "originalName": payload.get("originalName"), "byteSize": payload.get("byteSize"), "uploadedAt": payload.get("uploadedAt"), "downloadUrl": None, "viewUrl": None, } if s3 and bucket: try: from src.minio.storage import settings as s3s default_name = str(payload.get("originalName") or "official-form-layout.pdf") pdf_meta["downloadUrl"] = await s3.get_download_url( bucket, storage_key, ttl=s3s.s3_signed_url_ttl, filename=default_name, inline=False, ) pdf_meta["viewUrl"] = await s3.get_download_url( bucket, storage_key, ttl=s3s.s3_signed_url_ttl, filename=default_name, inline=True, response_content_type="application/pdf", ) except Exception: pdf_meta["downloadUrl"] = None pdf_meta["viewUrl"] = None return { "caseId": safe_case_id, "layoutEdits": edits, "updatedAt": payload.get("updatedAt"), "pdf": pdf_meta, } @app.post("/api/v1/application-drafts/{case_id}/official-form-layout") async def save_application_draft_official_form_layout( case_id: str, layout_edits_json: str = Form(..., description="JSON array of PdfTextLayoutEdit"), file: UploadFile = File(..., description="Edited official-form PDF"), authorization: Optional[str] = Header(None), ): from src.initiative_db.drafts import get_official_form_layout_payload, save_official_form_layout_payload from src.initiative_db.engine import get_session, is_postgres_enabled from src.minio.storage import StorageError uid = decode_access_token_user_id(authorization) if uid is None: raise HTTPException(status_code=401, detail="Đăng nhập để lưu bố cục PDF.") if not is_postgres_enabled(): raise HTTPException(status_code=503, detail="Cần PostgreSQL để lưu bố cục PDF.") s3, bucket, _cfg_err = _load_minio_for_exports() if s3 is None or bucket is None: raise HTTPException( status_code=503, detail="Lưu tệp MinIO chưa cấu hình. Đặt biến môi trường S3/MinIO hoặc xem tài liệu triển khai.", ) safe_case_id = _normalize_case_id(case_id) try: raw_edits = json.loads(layout_edits_json) except Exception as exc: raise HTTPException(status_code=422, detail="layoutEdits JSON không hợp lệ.") from exc normalized_edits = _normalize_pdf_layout_edits(raw_edits) content_type = (file.content_type or "").split(";")[0].strip().lower() or "application/octet-stream" if content_type != "application/pdf": raise HTTPException(status_code=422, detail=f"Chỉ nhận PDF, nhận được: {content_type}") async with get_session() as session: from src.initiative_db.submissions import resolve_initiative_for_draft_case_key ini = await resolve_initiative_for_draft_case_key(session, safe_case_id) if ini is None: raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ (hãy lưu bản nháp trước).") if ini.owner_id != uid: raise HTTPException(status_code=403, detail="Chỉ chủ sở hữu mới lưu bố cục PDF.") st = str(ini.status or "") if not _initiative_allows_owner_evidence_edit(st): raise HTTPException(status_code=422, detail="Hồ sơ đã kết thúc duyệt — không cập nhật bố cục PDF.") existing_payload = await get_official_form_layout_payload(session, ini.case_code) object_key = s3.build_key_for_initiative(ini.id, file.filename or "official-form-layout.pdf") try: upload = await s3.upload( bucket=bucket, key=object_key, fileobj=file.file, mime_type="application/pdf", metadata={"uploaded_by": str(uid), "case_code": ini.case_code, "role": "official_form_layout_pdf"}, ) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc except StorageError as exc: raise HTTPException(status_code=502, detail=f"Không tải được PDF bố cục lên kho: {exc}") from exc now_iso = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") payload = { "storageKey": object_key, "originalName": file.filename or "official-form-layout.pdf", "mimeType": "application/pdf", "byteSize": upload.get("size"), "sha256": upload.get("sha256"), "uploadedBy": str(uid), "uploadedAt": now_iso, "updatedAt": now_iso, "layoutEdits": normalized_edits, "layoutEditCount": len(normalized_edits), } await save_official_form_layout_payload( session, case_id=ini.case_code, payload=payload, owner_id=uid, ) await session.commit() prior_storage_key = str((existing_payload or {}).get("storageKey") or "").strip() if prior_storage_key and prior_storage_key != object_key: try: await s3.delete(bucket, prior_storage_key) except StorageError as exc: logger.warning("MinIO delete of replaced official-form layout PDF failed: %s", exc) return { "ok": True, "caseId": safe_case_id, "layoutEdits": normalized_edits, "storageKey": object_key, "byteSize": upload.get("size"), "uploadedAt": payload["uploadedAt"], } class EvidenceReviewBody(BaseModel): decision: Literal["approved", "rejected"] @app.patch("/api/v1/application-drafts/{case_id}/evidence/review") async def patch_evidence_review( case_id: str, request: Request, kind: str = Query(..., description="research | textbook | technical"), body: EvidenceReviewBody = Body(...), authorization: Optional[str] = Header(None), ): """ Duyệt / từ chối minh chứng (chỉ admin hoặc hội đồng / editor). """ from src.initiative_db.application_storage import get_evidence_artifact_row, set_evidence_artifact_review from src.initiative_db.engine import get_session, is_postgres_enabled if not _is_staff_reviewer(authorization): raise HTTPException(status_code=403, detail="Chỉ quản trị hoặc hội đồng mới thẩm định minh chứng.") uid = decode_access_token_user_id(authorization) if uid is None: raise HTTPException(status_code=401, detail="Đăng nhập.") if not is_postgres_enabled(): raise HTTPException(status_code=503, detail="Cần PostgreSQL.") kinds = request.query_params.getlist("kind") if len(kinds) > 1: effective_kind: object = kinds elif len(kinds) == 1: effective_kind = kinds[0] else: effective_kind = kind role = _evidence_kind_to_role(effective_kind) if role is None: raise HTTPException(status_code=400, detail="kind phải là research, textbook hoặc technical") async with get_session() as session: from src.initiative_db.submissions import resolve_initiative_for_draft_case_key ini = await resolve_initiative_for_draft_case_key(session, case_id) if ini is None: raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ.") row = await get_evidence_artifact_row(session, initiative_id=ini.id, role=role) if row is None: raise HTTPException(status_code=404, detail="Chưa có tệp minh chứng cho loại này.") before_review = { "reviewStatus": row.review_status, "reviewedBy": str(row.reviewed_by) if row.reviewed_by else None, } await set_evidence_artifact_review( session, initiative_id=ini.id, role=role, review_status=body.decision, reviewer_id=uid, ) from src.auth_jwt import decode_bearer_token from src.audit import AuditAction, jwt_payload_actor_email, record_audit ae, ar = jwt_payload_actor_email(decode_bearer_token(authorization)) await record_audit( session, actor_user_id=uid, actor_email=ae, actor_role=ar, action=AuditAction.update, entity_type="application_evidence_review", entity_id=f"{ini.case_code}:{role}", before=before_review, after={"reviewStatus": body.decision, "reviewedBy": str(uid)}, metadata={"caseId": ini.case_code}, ) await session.commit() return {"ok": True, "kind": _evidence_role_to_api_kind(role), "decision": body.decision} @app.get("/api/v1/initiatives/by-case/{case_id}/tab-snapshots") async def list_initiative_tab_snapshots( case_id: str, tab: Optional[str] = Query( None, description="Optional filter: report | application | contribution" ), limit: int = Query(20, ge=1, le=200), authorization: Optional[str] = Header(None), ): """List versioned tab payloads for an initiative (Postgres + migration 002). Owner-only.""" from sqlalchemy import select from src.initiative_db.application_storage import list_tab_snapshots_for_case from src.initiative_db.engine import get_session, is_postgres_enabled from src.initiative_db.models import Initiative uid = decode_access_token_user_id(authorization) if uid is None: raise HTTPException(status_code=401, detail="Đăng nhập để xem lịch sử tab.") if not is_postgres_enabled(): return {"data": []} safe_case_id = _normalize_case_id(case_id) try: async with get_session() as session: ini = ( await session.execute(select(Initiative).where(Initiative.case_code == safe_case_id)) ).scalar_one_or_none() if ini is None: return {"data": []} if ini.owner_id != uid: raise HTTPException(status_code=403, detail="Không có quyền xem hồ sơ này.") data = await list_tab_snapshots_for_case( session, case_code=safe_case_id, tab=tab, limit=limit ) return {"data": data} except HTTPException: raise except Exception: logger.exception("GET tab-snapshots failed case=%s", safe_case_id) raise HTTPException(status_code=500, detail="Không tải được lịch sử tab") from None @app.get("/api/v1/initiatives/by-case/{case_id}/submit-snapshots") async def list_initiative_submit_snapshots( case_id: str, limit: int = Query(10, ge=1, le=50), authorization: Optional[str] = Header(None), ): """Immutable submit snapshots for an initiative (Postgres + migration 002). Owner-only.""" from sqlalchemy import select from src.initiative_db.application_storage import list_submit_snapshots_for_case from src.initiative_db.engine import get_session, is_postgres_enabled from src.initiative_db.models import Initiative uid = decode_access_token_user_id(authorization) if uid is None: raise HTTPException(status_code=401, detail="Đăng nhập để xem lịch sử nộp.") if not is_postgres_enabled(): return {"data": []} safe_case_id = _normalize_case_id(case_id) try: async with get_session() as session: ini = ( await session.execute(select(Initiative).where(Initiative.case_code == safe_case_id)) ).scalar_one_or_none() if ini is None: return {"data": []} if ini.owner_id != uid: raise HTTPException(status_code=403, detail="Không có quyền xem hồ sơ này.") data = await list_submit_snapshots_for_case(session, case_code=safe_case_id, limit=limit) return {"data": data} except HTTPException: raise except Exception: logger.exception("GET submit-snapshots failed case=%s", safe_case_id) raise HTTPException(status_code=500, detail="Không tải được lịch sử nộp") from None @app.post("/api/v1/review-documents") async def create_review_document( body: ReviewDocumentSaveRequest, authorization: Optional[str] = Header(None), ): """ Persist ReviewPanel JSON bundle. Primary payload is `officialBieuMau`; `templateData` / `fullBundle` are optional mirrors. """ from src.initiative_db.engine import get_session, is_postgres_enabled from src.initiative_db.submissions import save_review_document_bundle safe_case_id = _normalize_case_id(body.caseId) req_case_id = _normalize_case_id(body.caseId) if req_case_id != safe_case_id: raise HTTPException(status_code=400, detail="caseId in path and body must match.") owner_uid = _require_authenticated_user(authorization) if is_postgres_enabled(): try: async with get_session() as session: await _assert_initiative_case_access(session, safe_case_id, owner_uid, authorization) saved = await save_review_document_bundle( session, case_id=safe_case_id, official_bieu_mau=body.officialBieuMau, template_data=body.templateData, full_bundle=body.fullBundle, owner_user_id=owner_uid, ) return saved except HTTPException: raise except Exception: logger.exception("review-document save (PostgreSQL) failed case=%s", safe_case_id) raise HTTPException(status_code=500, detail="Không lưu được JSON ReviewPanel") from None # file fallback target_dir = APP_ROOT_DIR / "assets" / "review-documents" target_dir.mkdir(parents=True, exist_ok=True) payload = { "caseId": safe_case_id, "officialBieuMau": body.officialBieuMau, "templateData": body.templateData, "fullBundle": body.fullBundle, "savedAt": datetime.utcnow().replace(microsecond=0).isoformat() + "Z", } with open(target_dir / f"{safe_case_id}.json", "w", encoding="utf-8") as handle: json.dump(payload, handle, ensure_ascii=False, indent=2) return payload @app.get("/api/v1/review-documents") async def list_review_documents( caseId: str, limit: int = Query(20, ge=1, le=200), authorization: Optional[str] = Header(None), ): """List review documents by case id (latest first).""" from src.initiative_db.engine import get_session, is_postgres_enabled from src.initiative_db.submissions import list_review_document_bundles uid = _require_authenticated_user(authorization) safe_case_id = _normalize_case_id(caseId) if is_postgres_enabled(): try: async with get_session() as session: await _assert_initiative_case_access(session, safe_case_id, uid, authorization) rows = await list_review_document_bundles(session, case_id=safe_case_id, limit=limit) return {"data": rows} except Exception: logger.exception("review-document load (PostgreSQL) failed case=%s", safe_case_id) raise HTTPException(status_code=500, detail="Không tải được JSON ReviewPanel") from None target_file = APP_ROOT_DIR / "assets" / "review-documents" / f"{safe_case_id}.json" if not _is_staff_reviewer(authorization): raise HTTPException(status_code=403, detail="Không có quyền xem hồ sơ này.") if target_file.exists(): try: with open(target_file, "r", encoding="utf-8") as handle: return {"data": [json.load(handle)]} except Exception: logger.exception("review-document file fallback load failed case=%s", safe_case_id) raise HTTPException(status_code=500, detail="Không tải được JSON ReviewPanel") from None return {"data": []} @app.get("/api/v1/review-documents/{review_document_id}") async def get_review_document_by_id( review_document_id: str, authorization: Optional[str] = Header(None) ): from src.initiative_db.engine import get_session, is_postgres_enabled from src.initiative_db.submissions import get_review_document_bundle_by_id uid = _require_authenticated_user(authorization) if is_postgres_enabled(): try: async with get_session() as session: await _assert_review_document_access(session, review_document_id, uid, authorization) row = await get_review_document_bundle_by_id( session, review_document_id=review_document_id ) if row is None: raise HTTPException(status_code=404, detail="Không tìm thấy review document") return row except HTTPException: raise except Exception: logger.exception("review-document get by id failed id=%s", review_document_id) raise HTTPException(status_code=500, detail="Không tải được review document") from None raise HTTPException(status_code=501, detail="ID-based lookup requires PostgreSQL mode") @app.put("/api/v1/review-documents/{review_document_id}") async def update_review_document_by_id( review_document_id: str, body: ReviewDocumentUpdateRequest, authorization: Optional[str] = Header(None), ): from src.initiative_db.engine import get_session, is_postgres_enabled from src.initiative_db.submissions import update_review_document_bundle uid = _require_authenticated_user(authorization) if is_postgres_enabled(): try: async with get_session() as session: await _assert_review_document_access(session, review_document_id, uid, authorization) row = await update_review_document_bundle( session, review_document_id=review_document_id, official_bieu_mau=body.officialBieuMau, template_data=body.templateData, full_bundle=body.fullBundle, ) if row is None: raise HTTPException(status_code=404, detail="Không tìm thấy review document") return row except HTTPException: raise except Exception: logger.exception("review-document update failed id=%s", review_document_id) raise HTTPException(status_code=500, detail="Không cập nhật được review document") from None raise HTTPException(status_code=501, detail="ID-based update requires PostgreSQL mode") @app.delete("/api/v1/review-documents/{review_document_id}") async def delete_review_document_by_id( review_document_id: str, authorization: Optional[str] = Header(None) ): from src.initiative_db.engine import get_session, is_postgres_enabled from src.initiative_db.submissions import delete_review_document_bundle uid = _require_authenticated_user(authorization) if is_postgres_enabled(): try: async with get_session() as session: await _assert_review_document_access(session, review_document_id, uid, authorization) ok = await delete_review_document_bundle( session, review_document_id=review_document_id ) if not ok: raise HTTPException(status_code=404, detail="Không tìm thấy review document") return {"deleted": True, "id": review_document_id} except HTTPException: raise except Exception: logger.exception("review-document delete failed id=%s", review_document_id) raise HTTPException(status_code=500, detail="Không xóa được review document") from None raise HTTPException(status_code=501, detail="ID-based delete requires PostgreSQL mode") # Backward-compatible endpoints (deprecated) @app.post("/api/v1/applications/{case_id}/review-document") async def save_review_document( case_id: str, body: ReviewDocumentSaveRequest, authorization: Optional[str] = Header(None), ): if _normalize_case_id(case_id) != _normalize_case_id(body.caseId): raise HTTPException(status_code=400, detail="caseId in path and body must match.") return await create_review_document(body, authorization) @app.get("/api/v1/applications/{case_id}/review-document") async def get_review_document(case_id: str): rows = await list_review_documents(case_id, limit=1) data = rows.get("data") if isinstance(rows, dict) else None if isinstance(data, list) and data: return data[0] raise HTTPException(status_code=404, detail="Không tìm thấy JSON ReviewPanel") @app.get("/api/v1/applications/{case_id}/review-document/be01-context") async def get_review_document_be01_context(case_id: str): """Convert latest official ReviewPanel JSON to be01 `data_blank.json` shape.""" from src.be01.official_to_data_blank import official_to_data_blank row = await get_review_document(case_id) official = row.get("officialBieuMau") if isinstance(row, dict) else {} if not isinstance(official, dict) or not official: raise HTTPException(status_code=404, detail="Không có officialBieuMau để chuyển đổi.") return { "caseId": str(row.get("caseId") or case_id), "be01Context": official_to_data_blank(official), } class PreviewApplicationFormDocxRequest(BaseModel): """Paired with `data_blank.json` after `official_to_data_blank(officialBieuMau)`.""" officialBieuMau: dict[str, Any] @app.post("/api/v1/docx/preview-application-form") async def preview_application_form_docx(body: PreviewApplicationFormDocxRequest): """ Render `template_application_form.docx` (Jinja2/docxtpl) and return a filled .docx. Accepts the same `officialBieuMau` object produced on the « Xem lại » tab. """ from src.be01.official_to_data_blank import official_to_data_blank from src.be01.fill_application_form import fill_application_form_docx try: ctx = official_to_data_blank(body.officialBieuMau or {}) except Exception as exc: # noqa: BLE001 raise HTTPException( status_code=400, detail="Không chuyển officialBieuMau sang dữ liệu biểu mẫu: " + str(exc) ) from exc try: raw = fill_application_form_docx(ctx) except ImportError as exc: raise HTTPException( status_code=501, detail="Thiếu thư viện docxtpl. Cài: pip install docxtpl" ) from exc except FileNotFoundError as exc: raise HTTPException( status_code=500, detail="Không tìm thấy file mẫu Word trên server: " + str(exc) ) from exc except Exception as exc: # noqa: BLE001 raise HTTPException( status_code=500, detail="Lỗi khi render mẫu Word: " + str(exc) ) from exc return Response( content=raw, media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", headers={"Content-Disposition": 'inline; filename="mau-don-va-bao-cao-xem-truoc.docx"'}, ) @app.post("/api/v1/docx/preview-application-form-pdf") async def preview_application_form_pdf(body: PreviewApplicationFormDocxRequest): """ Same merge as `preview-application-form` (docxtpl), then LibreOffice → PDF so layout matches DOCX. """ from src.be01.docx_to_pdf import convert_docx_bytes_to_pdf from src.be01.fill_application_form import fill_application_form_docx from src.be01.official_to_data_blank import official_to_data_blank try: ctx = official_to_data_blank(body.officialBieuMau or {}) except Exception as exc: # noqa: BLE001 raise HTTPException( status_code=400, detail="Không chuyển officialBieuMau sang dữ liệu biểu mẫu: " + str(exc) ) from exc try: docx_bytes = fill_application_form_docx(ctx) except ImportError as exc: raise HTTPException( status_code=501, detail="Thiếu thư viện docxtpl. Cài: pip install docxtpl" ) from exc except FileNotFoundError as exc: raise HTTPException( status_code=500, detail="Không tìm thấy file mẫu Word trên server: " + str(exc) ) from exc except Exception as exc: # noqa: BLE001 raise HTTPException( status_code=500, detail="Lỗi khi render mẫu Word: " + str(exc) ) from exc try: pdf_bytes = convert_docx_bytes_to_pdf( docx_bytes, relax_justified_softbreaks=True, strip_table_row_heights=False, ) except FileNotFoundError as exc: raise HTTPException( status_code=501, detail="Chưa cài LibreOffice để xuất PDF. Docker: thêm libreoffice-writer-nogui; " "hoặc đặt LIBREOFFICE_PATH. Chi tiết: " + str(exc), ) from exc except (RuntimeError, ValueError, subprocess.TimeoutExpired) as exc: raise HTTPException( status_code=500, detail="Không chuyển DOCX sang PDF: " + str(exc) ) from exc return Response( content=pdf_bytes, media_type="application/pdf", headers={"Content-Disposition": 'inline; filename="mau-ho-so-sang-kien.pdf"'}, ) @app.post("/api/v1/docx/convert-pdf") async def convert_uploaded_docx_to_pdf( file: UploadFile = File(...), relax_justified_softbreaks: bool = Form(True), strip_table_row_heights: bool = Form(False), ): """ Convert an uploaded `.docx` file to PDF using LibreOffice for near-Word layout fidelity. """ from src.be01.docx_to_pdf import convert_docx_bytes_to_pdf filename = (file.filename or "").strip() if not filename: raise HTTPException(status_code=400, detail="Thiếu tên file .docx.") if not filename.lower().endswith(".docx"): raise HTTPException(status_code=400, detail="Chỉ hỗ trợ file .docx.") try: docx_bytes = await file.read() except Exception as exc: # noqa: BLE001 raise HTTPException(status_code=400, detail="Không đọc được nội dung file upload.") from exc if not docx_bytes: raise HTTPException(status_code=400, detail="File .docx rỗng.") try: pdf_bytes = await asyncio.to_thread( convert_docx_bytes_to_pdf, docx_bytes, relax_justified_softbreaks=relax_justified_softbreaks, strip_table_row_heights=strip_table_row_heights, ) except FileNotFoundError as exc: raise HTTPException( status_code=501, detail="Chưa cài LibreOffice để xuất PDF. Docker: thêm libreoffice-writer-nogui; " "hoặc đặt LIBREOFFICE_PATH. Chi tiết: " + str(exc), ) from exc except (RuntimeError, ValueError, subprocess.TimeoutExpired) as exc: raise HTTPException( status_code=500, detail="Không chuyển DOCX sang PDF: " + str(exc) ) from exc safe_stem = "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in Path(filename).stem) out_name = (safe_stem or "document") + ".pdf" return Response( content=pdf_bytes, media_type="application/pdf", headers={"Content-Disposition": f'inline; filename="{out_name}"'}, ) # --- Đơn sáng kiến: nộp PDF (người nộp) + danh sách cho admin « Danh Sách Sáng kiến » --- SUBMITTED_INITIATIVES_DIR = Path( os.getenv( "SUBMITTED_INITIATIVES_DIR", str((APP_ROOT_DIR.parent / "fe0" / "public" / "submitted-initiatives").resolve()), ) ) SUBMITTED_INDEX_PATH = SUBMITTED_INITIATIVES_DIR / "index.json" SUBMITTED_INITIATIVES_DIR.mkdir(parents=True, exist_ok=True) app.mount( "/submitted-initiatives", StaticFiles(directory=str(SUBMITTED_INITIATIVES_DIR.resolve())), name="submitted_initiatives", ) def _load_submitted_items() -> List[Dict[str, Any]]: SUBMITTED_INITIATIVES_DIR.mkdir(parents=True, exist_ok=True) if not SUBMITTED_INDEX_PATH.exists(): return [] try: with open(SUBMITTED_INDEX_PATH, "r", encoding="utf-8") as handle: data = json.load(handle) items = data.get("items") if isinstance(data, dict) else None return list(items) if isinstance(items, list) else [] except Exception: logger.exception("Failed to load submitted initiatives index") return [] def _save_submitted_items(items: List[Dict[str, Any]]) -> None: SUBMITTED_INITIATIVES_DIR.mkdir(parents=True, exist_ok=True) with open(SUBMITTED_INDEX_PATH, "w", encoding="utf-8") as handle: json.dump({"items": items}, handle, ensure_ascii=False, indent=2) @app.post("/api/applications/submit") async def submit_initiative_application( file: UploadFile = File(...), metadata: str = Form(""), authorization: Optional[str] = Header(None), ): """ Nhận file PDF hồ sơ đầy đủ từ tab « Xem lại », lưu vào public/submitted-initiatives và ghi vào index để GET /api/applications trả về cho admin. """ if not file.filename or not file.filename.lower().endswith(".pdf"): raise HTTPException(status_code=400, detail="Yêu cầu file PDF (.pdf)") content = await file.read() if not content or len(content) < 100: raise HTTPException(status_code=400, detail="File PDF không hợp lệ hoặc quá nhỏ") try: meta = json.loads(metadata) if metadata.strip() else {} except json.JSONDecodeError: meta = {} new_id = f"sub-{uuid.uuid4().hex[:16]}" now = datetime.utcnow().replace(microsecond=0).isoformat() + "Z" safe_name = f"{new_id}.pdf" SUBMITTED_INITIATIVES_DIR.mkdir(parents=True, exist_ok=True) pdf_path = SUBMITTED_INITIATIVES_DIR / safe_name with open(pdf_path, "wb") as handle: handle.write(content) initiative_name = (meta.get("initiativeName") or meta.get("name") or "").strip() or "Hồ sơ sáng kiến" author_name = (meta.get("authorName") or "").strip() or "—" author_email = (meta.get("authorEmail") or "").strip() or None author_phone = (meta.get("authorPhone") or "").strip() or None case_id = (meta.get("caseId") or "").strip() or None item: Dict[str, Any] = { "id": new_id, "submittedDate": now, "name": initiative_name, "author": { "id": case_id or new_id, "name": author_name, "email": author_email, "phone": author_phone, }, "subjectId": meta.get("subjectId") or "", "groupId": meta.get("groupId") or "", "status": "pending", "reviewStatus": "not_reviewed", "supervisor": None, "reviewer": None, "reviewDeadline": None, "conference": None, "topicType": str(meta.get("topicType") or "Hồ sơ PDF (đơn + báo cáo)"), "files": { "fullText": {"url": f"/submitted-initiatives/{safe_name}", "type": "pdf"}, "abstract": None, "poster": None, }, } public_url = f"/submitted-initiatives/{safe_name}" from src.initiative_db.engine import get_session, is_postgres_enabled from src.initiative_db.submissions import ApplicationSubmitPersistError, save_submitted_application from src.initiative_db.submission_readiness import ApplicationSubmissionNotReadyError owner_uid = decode_access_token_user_id(authorization) if is_postgres_enabled(): try: pdf_sha256 = hashlib.sha256(content).hexdigest() pdf_len = len(content) async with get_session() as session: saved = await save_submitted_application( session=session, metadata=meta if isinstance(meta, dict) else {}, file_url=public_url, submission_id=new_id, owner_user_id=owner_uid, pdf_byte_size=pdf_len, pdf_sha256=pdf_sha256, pdf_original_name=safe_name, pdf_body=content, ) logger.info("Submitted initiative PDF persisted in PostgreSQL path=%s", pdf_path) return saved except ApplicationSubmissionNotReadyError as exc: try: pdf_path.unlink(missing_ok=True) except OSError: pass raise HTTPException( status_code=400, detail={ "message": "Hồ sơ chưa đủ điều kiện nộp.", "missing": exc.missing, }, ) from exc except ApplicationSubmitPersistError as exc: raise HTTPException( status_code=503, detail=str(exc), ) from exc except Exception: logger.exception("application submission persist (PostgreSQL) failed; fallback to file index") items = _load_submitted_items() items.insert(0, item) _save_submitted_items(items) logger.info("Submitted initiative PDF id=%s path=%s", new_id, pdf_path) return { "id": new_id, "submittedDate": now, "publicUrl": public_url, "name": initiative_name, } @app.post("/api/applications/new") async def create_submitted_application( body: CreateSubmittedApplicationBody, authorization: Optional[str] = Header(None), ): """Create a new submitted-application shell row and return generated application id.""" from src.initiative_db.engine import get_session, is_postgres_enabled from src.initiative_db.models import User from src.initiative_db.submissions import create_submitted_application_shell uid = decode_access_token_user_id(authorization) if uid is None: raise HTTPException(status_code=401, detail="Đăng nhập để tạo hồ sơ mới.") if not is_postgres_enabled(): raise HTTPException(status_code=503, detail="Tính năng này yêu cầu PostgreSQL.") try: async with get_session() as session: user = await session.get(User, uid) row = await create_submitted_application_shell( session=session, owner_user_id=uid, name=(body.name or "").strip() or None, author_name=(str(user.full_name).strip() if user and user.full_name else None), author_email=(str(user.email).strip() if user and user.email else None), author_phone=(str(user.phone).strip() if user and user.phone else None), ) return {"id": str(row.get("id") or ""), "application": row} except HTTPException: raise except Exception: logger.exception("POST /api/applications/new failed") raise HTTPException(status_code=500, detail="Không thể tạo hồ sơ mới.") from None def _get_application_from_file_index(application_id: str) -> Optional[Dict[str, Any]]: for row in _load_submitted_items(): if str(row.get("id")) == application_id: return row return None @app.get("/api/applications/mine") async def list_my_applications(authorization: Optional[str] = Header(None)): """Submitted applications for the logged-in applicant (Postgres + optional file fallback).""" from src.initiative_db.engine import get_session, is_postgres_enabled from src.initiative_db.models import User from src.initiative_db.submissions import list_my_submitted_applications uid = decode_access_token_user_id(authorization) if uid is None: raise HTTPException(status_code=401, detail="Đăng nhập để xem hồ sơ của bạn.") if is_postgres_enabled(): try: async with get_session() as session: user = await session.get(User, uid) email = str(user.email) if user is not None else "" data = await list_my_submitted_applications(session, uid, email) return {"data": data} except HTTPException: raise except Exception: logger.exception("GET /api/applications/mine (PostgreSQL) failed") raise HTTPException(status_code=500, detail="Không tải được danh sách hồ sơ") from None payload = decode_bearer_token(authorization) token_email = str((payload or {}).get("email") or "").strip().lower() items = _load_submitted_items() filtered: List[Dict[str, Any]] = [] for row in items: auth_em = str((row.get("author") or {}).get("email") or "").strip().lower() if token_email and auth_em == token_email: filtered.append(row) filtered.sort(key=lambda x: str(x.get("submittedDate") or ""), reverse=True) for row in filtered: sd = str(row.get("submittedDate") or "") if len(sd) >= 4 and sd[:4].isdigit(): row["calendarYear"] = int(sd[:4]) return {"data": filtered} async def _enrich_application_detail_full_pdf_presign(session, row: Dict[str, Any]) -> None: """If full PDF artifact is stored as a MinIO exports key, add files.fullText.viewUrl for admins.""" from sqlalchemy import select from src.initiative_db.models import ApplicationArtifact, Initiative case = str(row.get("draft_case_id") or "").strip() if not case: return ini = ( await session.execute(select(Initiative).where(Initiative.case_code == case)) ).scalar_one_or_none() if ini is None: return art = ( await session.execute( select(ApplicationArtifact).where( ApplicationArtifact.initiative_id == ini.id, ApplicationArtifact.role == "full_pdf", ) ) ).scalar_one_or_none() if art is None or not (art.storage_uri or "").strip(): return uri = (art.storage_uri or "").strip() if uri.startswith("/submitted-initiatives") or uri.startswith(("http://", "https://")): return try: from src.minio.storage import S3Storage, settings as s3s s3 = S3Storage() bucket = s3s.s3_bucket_exports view_url = await s3.get_download_url( bucket, uri, ttl=3600, filename=(art.original_name or "ho-so.pdf"), inline=True, response_content_type="application/pdf", ) except Exception: logger.warning("Presigned URL for submitted full PDF failed (case=%s)", case, exc_info=True) return files = row.setdefault("files", {}) ft = files.get("fullText") merged = dict(ft) if isinstance(ft, dict) else {} merged["viewUrl"] = view_url merged["storageKey"] = uri files["fullText"] = merged @app.get("/api/applications/export") async def export_applications_excel( authorization: Optional[str] = Header(None), page: int = 1, pageSize: int = 20, name: str = "", authorName: str = "", reviewerName: str = "", status: str = "", reviewStatus: str = "", dateFrom: str = "", dateTo: str = "", sortBy: str = "submittedDate", sortOrder: str = "desc", lifecycle: str = "", ): """ Xuất Excel danh sách sáng kiến (cùng bộ lọc / sắp xếp với GET /api/applications). Cột TT & MSSK: «YYYY-n» (n tăng theo từng năm trong bản xuất). Chỉ tài khoản admin. """ _require_admin_user(authorization) from src.be01.export_applications_list_xlsx import build_applications_list_xlsx from src.initiative_db.engine import get_session, is_postgres_enabled from src.initiative_db.submissions import submitted_applications_pairs_for_export _ = page, pageSize # client gửi cùng query với danh sách; không phân trang khi xuất if is_postgres_enabled(): try: async with get_session() as session: pairs = await submitted_applications_pairs_for_export( session, name=name, author_name=authorName, reviewer_name=reviewerName, status=status, review_status=reviewStatus, date_from=dateFrom, date_to=dateTo, sort_by=sortBy, sort_order=sortOrder, lifecycle=lifecycle, ) except Exception: logger.exception("GET /api/applications/export (PostgreSQL) failed") raise HTTPException( status_code=503, detail="Không thể xuất Excel từ cơ sở dữ liệu. Vui lòng thử lại sau.", ) from None else: items = _load_submitted_items() lc = (lifecycle or "").strip().lower() def match(row: Dict[str, Any], *, skip_status: bool = False) -> bool: row_status = str(row.get("status") or "") if lc == "inbox": if row_status in ("approved", "rejected"): return False elif lc == "decided": if row_status not in ("approved", "rejected"): return False n = name.strip().lower() if n and n not in str(row.get("name") or "").lower(): return False an = authorName.strip().lower() auth = row.get("author") or {} if an and an not in str(auth.get("name") or "").lower(): return False rn = reviewerName.strip().lower() if rn: rev = row.get("reviewer") or {} if rn not in str(rev.get("name") or "").lower(): return False if not skip_status and status and row_status != status: return False if reviewStatus and str(row.get("reviewStatus") or "") != reviewStatus: return False sd = row.get("submittedDate") if dateFrom and sd: sd_day = str(sd)[:10] if len(sd_day) == 10 and sd_day < dateFrom: return False if dateTo and sd: sd_day = str(sd)[:10] if len(sd_day) == 10 and sd_day > dateTo: return False return True filtered = [x for x in items if match(x, skip_status=False)] reverse = sortOrder != "asc" if sortBy == "name": filtered.sort(key=lambda x: str(x.get("name") or ""), reverse=reverse) elif sortBy == "author": filtered.sort( key=lambda x: str((x.get("author") or {}).get("name") or ""), reverse=reverse, ) else: filtered.sort(key=lambda x: str(x.get("submittedDate") or ""), reverse=reverse) pairs = [(r, {}) for r in filtered] body = build_applications_list_xlsx(pairs) safe_fn = f"sang-kien-export-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M')}.xlsx" return Response( content=body, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": f'attachment; filename="{safe_fn}"'}, ) BULK_APPLICATION_BACKUPS_MAX = 250 @app.get("/api/applications/export-backups") async def export_applications_backups_bundle( authorization: Optional[str] = Header(None), page: int = 1, pageSize: int = 20, name: str = "", authorName: str = "", reviewerName: str = "", status: str = "", reviewStatus: str = "", dateFrom: str = "", dateTo: str = "", sortBy: str = "submittedDate", sortOrder: str = "desc", lifecycle: str = "", ): """ Admin-only: một file ZIP chứa từng file ZIP sao lưu hồ sơ (cùng bộ lọc / sắp xếp với Xuất Excel). """ from sqlalchemy import desc, select from src.audit import AuditAction, record_audit, resolve_actor_fields from src.initiative_db.application_backup import build_backup_zipstream from src.initiative_db.backup_naming import backup_zip_attachment_filename from src.initiative_db.engine import get_session, is_postgres_enabled from src.initiative_db.models import ApplicationArtifact, ApplicationReviewDocument, User from src.initiative_db.submissions import ( _as_review_document_row, resolve_submitted_initiative_for_backup, submitted_applications_pairs_for_export, ) from src.minio.storage import _sanitize_filename, settings as s3_settings from zipstream import ZipStream admin_uid = _require_admin_user(authorization) if not is_postgres_enabled(): raise HTTPException(status_code=503, detail="Sao lưu yêu cầu PostgreSQL.") _ = page, pageSize outer = ZipStream() used_inner_names: set[str] = set() def _unique_member_name(safe_fn: str) -> str: base = safe_fn or "backup.zip" if base not in used_inner_names: used_inner_names.add(base) return base stem = base[:-4] if base.lower().endswith(".zip") else base n = 2 while True: cand = f"{stem}_{n}.zip" if cand not in used_inner_names: used_inner_names.add(cand) return cand n += 1 packed_ids: List[str] = [] async with get_session() as session: try: pairs = await submitted_applications_pairs_for_export( session, name=name, author_name=authorName, reviewer_name=reviewerName, status=status, review_status=reviewStatus, date_from=dateFrom, date_to=dateTo, sort_by=sortBy, sort_order=sortOrder, lifecycle=lifecycle, ) except Exception: logger.exception("GET /api/applications/export-backups (list) failed") raise HTTPException( status_code=503, detail="Không thể tải danh sách hồ sơ để đóng gói sao lưu.", ) from None if len(pairs) > BULK_APPLICATION_BACKUPS_MAX: raise HTTPException( status_code=413, detail=f"Quá nhiều hồ sơ ({len(pairs)}). Tối đa {BULK_APPLICATION_BACKUPS_MAX} — vui lòng thu hẹp bộ lọc.", ) for row, _payload in pairs: application_id = str(row.get("id") or "").strip() if not application_id: continue resolved = await resolve_submitted_initiative_for_backup(session, application_id) if resolved is None: continue initiative, public_id = resolved arts = ( await session.execute( select(ApplicationArtifact).where(ApplicationArtifact.initiative_id == initiative.id) ) ).scalars().all() rd = ( await session.execute( select(ApplicationReviewDocument) .where(ApplicationReviewDocument.initiative_id == initiative.id) .order_by(desc(ApplicationReviewDocument.document_version)) .limit(1) ) ).scalar_one_or_none() review_json = _as_review_document_row(rd) if rd is not None else None owner = await session.get(User, initiative.owner_id) inner_fn = backup_zip_attachment_filename( owner_email=owner.email if owner is not None else None, owner_full_name=owner.full_name if owner is not None else None, public_application_id=public_id, ) member_name = _unique_member_name(inner_fn) inner_z = build_backup_zipstream( settings=s3_settings, initiative=initiative, application_id=public_id, case_code=initiative.case_code, artifacts=list(arts), review_doc_json=review_json, owner_id=str(initiative.owner_id), submitted_at=initiative.submitted_at.isoformat() if initiative.submitted_at is not None else None, ) outer.add(iter(inner_z), member_name) packed_ids.append(public_id) if not packed_ids: raise HTTPException( status_code=404, detail="Không có hồ sơ nào khớp bộ lọc để đóng gói sao lưu.", ) actor_email, actor_role = await resolve_actor_fields(session, admin_uid) await record_audit( session, actor_user_id=admin_uid, actor_email=actor_email, actor_role=actor_role, action=AuditAction.read, entity_type="application_backup_bulk", entity_id="bulk", metadata={ "outcome": "nested_zip_stream", "packed_count": len(packed_ids), "application_ids": packed_ids[:100], "truncated_ids": len(packed_ids) > 100, }, ) await session.commit() outer_fn = _sanitize_filename( f"sang-kien-sao-luu-tong-hop-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M')}.zip" ) or "sang-kien-sao-luu-tong-hop.zip" return StreamingResponse( outer, media_type="application/zip", headers={"Content-Disposition": f'attachment; filename="{outer_fn}"'}, ) @app.get("/api/applications/{application_id}") async def get_application( application_id: str, authorization: Optional[str] = Header(None) ): """Single submitted application for review page (admin / applicant deep link).""" from src.initiative_db.engine import get_session, is_postgres_enabled from src.initiative_db.models import User from src.initiative_db.submissions import ( _applicant_may_mutate_row, _as_submission_item, _resolve_initiative_and_latest_draft_for_application_id, get_application_by_id, ) uid = _require_authenticated_user(authorization) if is_postgres_enabled(): try: async with get_session() as session: if not _is_staff_reviewer(authorization): try: initiative, draft = await _resolve_initiative_and_latest_draft_for_application_id( session, application_id ) except LookupError as exc: raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ") from exc payload = dict(draft.payload) if isinstance(draft.payload, dict) else {} row = _as_submission_item(initiative, payload) user = await session.get(User, uid) email = str(user.email) if user is not None else "" if not _applicant_may_mutate_row(initiative, row, uid, email): raise HTTPException(status_code=403, detail="Không có quyền xem hồ sơ này.") row = await get_application_by_id(session, application_id) if row is not None: await _enrich_application_detail_full_pdf_presign(session, row) return row except HTTPException: raise except Exception: logger.exception("application detail query (PostgreSQL) failed; refusing file index fallback while DB is configured") raise HTTPException( status_code=503, detail="Không thể tải hồ sơ từ cơ sở dữ liệu. Vui lòng thử lại sau hoặc liên hệ quản trị.", ) from None if not _is_staff_reviewer(authorization): payload = decode_bearer_token(authorization) token_email = str((payload or {}).get("email") or "").strip().lower() row_check = _get_application_from_file_index(application_id) if row_check is None: raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ") auth_em = str((row_check.get("author") or {}).get("email") or "").strip().lower() if not token_email or auth_em != token_email: raise HTTPException(status_code=403, detail="Không có quyền xem hồ sơ này.") row = _get_application_from_file_index(application_id) if row is not None: return row raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ") @app.get("/api/applications/{application_id}/backup") async def download_application_backup( application_id: str, authorization: Optional[str] = Header(None), ): """ Admin-only: stream a ZIP (manifest + submitted PDF + official DOCX/PDF + evidence + optional review JSON). """ from sqlalchemy import desc, select from src.audit import AuditAction, record_audit, resolve_actor_fields from src.initiative_db.application_backup import build_backup_zipstream from src.initiative_db.backup_naming import backup_zip_attachment_filename from src.initiative_db.engine import get_session, is_postgres_enabled from src.initiative_db.models import ApplicationArtifact, ApplicationReviewDocument, User from src.initiative_db.submissions import _as_review_document_row, resolve_submitted_initiative_for_backup from src.minio.storage import settings as s3_settings admin_uid = _require_admin_user(authorization) if not is_postgres_enabled(): raise HTTPException(status_code=503, detail="Sao lưu yêu cầu PostgreSQL.") async with get_session() as session: resolved = await resolve_submitted_initiative_for_backup(session, application_id) if resolved is None: raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ.") initiative, public_id = resolved arts = ( await session.execute( select(ApplicationArtifact).where(ApplicationArtifact.initiative_id == initiative.id) ) ).scalars().all() rd = ( await session.execute( select(ApplicationReviewDocument) .where(ApplicationReviewDocument.initiative_id == initiative.id) .order_by(desc(ApplicationReviewDocument.document_version)) .limit(1) ) ).scalar_one_or_none() review_json = _as_review_document_row(rd) if rd is not None else None actor_email, actor_role = await resolve_actor_fields(session, admin_uid) await record_audit( session, actor_user_id=admin_uid, actor_email=actor_email, actor_role=actor_role, action=AuditAction.read, entity_type="application_backup", entity_id=public_id, metadata={ "outcome": "streaming_zip", "initiative_id": str(initiative.id), "case_code": initiative.case_code, "artifact_count": len(arts), }, ) await session.commit() owner = await session.get(User, initiative.owner_id) safe_fn = backup_zip_attachment_filename( owner_email=owner.email if owner is not None else None, owner_full_name=owner.full_name if owner is not None else None, public_application_id=public_id, ) z = build_backup_zipstream( settings=s3_settings, initiative=initiative, application_id=public_id, case_code=initiative.case_code, artifacts=list(arts), review_doc_json=review_json, owner_id=str(initiative.owner_id), submitted_at=initiative.submitted_at.isoformat() if initiative.submitted_at is not None else None, ) return StreamingResponse( z, media_type="application/zip", headers={"Content-Disposition": f'attachment; filename="{safe_fn}"'}, ) @app.put("/api/applications/{application_id}") async def update_submitted_application( application_id: str, body: UpdateSubmittedApplicationBody, authorization: Optional[str] = Header(None), ): """Update name and submitted date for the applicant's own submission (Postgres or file index).""" from src.initiative_db.engine import get_session, is_postgres_enabled from src.initiative_db.models import User from src.initiative_db.submissions import _parse_submitted_date_input, update_my_submitted_application uid = decode_access_token_user_id(authorization) if uid is None: raise HTTPException(status_code=401, detail="Đăng nhập để cập nhật hồ sơ.") if is_postgres_enabled(): try: async with get_session() as session: user = await session.get(User, uid) email = str(user.email) if user is not None else "" return await update_my_submitted_application( session, uid, email, application_id, body.name, body.submittedDate ) except LookupError: raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ") except PermissionError: raise HTTPException(status_code=403, detail="Không có quyền cập nhật hồ sơ này.") except HTTPException: raise except Exception: logger.exception("PUT /api/applications (PostgreSQL) failed") raise HTTPException(status_code=500, detail="Không thể cập nhật hồ sơ") from None payload = decode_bearer_token(authorization) token_email = str((payload or {}).get("email") or "").strip().lower() items = _load_submitted_items() idx = next((i for i, r in enumerate(items) if str(r.get("id")) == application_id), None) if idx is None: raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ") row = items[idx] auth_em = str((row.get("author") or {}).get("email") or "").strip().lower() if not token_email or auth_em != token_email: raise HTTPException(status_code=403, detail="Không có quyền cập nhật hồ sơ này.") try: dt = _parse_submitted_date_input(body.submittedDate) iso = dt.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") except Exception: raise HTTPException(status_code=400, detail="Ngày nộp không hợp lệ.") updated = {**row, "name": body.name.strip(), "submittedDate": iso} if len(iso) >= 4 and iso[:4].isdigit(): updated["calendarYear"] = int(iso[:4]) items[idx] = updated _save_submitted_items(items) return items[idx] @app.delete("/api/applications/{application_id}") async def delete_submitted_application( application_id: str, authorization: Optional[str] = Header(None), ): """Delete the applicant's own submission (Postgres cascade; file index removes row + PDF if present).""" from src.initiative_db.engine import get_session, is_postgres_enabled from src.initiative_db.models import User from src.initiative_db.submissions import delete_my_submitted_application uid = decode_access_token_user_id(authorization) if uid is None: raise HTTPException(status_code=401, detail="Đăng nhập để xóa hồ sơ.") if is_postgres_enabled(): try: async with get_session() as session: user = await session.get(User, uid) email = str(user.email) if user is not None else "" await delete_my_submitted_application(session, uid, email, application_id) return {"deleted": True, "id": application_id} except LookupError: raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ") except PermissionError: raise HTTPException(status_code=403, detail="Không có quyền xóa hồ sơ này.") except HTTPException: raise except Exception: logger.exception("DELETE /api/applications (PostgreSQL) failed") raise HTTPException(status_code=500, detail="Không thể xóa hồ sơ") from None payload = decode_bearer_token(authorization) token_email = str((payload or {}).get("email") or "").strip().lower() items = _load_submitted_items() idx = next((i for i, r in enumerate(items) if str(r.get("id")) == application_id), None) if idx is None: raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ") row = items[idx] auth_em = str((row.get("author") or {}).get("email") or "").strip().lower() if not token_email or auth_em != token_email: raise HTTPException(status_code=403, detail="Không có quyền xóa hồ sơ này.") files = row.get("files") or {} ft = files.get("fullText") if isinstance(files.get("fullText"), dict) else None url = str((ft or {}).get("url") or "") if ft else "" if url.startswith("/submitted-initiatives/"): fname = url.replace("/submitted-initiatives/", "").lstrip("/") safe = "".join(c for c in fname if c.isalnum() or c in ("-", "_", ".")) if safe: pdf_path = SUBMITTED_INITIATIVES_DIR / safe try: if pdf_path.is_file(): pdf_path.unlink() except OSError: logger.warning("Could not delete PDF file %s", pdf_path) items.pop(idx) _save_submitted_items(items) return {"deleted": True, "id": application_id} @app.get("/api/conferences") async def list_conference_filter_options(): """ Distinct hội nghị / đợt (from ``application_workflow.conference``) for council/admin list filters. Returns a list of ``{id, name}`` objects for ``useLookupQuery`` / ``toOptionList``. """ from sqlalchemy import select from src.initiative_db.engine import get_session, is_postgres_enabled from src.initiative_db.models import ApplicationWorkflow if not is_postgres_enabled(): return [] try: async with get_session() as session: rows = (await session.execute(select(ApplicationWorkflow))).scalars().all() seen: dict[str, dict] = {} for wf in rows: c = wf.conference if not isinstance(c, dict): continue cid = str(c.get("id") or "").strip() cname = str(c.get("name") or "").strip() if not cname: continue opt_id = cid if cid else f"__name__:{cname}" if opt_id not in seen: seen[opt_id] = {"id": opt_id, "name": cname} return sorted(seen.values(), key=lambda x: (str(x.get("name") or "").lower(), str(x.get("id") or ""))) except Exception: logger.exception("GET /api/conferences failed") return [] @app.get("/api/supervisors") async def list_supervisor_filter_options(): """Distinct supervisors from ``application_workflow.supervisor`` for dashboard filters.""" from sqlalchemy import select from src.initiative_db.engine import get_session, is_postgres_enabled from src.initiative_db.models import ApplicationWorkflow if not is_postgres_enabled(): return [] try: async with get_session() as session: rows = (await session.execute(select(ApplicationWorkflow))).scalars().all() seen: dict[str, dict] = {} for wf in rows: s = wf.supervisor if not isinstance(s, dict): continue sid = str(s.get("id") or "").strip() sname = str(s.get("name") or s.get("fullName") or "").strip() if not sname: continue opt_id = sid if sid else f"__name__:{sname}" if opt_id not in seen: seen[opt_id] = {"id": opt_id, "name": sname} return sorted(seen.values(), key=lambda x: (str(x.get("name") or "").lower(), str(x.get("id") or ""))) except Exception: logger.exception("GET /api/supervisors failed") return [] @app.get("/api/applications") async def list_applications( page: int = 1, pageSize: int = 20, name: str = "", authorName: str = "", reviewerName: str = "", status: str = "", reviewStatus: str = "", dateFrom: str = "", dateTo: str = "", sortBy: str = "submittedDate", sortOrder: str = "desc", lifecycle: str = "", authorization: Optional[str] = Header(None), ): """ Danh sách hồ sơ đã nộp (PDF) cho dashboard admin / hội đồng. Dữ liệu lưu cục bộ qua POST /api/applications/submit. """ _require_staff_reviewer(authorization) from src.initiative_db.engine import get_session, is_postgres_enabled from src.initiative_db.submissions import list_submitted_applications page = max(1, page) page_size = max(1, min(100, pageSize)) if is_postgres_enabled(): try: async with get_session() as session: return await list_submitted_applications( session=session, page=page, page_size=page_size, name=name, author_name=authorName, reviewer_name=reviewerName, status=status, review_status=reviewStatus, date_from=dateFrom, date_to=dateTo, sort_by=sortBy, sort_order=sortOrder, lifecycle=lifecycle, ) except Exception: logger.exception("application list query (PostgreSQL) failed; refusing file index fallback while DB is configured") raise HTTPException( status_code=503, detail="Không thể tải danh sách hồ sơ từ cơ sở dữ liệu. Vui lòng thử lại sau hoặc liên hệ quản trị.", ) from None items = _load_submitted_items() lc = (lifecycle or "").strip().lower() def match(row: Dict[str, Any], *, skip_status: bool = False) -> bool: row_status = str(row.get("status") or "") if lc == "inbox": if row_status in ("approved", "rejected"): return False elif lc == "decided": if row_status not in ("approved", "rejected"): return False n = name.strip().lower() if n and n not in str(row.get("name") or "").lower(): return False an = authorName.strip().lower() auth = row.get("author") or {} if an and an not in str(auth.get("name") or "").lower(): return False rn = reviewerName.strip().lower() if rn: rev = row.get("reviewer") or {} if rn not in str(rev.get("name") or "").lower(): return False if not skip_status and status and row_status != status: return False if reviewStatus and str(row.get("reviewStatus") or "") != reviewStatus: return False sd = row.get("submittedDate") if dateFrom and sd: sd_day = str(sd)[:10] if len(sd_day) == 10 and sd_day < dateFrom: return False if dateTo and sd: sd_day = str(sd)[:10] if len(sd_day) == 10 and sd_day > dateTo: return False return True filtered_for_counts = [x for x in items if match(x, skip_status=True)] status_counts = { "approved": sum(1 for x in filtered_for_counts if str(x.get("status") or "") == "approved"), "rejected": sum(1 for x in filtered_for_counts if str(x.get("status") or "") == "rejected"), } filtered = [x for x in items if match(x, skip_status=False)] reverse = sortOrder != "asc" if sortBy == "name": filtered.sort(key=lambda x: str(x.get("name") or ""), reverse=reverse) elif sortBy == "author": filtered.sort( key=lambda x: str((x.get("author") or {}).get("name") or ""), reverse=reverse, ) else: filtered.sort(key=lambda x: str(x.get("submittedDate") or ""), reverse=reverse) total = len(filtered) start = (page - 1) * page_size page_data = filtered[start : start + page_size] total_pages = max(1, (total + page_size - 1) // page_size) if total else 1 return { "data": page_data, "pagination": { "page": page, "pageSize": page_size, "totalItems": total, "totalPages": total_pages, }, "statusCounts": status_counts, } @app.get("/api/applications/{application_id}/admin-result") async def get_application_admin_result( application_id: str, authorization: Optional[str] = Header(None), ): """READ kết quả Duyệt/Từ chối do quản trị ghi nhận (theo ``applicationId``).""" _require_admin_user(authorization) from src.initiative_db.engine import get_session, is_postgres_enabled from src.initiative_db.application_admin_results import get_admin_result_for_application if not is_postgres_enabled(): raise HTTPException(status_code=503, detail="Cần PostgreSQL để đọc kết quả.") async with get_session() as session: row = await get_admin_result_for_application(session, application_id) if row is None: raise HTTPException(status_code=404, detail="Chưa có kết quả cho mã hồ sơ này.") return row @app.get("/api/notifications") async def api_list_notifications( page: int = 1, pageSize: int = 20, authorization: Optional[str] = Header(None), ): """In-app inbox for the current user (applicant receives rows after admin adjudication).""" uid = decode_access_token_user_id(authorization) if uid is None: raise HTTPException(status_code=401, detail="Đăng nhập để xem thông báo.") from src.initiative_db.engine import get_session, is_postgres_enabled from src.initiative_db.user_notifications import list_notifications_for_user if not is_postgres_enabled(): return { "data": [], "pagination": {"page": 1, "pageSize": max(1, min(100, pageSize)), "totalItems": 0, "totalPages": 1}, } async with get_session() as session: return await list_notifications_for_user(session, uid, page=page, page_size=pageSize) @app.get("/api/notifications/unread-count") async def api_notifications_unread_count(authorization: Optional[str] = Header(None)): uid = decode_access_token_user_id(authorization) if uid is None: raise HTTPException(status_code=401, detail="Đăng nhập để xem thông báo.") from src.initiative_db.engine import get_session, is_postgres_enabled from src.initiative_db.user_notifications import count_unread_notifications if not is_postgres_enabled(): return {"count": 0} async with get_session() as session: n = await count_unread_notifications(session, uid) return {"count": n} @app.patch("/api/notifications/{notification_id}/read") async def api_mark_notification_read( notification_id: str, authorization: Optional[str] = Header(None), ): uid = decode_access_token_user_id(authorization) if uid is None: raise HTTPException(status_code=401, detail="Đăng nhập để xem thông báo.") try: nid = uuid.UUID(notification_id.strip()) except ValueError: raise HTTPException(status_code=404, detail="Không tìm thấy thông báo.") from None from src.initiative_db.engine import get_session, is_postgres_enabled from src.initiative_db.user_notifications import mark_notification_read if not is_postgres_enabled(): raise HTTPException(status_code=404, detail="Không tìm thấy thông báo.") async with get_session() as session: ok = await mark_notification_read(session, uid, nid) if not ok: raise HTTPException(status_code=404, detail="Không tìm thấy thông báo.") return {"ok": True} @app.post("/api/applications/{application_id}/admin-result") async def create_application_admin_result( application_id: str, body: AdminApplicationResultBody, authorization: Optional[str] = Header(None), ): """CREATE kết quả — đồng bộ ``initiatives.status`` (approved / rejected).""" admin_uid = _require_admin_user(authorization) from src.initiative_db.engine import get_session, is_postgres_enabled from src.initiative_db.application_admin_results import create_admin_result if not is_postgres_enabled(): raise HTTPException(status_code=503, detail="Cần PostgreSQL để lưu kết quả.") from src.initiative_db.user_notifications import best_effort_notify_applicant_after_admin_decision result: Optional[Dict[str, Any]] = None try: async with get_session() as session: result = await create_admin_result( session, application_id, admin_uid, decision=body.decision, feedback=body.feedback, rationale=body.rationale, ) except LookupError: raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ đã nộp với mã đã chọn.") from None except ValueError as exc: msg = str(exc) if msg == "result_already_exists": raise HTTPException( status_code=409, detail="Đã có kết quả cho hồ sơ này — dùng cập nhật hoặc xóa trước.", ) from None if msg == "invalid_decision": raise HTTPException(status_code=422, detail="Quyết định không hợp lệ.") from None raise HTTPException(status_code=400, detail=msg) from None await best_effort_notify_applicant_after_admin_decision(result) return result @app.put("/api/applications/{application_id}/admin-result") async def update_application_admin_result( application_id: str, body: AdminApplicationResultBody, authorization: Optional[str] = Header(None), ): """Idempotent upsert: tạo hoặc cập nhật kết quả trong một yêu cầu (đồng bộ ``initiatives.status``).""" admin_uid = _require_admin_user(authorization) from src.initiative_db.engine import get_session, is_postgres_enabled from src.initiative_db.application_admin_results import upsert_admin_result if not is_postgres_enabled(): raise HTTPException(status_code=503, detail="Cần PostgreSQL để lưu kết quả.") from src.initiative_db.user_notifications import best_effort_notify_applicant_after_admin_decision result: Optional[Dict[str, Any]] = None try: async with get_session() as session: result = await upsert_admin_result( session, application_id, admin_uid, decision=body.decision, feedback=body.feedback, rationale=body.rationale, ) except LookupError: raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ đã nộp với mã đã chọn.") from None except ValueError as exc: if str(exc) == "invalid_decision": raise HTTPException(status_code=422, detail="Quyết định không hợp lệ.") from None raise HTTPException(status_code=400, detail=str(exc)) from None await best_effort_notify_applicant_after_admin_decision(result) return result @app.delete("/api/applications/{application_id}/admin-result") async def delete_application_admin_result( application_id: str, authorization: Optional[str] = Header(None), ): """DELETE kết quả — trả ``initiatives.status`` về ``submitted``.""" from src.initiative_db.engine import get_session, is_postgres_enabled from src.initiative_db.application_admin_results import delete_admin_result admin_uid = _require_admin_user(authorization) if not is_postgres_enabled(): raise HTTPException(status_code=503, detail="Cần PostgreSQL để xóa kết quả.") try: async with get_session() as session: await delete_admin_result(session, application_id, actor_user_id=admin_uid) except LookupError as exc: if exc.args and exc.args[0] == "result_not_found": raise HTTPException(status_code=404, detail="Chưa có kết quả để xóa.") from None raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ đã nộp với mã đã chọn.") from None return {"deleted": True, "applicationId": application_id} if __name__ == "__main__": import uvicorn # type: ignore uvicorn.run(app, host='0.0.0.0', port='4402', debug=True)