diff --git a/backend/app/api/exports.py b/backend/app/api/exports.py new file mode 100644 index 0000000..c468c24 --- /dev/null +++ b/backend/app/api/exports.py @@ -0,0 +1,79 @@ +import os +import uuid + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import FileResponse +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_user +from app.database import get_db +from app.models.export_file import ExportFile, ExportFormat +from app.models.protocol import Protocol +from app.models.user import User +from app.schemas.export_file import ExportFileResponse, ExportRequest +from app.services.export import export_docx, export_markdown, export_pdf + +router = APIRouter(prefix="/api/exports", tags=["exports"]) + +EXPORTERS = { + ExportFormat.md: export_markdown, + ExportFormat.docx: export_docx, + ExportFormat.pdf: export_pdf, +} + +MEDIA_TYPES = { + ExportFormat.md: "text/markdown", + ExportFormat.docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ExportFormat.pdf: "application/pdf", +} + + +@router.post("/protocol/{protocol_id}", response_model=ExportFileResponse, status_code=201) +async def create_export( + protocol_id: uuid.UUID, + data: ExportRequest, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + result = await db.execute( + select(Protocol).where(Protocol.id == protocol_id) + ) + protocol = result.scalar_one_or_none() + if not protocol: + raise HTTPException(404, "Protocol not found") + + exporter = EXPORTERS[data.format] + file_path = exporter(protocol.content, protocol.id) + file_size = os.path.getsize(file_path) + + export_file = ExportFile( + protocol_id=protocol_id, + format=data.format, + file_path=file_path, + file_size=file_size, + ) + db.add(export_file) + await db.commit() + await db.refresh(export_file) + return export_file + + +@router.get("/{export_id}/download") +async def download_export( + export_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + result = await db.execute( + select(ExportFile).where(ExportFile.id == export_id) + ) + export_file = result.scalar_one_or_none() + if not export_file or not os.path.exists(export_file.file_path): + raise HTTPException(404, "Export file not found") + + return FileResponse( + path=export_file.file_path, + media_type=MEDIA_TYPES[export_file.format], + filename=f"protocol.{export_file.format.value}", + ) diff --git a/backend/app/main.py b/backend/app/main.py index 76197c3..9176ee0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,8 +4,10 @@ from fastapi import FastAPI from sqlalchemy import select from app.api.auth import router as auth_router +from app.api.exports import router as exports_router from app.api.protocols import router as protocols_router from app.api.recordings import router as recordings_router +from app.api.settings import router as settings_router from app.api.templates import router as templates_router from app.database import async_session from app.models.prompt_template import PromptTemplate @@ -33,6 +35,8 @@ app.include_router(auth_router) app.include_router(recordings_router) app.include_router(protocols_router) app.include_router(templates_router) +app.include_router(exports_router) +app.include_router(settings_router) @app.get("/api/health") diff --git a/backend/app/schemas/export_file.py b/backend/app/schemas/export_file.py new file mode 100644 index 0000000..cf59c75 --- /dev/null +++ b/backend/app/schemas/export_file.py @@ -0,0 +1,20 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel + +from app.models.export_file import ExportFormat + + +class ExportRequest(BaseModel): + format: ExportFormat + + +class ExportFileResponse(BaseModel): + id: uuid.UUID + protocol_id: uuid.UUID + format: ExportFormat + file_size: int + created_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/services/export.py b/backend/app/services/export.py new file mode 100644 index 0000000..514e947 --- /dev/null +++ b/backend/app/services/export.py @@ -0,0 +1,105 @@ +import json +import os +import uuid + +import markdown +from docx import Document +from docx.shared import Pt +from jinja2 import Template +from weasyprint import HTML + +from app.config import settings + +SECTION_LABELS = { + "date_participants_topic": "Дата, участники, тема встречи", + "process_description": "Описание обследуемого процесса", + "current_state": "Текущее состояние (as-is)", + "problems": "Выявленные проблемы / узкие места", + "agreements": "Договорённости и решения", + "open_questions": "Открытые вопросы", + "tasks": "Задачи с ответственными и сроками", + "next_steps": "Следующие шаги", + "client_goals": "Верхнеуровневые цели клиента", + "project_parameters": "Параметры будущего проекта", + "budget_constraints": "Бюджетные ограничения", + "time_constraints": "Ограничения по срокам", + "decision_makers": "Лица, принимающие решения (ЛПР)", + "beneficiaries": "Бенефициары", + "influencers": "Лица, влияющие на принятие решения", + "discussed_topics": "Обсуждённые вопросы", + "decisions": "Принятые решения", +} + + +def _format_section_value(value) -> str: + if isinstance(value, list): + if value and isinstance(value[0], dict): + lines = [] + for item in value: + parts = [f"{k}: {v}" for k, v in item.items()] + lines.append("- " + ", ".join(parts)) + return "\n".join(lines) + return "\n".join(f"- {item}" for item in value) + return str(value) + + +def _protocol_to_markdown(content: dict) -> str: + lines = ["# Протокол встречи\n"] + for key, value in content.items(): + if key == "raw": + lines.append(str(value)) + continue + label = SECTION_LABELS.get(key, key) + lines.append(f"## {label}\n") + lines.append(_format_section_value(value)) + lines.append("") + return "\n".join(lines) + + +def export_markdown(content: dict, protocol_id: uuid.UUID) -> str: + os.makedirs(settings.export_dir, exist_ok=True) + file_path = os.path.join(settings.export_dir, f"{protocol_id}.md") + md_text = _protocol_to_markdown(content) + with open(file_path, "w", encoding="utf-8") as f: + f.write(md_text) + return file_path + + +def export_docx(content: dict, protocol_id: uuid.UUID) -> str: + os.makedirs(settings.export_dir, exist_ok=True) + file_path = os.path.join(settings.export_dir, f"{protocol_id}.docx") + + doc = Document() + doc.add_heading("Протокол встречи", level=0) + + for key, value in content.items(): + if key == "raw": + doc.add_paragraph(str(value)) + continue + label = SECTION_LABELS.get(key, key) + doc.add_heading(label, level=1) + text = _format_section_value(value) + for line in text.split("\n"): + doc.add_paragraph(line, style="List Bullet" if line.startswith("- ") else None) + + doc.save(file_path) + return file_path + + +def export_pdf(content: dict, protocol_id: uuid.UUID) -> str: + os.makedirs(settings.export_dir, exist_ok=True) + file_path = os.path.join(settings.export_dir, f"{protocol_id}.pdf") + + md_text = _protocol_to_markdown(content) + html_body = markdown.markdown(md_text) + html_full = f""" +
+ + {html_body} + """ + HTML(string=html_full).write_pdf(file_path) + return file_path diff --git a/backend/tests/test_export.py b/backend/tests/test_export.py new file mode 100644 index 0000000..1d6c7d4 --- /dev/null +++ b/backend/tests/test_export.py @@ -0,0 +1,33 @@ +import pytest +from app.services.export import _protocol_to_markdown, _format_section_value + + +def test_format_section_value_string(): + assert _format_section_value("Hello world") == "Hello world" + + +def test_format_section_value_list(): + result = _format_section_value(["item1", "item2"]) + assert "- item1" in result + assert "- item2" in result + + +def test_format_section_value_list_of_dicts(): + tasks = [{"task": "Do X", "responsible": "Alice", "deadline": "2026-04-10"}] + result = _format_section_value(tasks) + assert "task: Do X" in result + assert "responsible: Alice" in result + + +def test_protocol_to_markdown(): + content = { + "date_participants_topic": "01.04.2026, Иванов, Петров", + "problems": ["Проблема 1", "Проблема 2"], + "next_steps": "Назначить следующую встречу", + } + md = _protocol_to_markdown(content) + assert "# Протокол встречи" in md + assert "## Дата, участники, тема встречи" in md + assert "## Выявленные проблемы" in md + assert "- Проблема 1" in md + assert "## Следующие шаги" in md