"""Superadmin-only Project CT archive endpoints.""" from __future__ import annotations import hashlib import io import json import os import tempfile from datetime import date from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status from fastapi.responses import FileResponse, Response from starlette.background import BackgroundTask from pydantic import BaseModel from app.admin.vtiger_archive import ARCHIVE_MODULES, run_archive_sync, termination_readiness from app.admin.hub_impact import build_impact_report, save_impact_report from app.admin.archive_bundle import create_archive_bundle, file_sha256, import_archive_bundle from app.core.auth_dependencies import get_current_user from app.core.database import execute_query, execute_query_single router = APIRouter() class ImpactReportRequest(BaseModel): archive_version_id: int vtiger_from: date vtiger_to: date hub_from: date hub_to: date def require_hidden_superadmin(current_user: dict = Depends(get_current_user)) -> dict: # This feature must not disclose its existence to ordinary users. if not current_user.get("is_superadmin"): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found") return current_user @router.get("/admin/vtiger-archive/status") async def archive_status(current_user: dict = Depends(require_hidden_superadmin)): versions = execute_query( """SELECT id,sync_kind,status,started_at,completed_at,source_cutoff,module_counts, critical_errors,control_report,control_approved_at,raw_export_sha256 FROM vtiger_archive_versions ORDER BY id DESC LIMIT 25""" ) or [] checkpoints = execute_query( "SELECT * FROM vtiger_archive_checkpoints ORDER BY module" ) or [] transfers = execute_query( """SELECT id,direction,bundle_sha256,through_version_id,source_instance,status,counts, error_message,started_at,completed_at FROM vtiger_archive_transfers ORDER BY id DESC LIMIT 25""" ) or [] file_archive = execute_query( """SELECT source_module,COUNT(*)::integer AS files,SUM(size_bytes)::bigint AS size_bytes FROM vtiger_archive_files GROUP BY source_module ORDER BY source_module""" ) or [] return {"versions": versions, "checkpoints": checkpoints, "transfers": transfers, "file_archive": file_archive, "readiness": termination_readiness()} @router.post("/admin/vtiger-archive/sync/{sync_kind}") async def archive_sync(sync_kind: str, current_user: dict = Depends(require_hidden_superadmin)): return await run_archive_sync(sync_kind, int(current_user["id"])) @router.post("/admin/vtiger-archive/versions/{version_id}/approve-control") async def approve_control(version_id: int, current_user: dict = Depends(require_hidden_superadmin)): version = execute_query_single( "SELECT * FROM vtiger_archive_versions WHERE id=%s", (version_id,), ) if not version: raise HTTPException(status_code=404, detail="Arkivversionen findes ikke") if version.get("status") != "completed" or version.get("critical_errors"): raise HTTPException(status_code=409, detail="En fejlet eller ukomplet kontrolrapport kan ikke godkendes") counts = version.get("module_counts") or {} missing = [module for module in ARCHIVE_MODULES if module not in counts] if missing: raise HTTPException(status_code=409, detail=f"Kontrolrapport mangler moduler: {', '.join(missing)}") execute_query( """UPDATE vtiger_archive_versions SET control_approved_at=NOW(),control_approved_by=%s WHERE id=%s""", (current_user["id"], version_id), fetch=False, ) return {"approved": True, "version_id": version_id, "readiness": termination_readiness()} @router.get("/admin/vtiger-archive/versions/{version_id}/raw-export") async def raw_export( version_id: int, module: str | None = Query(None), current_user: dict = Depends(require_hidden_superadmin), ): version = execute_query_single("SELECT id FROM vtiger_archive_versions WHERE id=%s", (version_id,)) if not version: raise HTTPException(status_code=404, detail="Arkivversionen findes ikke") params: list[object] = [version_id] module_filter = "" if module: module_filter = "AND module=%s" params.append(module) rows = execute_query( f"""SELECT DISTINCT ON (module,vtiger_id) module,vtiger_id,revision_no, source_created_at,source_modified_at,is_deleted,payload,payload_sha256 FROM vtiger_archive_records WHERE version_id <= %s {module_filter} ORDER BY module,vtiger_id,revision_no DESC""", tuple(params), ) or [] relations = execute_query( """SELECT source_module,source_vtiger_id,field_name,target_vtiger_id,target_module FROM vtiger_archive_relations WHERE version_id <= %s ORDER BY source_module,source_vtiger_id,field_name,target_vtiger_id""", (version_id,), ) or [] version_meta = execute_query_single( """SELECT id,sync_kind,status,started_at,completed_at,source_cutoff,module_counts, warnings,critical_errors,control_report,control_approved_at FROM vtiger_archive_versions WHERE id=%s""", (version_id,), ) or {} export_lines = [{"record_type": "archive_version", "data": dict(version_meta)}] export_lines.extend({"record_type": "entity", "data": dict(row)} for row in rows) export_lines.extend({"record_type": "relation", "data": dict(row)} for row in relations) body = "\n".join(json.dumps(item, ensure_ascii=False, default=str, sort_keys=True) for item in export_lines) + "\n" digest = hashlib.sha256(body.encode("utf-8")).hexdigest() if not module: execute_query( "UPDATE vtiger_archive_versions SET raw_export_sha256=%s WHERE id=%s", (digest, version_id), fetch=False, ) filename = f"project-ct-vtiger-v{version_id}{'-' + module if module else ''}.jsonl" return Response( content=body, media_type="application/x-ndjson", headers={"Content-Disposition": f'attachment; filename="{filename}"', "X-Content-SHA256": digest}, ) @router.get("/admin/vtiger-archive/readiness") async def readiness(current_user: dict = Depends(require_hidden_superadmin)): return termination_readiness() @router.get("/admin/vtiger-archive/bundle.zip") async def export_archive_bundle( through_version_id: int | None = Query(None), current_user: dict = Depends(require_hidden_superadmin), ): if through_version_id is None: latest = execute_query_single( "SELECT id FROM vtiger_archive_versions WHERE status='completed' ORDER BY id DESC LIMIT 1" ) if not latest: raise HTTPException(status_code=409, detail="Der findes ingen fuldført arkivversion") through_version_id = int(latest["id"]) selected = execute_query_single( "SELECT id,status FROM vtiger_archive_versions WHERE id=%s", (through_version_id,), ) if not selected: raise HTTPException(status_code=404, detail="Arkivversionen findes ikke") if selected.get("status") != "completed": raise HTTPException(status_code=409, detail="Kun en fuldført arkivversion kan eksporteres til produktion") transfer_id = execute_query_single( """INSERT INTO vtiger_archive_transfers(direction,through_version_id,status,initiated_by) VALUES ('export',%s,'running',%s) RETURNING id""", (through_version_id, current_user["id"]), )["id"] try: path, manifest = create_archive_bundle(through_version_id, int(current_user["id"])) counts = {name: info["count"] for name, info in manifest["entries"].items()} execute_query( """UPDATE vtiger_archive_transfers SET status='completed',completed_at=NOW(), bundle_sha256=%s,source_instance=%s,counts=%s::jsonb WHERE id=%s""", (manifest["bundle_sha256"], manifest["source_instance"], json.dumps(counts), transfer_id), fetch=False, ) return FileResponse( path, media_type="application/zip", filename=f"project-ct-vtiger-archive-v{through_version_id}.zip", background=BackgroundTask(lambda: os.path.exists(path) and os.unlink(path)), headers={"X-Archive-SHA256": manifest["bundle_sha256"], "X-vTiger-Contacted": "false"}, ) except Exception as exc: execute_query( "UPDATE vtiger_archive_transfers SET status='failed',completed_at=NOW(),error_message=%s WHERE id=%s", (str(exc), transfer_id), fetch=False, ) raise HTTPException(status_code=500, detail=f"Arkivpakken kunne ikke oprettes: {exc}") from exc @router.post("/admin/vtiger-archive/import-bundle") async def import_bundle( file: UploadFile = File(...), current_user: dict = Depends(require_hidden_superadmin), ): if not str(file.filename or "").lower().endswith(".zip"): raise HTTPException(status_code=400, detail="Vælg en Project CT .zip-arkivpakke") fd, path = tempfile.mkstemp(prefix="project-ct-upload-", suffix=".zip") os.close(fd) transfer_id = execute_query_single( """INSERT INTO vtiger_archive_transfers(direction,status,initiated_by) VALUES ('import','running',%s) RETURNING id""", (current_user["id"],), )["id"] try: size = 0 with open(path, "wb") as target: while chunk := await file.read(1024 * 1024): size += len(chunk) if size > 20 * 1024 * 1024 * 1024: raise ValueError("Arkivpakken må højst fylde 20 GB") target.write(chunk) uploaded_sha256 = file_sha256(path) previous = execute_query_single( """SELECT id,through_version_id,counts FROM vtiger_archive_transfers WHERE direction='import' AND status='completed' AND bundle_sha256=%s ORDER BY id DESC LIMIT 1""", (uploaded_sha256,), ) if previous: execute_query( """UPDATE vtiger_archive_transfers SET status='completed',completed_at=NOW(),bundle_sha256=%s, through_version_id=%s,counts=%s::jsonb WHERE id=%s""", (uploaded_sha256, previous.get("through_version_id"), json.dumps(previous.get("counts") or {}), transfer_id), fetch=False, ) return { "bundle_sha256": uploaded_sha256, "counts": previous.get("counts") or {}, "contacted_vtiger": False, "already_imported": True, "original_transfer_id": previous["id"], "transfer_id": transfer_id, } result = import_archive_bundle(path, int(current_user["id"])) execute_query( """UPDATE vtiger_archive_transfers SET status='completed',completed_at=NOW(),bundle_sha256=%s, source_instance=%s,through_version_id=%s,counts=%s::jsonb WHERE id=%s""", (result["bundle_sha256"], result["manifest"].get("source_instance"), result["manifest"].get("through_version_id"), json.dumps(result["counts"]), transfer_id), fetch=False, ) return {**result, "transfer_id": transfer_id} except ValueError as exc: execute_query( "UPDATE vtiger_archive_transfers SET status='failed',completed_at=NOW(),error_message=%s WHERE id=%s", (str(exc), transfer_id), fetch=False, ) raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: execute_query( "UPDATE vtiger_archive_transfers SET status='failed',completed_at=NOW(),error_message=%s WHERE id=%s", (str(exc), transfer_id), fetch=False, ) raise HTTPException(status_code=500, detail=f"Arkivpakken kunne ikke importeres: {exc}") from exc finally: if os.path.exists(path): os.unlink(path) @router.get("/admin/hub-impact/options") async def impact_options(current_user: dict = Depends(require_hidden_superadmin)): versions = execute_query( """SELECT id,sync_kind,completed_at,source_cutoff,module_counts,control_report,control_approved_at FROM vtiger_archive_versions WHERE status='completed' ORDER BY id DESC""" ) or [] reports = execute_query( """SELECT id,archive_version_id,vtiger_from,vtiger_to,hub_from,hub_to,result_sha256,generated_at FROM hub_impact_reports ORDER BY id DESC LIMIT 30""" ) or [] return {"versions": versions, "reports": reports, "readiness": termination_readiness()} @router.post("/admin/hub-impact/reports") async def create_impact_report(payload: ImpactReportRequest, current_user: dict = Depends(require_hidden_superadmin)): try: result = build_impact_report(payload.archive_version_id, payload.vtiger_from, payload.vtiger_to, payload.hub_from, payload.hub_to) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc report_id = save_impact_report(result, int(current_user["id"])) return {"id": report_id, "result": result} @router.get("/admin/hub-impact/reports/{report_id}") async def get_impact_report(report_id: int, current_user: dict = Depends(require_hidden_superadmin)): row = execute_query_single("SELECT * FROM hub_impact_reports WHERE id=%s", (report_id,)) if not row: raise HTTPException(status_code=404, detail="Rapporten findes ikke") return row def _impact_workbook(report: dict) -> bytes: from openpyxl import Workbook from openpyxl.styles import Font, PatternFill wb = Workbook() overview = wb.active overview.title = "Overblik" overview.append(["Project CT · Hub Impact", "vTiger", "Hub", "Forskel", "% ændring"]) for cell in overview[1]: cell.font = Font(bold=True, color="FFFFFF") cell.fill = PatternFill("solid", fgColor="0F4C75") labels = {"hours": "Timer", "time_entries": "Tidsregistreringer", "cases": "Sager", "orders": "Ordrer"} for key, label in labels.items(): overview.append([label, report["vtiger"]["totals"][key], report["hub"]["totals"][key], report["comparison"][key], (report.get("improvement") or {}).get("percent", {}).get(key)]) overview.append([]) overview.append(["Arkivversion", report["archive_version"]["id"]]) overview.append(["vTiger-periode", f"{report['periods']['vtiger']['from']} – {report['periods']['vtiger']['to']}"]) overview.append(["Hub-periode", f"{report['periods']['hub']['from']} – {report['periods']['hub']['to']}"]) overview.freeze_panes = "A2" overview.column_dimensions["A"].width = 28 for col in "BCDE": overview.column_dimensions[col].width = 18 evidence = wb.create_sheet("Effekt") evidence.append(["Dokumenteret ændring"]) for line in (report.get("improvement") or {}).get("evidence", []): evidence.append([line]) evidence.column_dimensions["A"].width = 90 employees = wb.create_sheet("Pr medarbejder") employees.append(["Kilde", "Medarbejder", "E-mail", "Timer", "Registreringer", "Sager", "Ordrer", "Timer/arbejdsdag"]) for source in ("vtiger", "hub"): for row in report[source]["employees"]: employees.append([source, row["name"], row.get("email"), row["hours"], row["time_entries"], row["cases"], row["orders"], row["productivity"]["hours_per_workday"]]) for cell in employees[1]: cell.font = Font(bold=True) employees.freeze_panes = "A2" employees.auto_filter.ref = employees.dimensions anomalies = wb.create_sheet("Afvigelser") anomalies.append(["Kilde", "Type", "ID", "Timer"]) for source in ("vtiger", "hub"): for row in report[source]["anomalies"]: anomalies.append([row.get("source"), row.get("type"), row.get("id"), row.get("hours")]) quality = wb.create_sheet("Datakvalitet") quality.append(["Kilde", "Manglende datoer", "Ikke matchede medarbejdere"]) for source in ("vtiger", "hub"): data = report[source]["data_quality"] quality.append([source, data["missing_dates"], ", ".join(data["unmatched_employees"])]) output = io.BytesIO() wb.save(output) return output.getvalue() @router.get("/admin/hub-impact/reports/{report_id}/export.xlsx") async def export_impact_report(report_id: int, current_user: dict = Depends(require_hidden_superadmin)): row = execute_query_single("SELECT result FROM hub_impact_reports WHERE id=%s", (report_id,)) if not row: raise HTTPException(status_code=404, detail="Rapporten findes ikke") return Response( content=_impact_workbook(row["result"]), media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": f'attachment; filename="project-ct-impact-{report_id}.xlsx"'}, )