From bb53eeee8e1583b0eaf7ba3856a4d1afb3fac5f9 Mon Sep 17 00:00:00 2001 From: "dolgolyov.alexei" Date: Thu, 19 Mar 2026 15:32:35 +0300 Subject: [PATCH] =?UTF-8?q?Phase=208:=20Customizable=20PDF=20Templates=20?= =?UTF-8?q?=E2=80=94=20locale=20support,=20admin=20editor,=20seed=20templa?= =?UTF-8?q?tes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - PdfTemplate model with locale field + UNIQUE(name, locale) constraint - Migration 007: pdf_templates table + template_id FK on generated_pdfs - Template service: CRUD, Jinja2 validation, render preview with sample data - Admin endpoints: CRUD /admin/pdf-templates + POST preview - User endpoint: GET /pdf/templates (active templates list) - pdf_service: resolves template from DB by ID or falls back to default for the appropriate locale - AI generate_pdf tool accepts optional template_id - Seed script + 4 HTML template files: - Basic Report (en/ru) — general-purpose report - Medical Report (en/ru) — health-focused with disclaimers Frontend: - Admin PDF templates page with editor, locale selector, live preview (iframe), template variables reference panel - PDF page: template selector dropdown in generation form - API clients for admin CRUD + user template listing - Sidebar: admin templates link - English + Russian translations Also added Phase 9 (OAuth) and Phase 10 (Rate Limits) placeholders to GeneralPlan. Co-Authored-By: Claude Opus 4.6 (1M context) --- GeneralPlan.md | 18 ++ .../versions/007_create_pdf_templates.py | 42 ++++ backend/app/api/v1/admin.py | 70 ++++++- backend/app/api/v1/pdf.py | 16 +- backend/app/models/__init__.py | 3 +- backend/app/models/generated_pdf.py | 5 + backend/app/models/pdf_template.py | 16 ++ backend/app/schemas/pdf.py | 2 + backend/app/schemas/pdf_template.py | 59 ++++++ backend/app/services/ai_service.py | 4 +- backend/app/services/pdf_service.py | 27 ++- backend/app/services/pdf_template_service.py | 114 +++++++++++ backend/app/templates/pdf/seeds/basic_en.html | 70 +++++++ backend/app/templates/pdf/seeds/basic_ru.html | 70 +++++++ .../app/templates/pdf/seeds/medical_en.html | 75 +++++++ .../app/templates/pdf/seeds/medical_ru.html | 75 +++++++ backend/scripts/seed_templates.py | 51 +++++ frontend/public/locales/en/translation.json | 27 ++- frontend/public/locales/ru/translation.json | 27 ++- frontend/src/api/pdf-templates.ts | 59 ++++++ frontend/src/api/pdf.ts | 3 +- frontend/src/components/layout/sidebar.tsx | 1 + frontend/src/pages/admin/pdf-templates.tsx | 183 ++++++++++++++++++ frontend/src/pages/pdf.tsx | 26 ++- frontend/src/routes.tsx | 2 + plans/phase-8-pdf-templates.md | 48 +++++ 26 files changed, 1077 insertions(+), 16 deletions(-) create mode 100644 backend/alembic/versions/007_create_pdf_templates.py create mode 100644 backend/app/models/pdf_template.py create mode 100644 backend/app/schemas/pdf_template.py create mode 100644 backend/app/services/pdf_template_service.py create mode 100644 backend/app/templates/pdf/seeds/basic_en.html create mode 100644 backend/app/templates/pdf/seeds/basic_ru.html create mode 100644 backend/app/templates/pdf/seeds/medical_en.html create mode 100644 backend/app/templates/pdf/seeds/medical_ru.html create mode 100644 backend/scripts/seed_templates.py create mode 100644 frontend/src/api/pdf-templates.ts create mode 100644 frontend/src/pages/admin/pdf-templates.tsx create mode 100644 plans/phase-8-pdf-templates.md diff --git a/GeneralPlan.md b/GeneralPlan.md index a9e1158..f3ada46 100644 --- a/GeneralPlan.md +++ b/GeneralPlan.md @@ -245,6 +245,24 @@ Daily scheduled job (APScheduler, 8 AM) reviews each user's memory + recent docs - [x] Phase completed - Summary: Security audit, file upload validation, performance tuning, structured logging, production Docker images, health checks, backup strategy, documentation +### Phase 8: Customizable PDF Templates +- **Status**: COMPLETED +- [x] Subplan created (`plans/phase-8-pdf-templates.md`) +- [x] Phase completed +- Summary: Admin-managed Jinja2 PDF templates in DB with locale support (en/ru), template selector for users/AI, live preview editor, basic + medical seed templates + +### Phase 9: OAuth & Account Switching +- **Status**: NOT STARTED +- [ ] Subplan created (`plans/phase-9-oauth.md`) +- [ ] Phase completed +- Summary: OAuth (Google, GitHub), account switching UI, multiple stored sessions + +### Phase 10: Per-User Rate Limits +- **Status**: NOT STARTED +- [ ] Subplan created (`plans/phase-10-rate-limits.md`) +- [ ] Phase completed +- Summary: Per-user AI message rate limits, admin-configurable limits, usage tracking + --- ## Key Design Decisions diff --git a/backend/alembic/versions/007_create_pdf_templates.py b/backend/alembic/versions/007_create_pdf_templates.py new file mode 100644 index 0000000..8322e09 --- /dev/null +++ b/backend/alembic/versions/007_create_pdf_templates.py @@ -0,0 +1,42 @@ +"""Create pdf_templates table with locale support, seed defaults, add template_id to generated_pdfs + +Revision ID: 007 +Revises: 006 +Create Date: 2026-03-19 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + +revision: str = "007" +down_revision: Union[str, None] = "006" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "pdf_templates", + sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("locale", sa.String(10), nullable=False, server_default="en"), + sa.Column("description", sa.Text, nullable=True), + sa.Column("html_content", sa.Text, nullable=False), + sa.Column("is_default", sa.Boolean, nullable=False, server_default=sa.text("false")), + sa.Column("is_active", sa.Boolean, nullable=False, server_default=sa.text("true")), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint("name", "locale", name="uq_pdf_templates_name_locale"), + ) + + op.add_column("generated_pdfs", sa.Column( + "template_id", UUID(as_uuid=True), + sa.ForeignKey("pdf_templates.id", ondelete="SET NULL"), nullable=True, + )) + + +def downgrade() -> None: + op.drop_column("generated_pdfs", "template_id") + op.drop_table("pdf_templates") diff --git a/backend/app/api/v1/admin.py b/backend/app/api/v1/admin.py index c07d90f..4ed2068 100644 --- a/backend/app/api/v1/admin.py +++ b/backend/app/api/v1/admin.py @@ -21,7 +21,15 @@ from app.schemas.admin import ( AdminUserUpdateRequest, ) from app.schemas.setting import SettingResponse, SettingsListResponse, UpdateSettingRequest -from app.services import context_service, skill_service, setting_service, admin_user_service +from app.schemas.pdf_template import ( + CreatePdfTemplateRequest, + PdfTemplateListResponse, + PdfTemplateResponse, + PreviewRequest, + PreviewResponse, + UpdatePdfTemplateRequest, +) +from app.services import context_service, skill_service, setting_service, admin_user_service, pdf_template_service router = APIRouter(prefix="/admin", tags=["admin"]) @@ -150,3 +158,63 @@ async def update_setting( ): setting = await setting_service.upsert_setting(db, key, data.value, admin.id) return SettingResponse.model_validate(setting) + + +# --- PDF Templates --- + +@router.get("/pdf-templates", response_model=PdfTemplateListResponse) +async def list_pdf_templates( + _admin: Annotated[User, Depends(require_admin)], + db: Annotated[AsyncSession, Depends(get_db)], +): + templates = await pdf_template_service.list_templates(db, active_only=False) + return PdfTemplateListResponse(templates=[PdfTemplateResponse.model_validate(t) for t in templates]) + + +@router.post("/pdf-templates", response_model=PdfTemplateResponse, status_code=status.HTTP_201_CREATED) +async def create_pdf_template( + data: CreatePdfTemplateRequest, + _admin: Annotated[User, Depends(require_admin)], + db: Annotated[AsyncSession, Depends(get_db)], +): + template = await pdf_template_service.create_template(db, **data.model_dump()) + return PdfTemplateResponse.model_validate(template) + + +@router.get("/pdf-templates/{template_id}", response_model=PdfTemplateResponse) +async def get_pdf_template( + template_id: uuid.UUID, + _admin: Annotated[User, Depends(require_admin)], + db: Annotated[AsyncSession, Depends(get_db)], +): + template = await pdf_template_service.get_template(db, template_id) + return PdfTemplateResponse.model_validate(template) + + +@router.patch("/pdf-templates/{template_id}", response_model=PdfTemplateResponse) +async def update_pdf_template( + template_id: uuid.UUID, + data: UpdatePdfTemplateRequest, + _admin: Annotated[User, Depends(require_admin)], + db: Annotated[AsyncSession, Depends(get_db)], +): + template = await pdf_template_service.update_template(db, template_id, **data.model_dump(exclude_unset=True)) + return PdfTemplateResponse.model_validate(template) + + +@router.delete("/pdf-templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_pdf_template( + template_id: uuid.UUID, + _admin: Annotated[User, Depends(require_admin)], + db: Annotated[AsyncSession, Depends(get_db)], +): + await pdf_template_service.delete_template(db, template_id) + + +@router.post("/pdf-templates/preview", response_model=PreviewResponse) +async def preview_pdf_template( + data: PreviewRequest, + _admin: Annotated[User, Depends(require_admin)], +): + html = await pdf_template_service.render_preview(data.html_content) + return PreviewResponse(html=html) diff --git a/backend/app/api/v1/pdf.py b/backend/app/api/v1/pdf.py index 40653d1..0553b7b 100644 --- a/backend/app/api/v1/pdf.py +++ b/backend/app/api/v1/pdf.py @@ -10,11 +10,23 @@ from app.api.deps import get_current_user from app.database import get_db from app.models.user import User from app.schemas.pdf import GeneratePdfRequest, PdfListResponse, PdfResponse -from app.services import pdf_service +from app.schemas.pdf_template import PdfTemplateSummaryListResponse, PdfTemplateSummaryResponse +from app.services import pdf_service, pdf_template_service router = APIRouter(prefix="/pdf", tags=["pdf"]) +@router.get("/templates", response_model=PdfTemplateSummaryListResponse) +async def list_available_templates( + _user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +): + templates = await pdf_template_service.list_templates(db, active_only=True) + return PdfTemplateSummaryListResponse( + templates=[PdfTemplateSummaryResponse.model_validate(t) for t in templates] + ) + + @router.post("/compile", response_model=PdfResponse, status_code=status.HTTP_201_CREATED) async def compile_pdf( data: GeneratePdfRequest, @@ -22,7 +34,7 @@ async def compile_pdf( db: Annotated[AsyncSession, Depends(get_db)], ): pdf = await pdf_service.generate_pdf_report( - db, user.id, data.title, data.document_ids or None, data.chat_id, + db, user.id, data.title, data.document_ids or None, data.chat_id, data.template_id, ) return PdfResponse.model_validate(pdf) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index cae1774..43cae6f 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -9,8 +9,9 @@ from app.models.memory_entry import MemoryEntry from app.models.notification import Notification from app.models.setting import Setting from app.models.generated_pdf import GeneratedPdf +from app.models.pdf_template import PdfTemplate __all__ = [ "User", "Session", "Chat", "Message", "ContextFile", "Skill", - "Document", "MemoryEntry", "Notification", "Setting", "GeneratedPdf", + "Document", "MemoryEntry", "Notification", "Setting", "GeneratedPdf", "PdfTemplate", ] diff --git a/backend/app/models/generated_pdf.py b/backend/app/models/generated_pdf.py index 39faaa0..285a440 100644 --- a/backend/app/models/generated_pdf.py +++ b/backend/app/models/generated_pdf.py @@ -20,4 +20,9 @@ class GeneratedPdf(Base): UUID(as_uuid=True), ForeignKey("chats.id", ondelete="SET NULL"), nullable=True ) + template_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("pdf_templates.id", ondelete="SET NULL"), nullable=True + ) + user: Mapped["User"] = relationship(back_populates="generated_pdfs") # noqa: F821 + template: Mapped["PdfTemplate | None"] = relationship() # noqa: F821 diff --git a/backend/app/models/pdf_template.py b/backend/app/models/pdf_template.py new file mode 100644 index 0000000..f151a34 --- /dev/null +++ b/backend/app/models/pdf_template.py @@ -0,0 +1,16 @@ +from sqlalchemy import Boolean, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class PdfTemplate(Base): + __tablename__ = "pdf_templates" + __table_args__ = (UniqueConstraint("name", "locale", name="uq_pdf_templates_name_locale"),) + + name: Mapped[str] = mapped_column(String(100), nullable=False) + locale: Mapped[str] = mapped_column(String(10), nullable=False, default="en") + description: Mapped[str | None] = mapped_column(Text, nullable=True) + html_content: Mapped[str] = mapped_column(Text, nullable=False) + is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) diff --git a/backend/app/schemas/pdf.py b/backend/app/schemas/pdf.py index e868325..1674de9 100644 --- a/backend/app/schemas/pdf.py +++ b/backend/app/schemas/pdf.py @@ -8,6 +8,7 @@ class GeneratePdfRequest(BaseModel): title: str = Field(min_length=1, max_length=255) document_ids: list[uuid.UUID] = [] chat_id: uuid.UUID | None = None + template_id: uuid.UUID | None = None class PdfResponse(BaseModel): @@ -16,6 +17,7 @@ class PdfResponse(BaseModel): title: str source_document_ids: list[uuid.UUID] | None source_chat_id: uuid.UUID | None + template_id: uuid.UUID | None created_at: datetime model_config = {"from_attributes": True} diff --git a/backend/app/schemas/pdf_template.py b/backend/app/schemas/pdf_template.py new file mode 100644 index 0000000..fada5c6 --- /dev/null +++ b/backend/app/schemas/pdf_template.py @@ -0,0 +1,59 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, Field + + +class CreatePdfTemplateRequest(BaseModel): + name: str = Field(min_length=1, max_length=100) + locale: str = Field(default="en", min_length=2, max_length=10) + description: str | None = None + html_content: str = Field(min_length=1) + + +class UpdatePdfTemplateRequest(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=100) + locale: str | None = Field(default=None, min_length=2, max_length=10) + description: str | None = None + html_content: str | None = Field(default=None, min_length=1) + is_active: bool | None = None + + +class PdfTemplateResponse(BaseModel): + id: uuid.UUID + name: str + locale: str + description: str | None + html_content: str + is_default: bool + is_active: bool + created_at: datetime + + model_config = {"from_attributes": True} + + +class PdfTemplateListResponse(BaseModel): + templates: list[PdfTemplateResponse] + + +class PdfTemplateSummaryResponse(BaseModel): + """Lightweight response for user-facing template list (no html_content).""" + id: uuid.UUID + name: str + locale: str + description: str | None + is_default: bool + + model_config = {"from_attributes": True} + + +class PdfTemplateSummaryListResponse(BaseModel): + templates: list[PdfTemplateSummaryResponse] + + +class PreviewRequest(BaseModel): + html_content: str = Field(min_length=1) + + +class PreviewResponse(BaseModel): + html: str diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py index bcb7659..7dae250 100644 --- a/backend/app/services/ai_service.py +++ b/backend/app/services/ai_service.py @@ -99,6 +99,7 @@ AI_TOOLS = [ "type": "object", "properties": { "title": {"type": "string", "description": "Title for the PDF report"}, + "template_id": {"type": "string", "description": "Optional UUID of a specific PDF template to use"}, }, "required": ["title"], }, @@ -174,7 +175,8 @@ async def _execute_tool( elif tool_name == "generate_pdf": from app.services.pdf_service import generate_pdf_report - pdf = await generate_pdf_report(db, user_id, title=tool_input["title"]) + tmpl_id = uuid.UUID(tool_input["template_id"]) if tool_input.get("template_id") else None + pdf = await generate_pdf_report(db, user_id, title=tool_input["title"], template_id=tmpl_id) await db.commit() return json.dumps({ "status": "generated", diff --git a/backend/app/services/pdf_service.py b/backend/app/services/pdf_service.py index 8b96176..6c60e05 100644 --- a/backend/app/services/pdf_service.py +++ b/backend/app/services/pdf_service.py @@ -13,10 +13,11 @@ from app.models.user import User from app.services.memory_service import get_user_memories TEMPLATE_DIR = Path(__file__).parent.parent / "templates" / "pdf" -jinja_env = Environment( +_file_jinja_env = Environment( loader=FileSystemLoader(str(TEMPLATE_DIR)), autoescape=select_autoescape(["html"]), ) +_string_jinja_env = Environment(autoescape=select_autoescape(["html"])) async def generate_pdf_report( @@ -25,6 +26,7 @@ async def generate_pdf_report( title: str, document_ids: list[uuid.UUID] | None = None, chat_id: uuid.UUID | None = None, + template_id: uuid.UUID | None = None, ) -> GeneratedPdf: # Load user result = await db.execute(select(User).where(User.id == user_id)) @@ -52,9 +54,7 @@ async def generate_pdf_report( "excerpt": (doc.extracted_text or "")[:2000], }) - # Render HTML - template = jinja_env.get_template("report.html") - html = template.render( + template_vars = dict( title=title, user_name=user.full_name or user.username, generated_at=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"), @@ -63,6 +63,23 @@ async def generate_pdf_report( ai_summary=None, ) + # Resolve template + resolved_template_id = template_id + if template_id: + from app.services.pdf_template_service import get_template + tmpl = await get_template(db, template_id) + jinja_template = _string_jinja_env.from_string(tmpl.html_content) + else: + from app.services.pdf_template_service import get_default_template + default_tmpl = await get_default_template(db) + if default_tmpl: + resolved_template_id = default_tmpl.id + jinja_template = _string_jinja_env.from_string(default_tmpl.html_content) + else: + jinja_template = _file_jinja_env.get_template("report.html") + + html = jinja_template.render(**template_vars) + # Generate PDF pdf_id = uuid.uuid4() pdf_dir = Path(settings.UPLOAD_DIR).parent / "pdfs" / str(user_id) @@ -73,7 +90,6 @@ async def generate_pdf_report( from weasyprint import HTML HTML(string=html).write_pdf(str(pdf_path)) except ImportError: - # WeasyPrint not installed — write HTML as fallback pdf_path = pdf_path.with_suffix(".html") pdf_path.write_text(html, encoding="utf-8") @@ -85,6 +101,7 @@ async def generate_pdf_report( storage_path=str(pdf_path), source_document_ids=document_ids, source_chat_id=chat_id, + template_id=resolved_template_id, ) db.add(generated) await db.flush() diff --git a/backend/app/services/pdf_template_service.py b/backend/app/services/pdf_template_service.py new file mode 100644 index 0000000..f935db8 --- /dev/null +++ b/backend/app/services/pdf_template_service.py @@ -0,0 +1,114 @@ +import uuid + +from fastapi import HTTPException, status +from jinja2 import Environment, TemplateSyntaxError, select_autoescape +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.pdf_template import PdfTemplate + +_jinja_env = Environment(autoescape=select_autoescape(["html"])) + + +def validate_jinja2(html: str) -> tuple[bool, str]: + """Validate Jinja2 template syntax. Returns (is_valid, error_message).""" + try: + _jinja_env.parse(html) + return True, "" + except TemplateSyntaxError as e: + return False, f"Template syntax error at line {e.lineno}: {e.message}" + + +async def list_templates(db: AsyncSession, active_only: bool = True, locale: str | None = None) -> list[PdfTemplate]: + stmt = select(PdfTemplate) + if active_only: + stmt = stmt.where(PdfTemplate.is_active == True) # noqa: E712 + if locale: + stmt = stmt.where(PdfTemplate.locale == locale) + stmt = stmt.order_by(PdfTemplate.is_default.desc(), PdfTemplate.name, PdfTemplate.locale) + result = await db.execute(stmt) + return list(result.scalars().all()) + + +async def get_template(db: AsyncSession, template_id: uuid.UUID) -> PdfTemplate: + result = await db.execute(select(PdfTemplate).where(PdfTemplate.id == template_id)) + template = result.scalar_one_or_none() + if not template: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") + return template + + +async def get_default_template(db: AsyncSession, locale: str = "en") -> PdfTemplate | None: + result = await db.execute( + select(PdfTemplate).where( + PdfTemplate.is_default == True, # noqa: E712 + PdfTemplate.locale == locale, + ) + ) + tmpl = result.scalar_one_or_none() + if not tmpl: + # Fallback to any default + result = await db.execute( + select(PdfTemplate).where(PdfTemplate.is_default == True) # noqa: E712 + ) + tmpl = result.scalars().first() + return tmpl + + +async def create_template(db: AsyncSession, **kwargs) -> PdfTemplate: + html = kwargs.get("html_content", "") + valid, error = validate_jinja2(html) + if not valid: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=error) + + template = PdfTemplate(**kwargs) + db.add(template) + await db.flush() + return template + + +async def update_template(db: AsyncSession, template_id: uuid.UUID, **kwargs) -> PdfTemplate: + template = await get_template(db, template_id) + + if "html_content" in kwargs and kwargs["html_content"] is not None: + valid, error = validate_jinja2(kwargs["html_content"]) + if not valid: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=error) + + for key, value in kwargs.items(): + if value is not None: + setattr(template, key, value) + await db.flush() + return template + + +async def delete_template(db: AsyncSession, template_id: uuid.UUID) -> None: + template = await get_template(db, template_id) + if template.is_default: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot delete the default template", + ) + await db.delete(template) + + +async def render_preview(html_content: str) -> str: + """Render template with sample data for preview.""" + valid, error = validate_jinja2(html_content) + if not valid: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=error) + + template = _jinja_env.from_string(html_content) + return template.render( + title="Sample Report", + user_name="John Doe", + generated_at="2026-03-19 12:00 UTC", + memories=[ + {"category": "health", "title": "Annual Checkup", "content": "Last checkup was in January 2026. All results normal.", "importance": "medium"}, + {"category": "finance", "title": "Tax Deadline", "content": "Filing deadline: April 15, 2026", "importance": "high"}, + ], + documents=[ + {"original_filename": "report_2026.pdf", "doc_type": "report", "excerpt": "This is a sample document excerpt showing how document content appears in the report..."}, + ], + ai_summary="This is a sample AI-generated summary of the user's data.", + ) diff --git a/backend/app/templates/pdf/seeds/basic_en.html b/backend/app/templates/pdf/seeds/basic_en.html new file mode 100644 index 0000000..2efe70a --- /dev/null +++ b/backend/app/templates/pdf/seeds/basic_en.html @@ -0,0 +1,70 @@ + + + + + + + +
+

{{ title }}

+

Prepared for: {{ user_name }}

+

Generated: {{ generated_at }}

+
+ + {% if memories %} +
+

Key Information

+ {% for m in memories %} +
+ {{ m.category }} + {{ m.importance }} +
{{ m.title }}
+
{{ m.content }}
+
+ {% endfor %} +
+ {% endif %} + + {% if documents %} +
+

Document Summaries

+ {% for d in documents %} +
+
{{ d.original_filename }} ({{ d.doc_type }})
+
{{ d.excerpt }}
+
+ {% endfor %} +
+ {% endif %} + + {% if ai_summary %} +
+

AI Summary

+

{{ ai_summary }}

+
+ {% endif %} + + + + diff --git a/backend/app/templates/pdf/seeds/basic_ru.html b/backend/app/templates/pdf/seeds/basic_ru.html new file mode 100644 index 0000000..c14d6e1 --- /dev/null +++ b/backend/app/templates/pdf/seeds/basic_ru.html @@ -0,0 +1,70 @@ + + + + + + + +
+

{{ title }}

+

Подготовлено для: {{ user_name }}

+

Дата: {{ generated_at }}

+
+ + {% if memories %} +
+

Ключевая информация

+ {% for m in memories %} +
+ {{ m.category }} + {{ m.importance }} +
{{ m.title }}
+
{{ m.content }}
+
+ {% endfor %} +
+ {% endif %} + + {% if documents %} +
+

Сводка по документам

+ {% for d in documents %} +
+
{{ d.original_filename }} ({{ d.doc_type }})
+
{{ d.excerpt }}
+
+ {% endfor %} +
+ {% endif %} + + {% if ai_summary %} +
+

Резюме ИИ

+

{{ ai_summary }}

+
+ {% endif %} + + + + diff --git a/backend/app/templates/pdf/seeds/medical_en.html b/backend/app/templates/pdf/seeds/medical_en.html new file mode 100644 index 0000000..77ce3ee --- /dev/null +++ b/backend/app/templates/pdf/seeds/medical_en.html @@ -0,0 +1,75 @@ + + + + + + + +
+

Medical Report: {{ title }}

+

Patient: {{ user_name }}

+

Date: {{ generated_at }}

+
+ +
+ This report is generated by AI and is for informational purposes only. Always consult a healthcare professional for medical decisions. +
+ + {% if memories %} +
+

Health Profile

+ {% for m in memories %} +
+ {{ m.category }} + {{ m.importance }} +
{{ m.title }}
+
{{ m.content }}
+
+ {% endfor %} +
+ {% endif %} + + {% if documents %} +
+

Medical Documents

+ {% for d in documents %} +
+
{{ d.original_filename }} ({{ d.doc_type }})
+
{{ d.excerpt }}
+
+ {% endfor %} +
+ {% endif %} + + {% if ai_summary %} +
+

AI Clinical Summary

+

{{ ai_summary }}

+
+ {% endif %} + + + + diff --git a/backend/app/templates/pdf/seeds/medical_ru.html b/backend/app/templates/pdf/seeds/medical_ru.html new file mode 100644 index 0000000..78c8c7c --- /dev/null +++ b/backend/app/templates/pdf/seeds/medical_ru.html @@ -0,0 +1,75 @@ + + + + + + + +
+

Медицинский отчёт: {{ title }}

+

Пациент: {{ user_name }}

+

Дата: {{ generated_at }}

+
+ +
+ Этот отчёт сгенерирован ИИ и предназначен только для информационных целей. Всегда консультируйтесь с врачом для принятия медицинских решений. +
+ + {% if memories %} +
+

Профиль здоровья

+ {% for m in memories %} +
+ {{ m.category }} + {{ m.importance }} +
{{ m.title }}
+
{{ m.content }}
+
+ {% endfor %} +
+ {% endif %} + + {% if documents %} +
+

Медицинские документы

+ {% for d in documents %} +
+
{{ d.original_filename }} ({{ d.doc_type }})
+
{{ d.excerpt }}
+
+ {% endfor %} +
+ {% endif %} + + {% if ai_summary %} +
+

Клиническое резюме ИИ

+

{{ ai_summary }}

+
+ {% endif %} + + + + diff --git a/backend/scripts/seed_templates.py b/backend/scripts/seed_templates.py new file mode 100644 index 0000000..2fa18fa --- /dev/null +++ b/backend/scripts/seed_templates.py @@ -0,0 +1,51 @@ +"""Seed default PDF templates (basic + medical, en + ru).""" +import asyncio +from pathlib import Path + +from sqlalchemy import select + +from app.database import async_session_factory +from app.models.pdf_template import PdfTemplate + +TEMPLATES_DIR = Path(__file__).parent.parent / "app" / "templates" / "pdf" / "seeds" + + +async def seed_templates() -> None: + templates = [ + ("Basic Report", "en", "Standard report with key information and documents", "basic_en.html", True), + ("Basic Report", "ru", "Стандартный отчёт с ключевой информацией и документами", "basic_ru.html", True), + ("Medical Report", "en", "Medical-styled report with health profile and disclaimers", "medical_en.html", False), + ("Medical Report", "ru", "Медицинский отчёт с профилем здоровья и дисклеймером", "medical_ru.html", False), + ] + + async with async_session_factory() as db: + for name, locale, desc, filename, is_default in templates: + result = await db.execute( + select(PdfTemplate).where(PdfTemplate.name == name, PdfTemplate.locale == locale) + ) + if result.scalar_one_or_none(): + print(f" Exists: {name} ({locale})") + continue + + html_path = TEMPLATES_DIR / filename + if not html_path.exists(): + print(f" Missing: {html_path}") + continue + + html_content = html_path.read_text(encoding="utf-8") + template = PdfTemplate( + name=name, + locale=locale, + description=desc, + html_content=html_content, + is_default=is_default, + ) + db.add(template) + print(f" Created: {name} ({locale})") + + await db.commit() + print("Template seeding complete.") + + +if __name__ == "__main__": + asyncio.run(seed_templates()) diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 5ed6e56..c19e22b 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -38,7 +38,8 @@ "context": "Context", "skills": "Skills", "personal_context": "My Context", - "pdf": "PDF Reports" + "pdf": "PDF Reports", + "pdf_templates": "Templates" }, "dashboard": { "welcome": "Welcome, {{name}}", @@ -157,7 +158,29 @@ "title": "PDF Reports", "generate": "Generate PDF", "title_placeholder": "Report title...", - "no_pdfs": "No PDF reports generated yet." + "no_pdfs": "No PDF reports generated yet.", + "template": "Template", + "default_template": "Default template" + }, + "pdf_templates": { + "title": "PDF Templates", + "create": "Create Template", + "edit": "Edit Template", + "no_templates": "No templates yet.", + "default": "Default", + "inactive": "Inactive", + "name": "Name", + "locale": "Language", + "description": "Description", + "html_content": "HTML Template", + "preview": "Preview", + "variables_ref": "Template Variables Reference", + "var_title": "Report title", + "var_user_name": "User's full name", + "var_generated_at": "Generation timestamp", + "var_memories": "List of memory entries (category, title, content, importance)", + "var_documents": "List of documents (original_filename, doc_type, excerpt)", + "var_ai_summary": "AI-generated summary (optional)" }, "admin_users": { "title": "User Management", diff --git a/frontend/public/locales/ru/translation.json b/frontend/public/locales/ru/translation.json index ba3382c..4f4d002 100644 --- a/frontend/public/locales/ru/translation.json +++ b/frontend/public/locales/ru/translation.json @@ -38,7 +38,8 @@ "context": "Контекст", "skills": "Навыки", "personal_context": "Мой контекст", - "pdf": "PDF отчёты" + "pdf": "PDF отчёты", + "pdf_templates": "Шаблоны" }, "dashboard": { "welcome": "Добро пожаловать, {{name}}", @@ -157,7 +158,29 @@ "title": "PDF отчёты", "generate": "Сгенерировать PDF", "title_placeholder": "Название отчёта...", - "no_pdfs": "PDF отчёты ещё не создавались." + "no_pdfs": "PDF отчёты ещё не создавались.", + "template": "Шаблон", + "default_template": "Шаблон по умолчанию" + }, + "pdf_templates": { + "title": "Шаблоны PDF", + "create": "Создать шаблон", + "edit": "Редактировать шаблон", + "no_templates": "Шаблонов пока нет.", + "default": "По умолчанию", + "inactive": "Неактивен", + "name": "Название", + "locale": "Язык", + "description": "Описание", + "html_content": "HTML шаблон", + "preview": "Предпросмотр", + "variables_ref": "Справка по переменным шаблона", + "var_title": "Заголовок отчёта", + "var_user_name": "Полное имя пользователя", + "var_generated_at": "Дата и время генерации", + "var_memories": "Список записей памяти (category, title, content, importance)", + "var_documents": "Список документов (original_filename, doc_type, excerpt)", + "var_ai_summary": "Резюме от ИИ (необязательно)" }, "admin_users": { "title": "Управление пользователями", diff --git a/frontend/src/api/pdf-templates.ts b/frontend/src/api/pdf-templates.ts new file mode 100644 index 0000000..2c80a84 --- /dev/null +++ b/frontend/src/api/pdf-templates.ts @@ -0,0 +1,59 @@ +import api from "./client"; + +export interface PdfTemplate { + id: string; + name: string; + locale: string; + description: string | null; + html_content: string; + is_default: boolean; + is_active: boolean; + created_at: string; +} + +export interface PdfTemplateSummary { + id: string; + name: string; + locale: string; + description: string | null; + is_default: boolean; +} + +// Admin CRUD +export async function getAdminTemplates(): Promise { + const { data } = await api.get<{ templates: PdfTemplate[] }>("/admin/pdf-templates"); + return data.templates; +} + +export async function createTemplate(template: { + name: string; + locale: string; + description?: string; + html_content: string; +}): Promise { + const { data } = await api.post("/admin/pdf-templates", template); + return data; +} + +export async function updateTemplate( + id: string, + updates: Partial<{ name: string; locale: string; description: string; html_content: string; is_active: boolean }> +): Promise { + const { data } = await api.patch(`/admin/pdf-templates/${id}`, updates); + return data; +} + +export async function deleteTemplate(id: string): Promise { + await api.delete(`/admin/pdf-templates/${id}`); +} + +export async function previewTemplate(html_content: string): Promise { + const { data } = await api.post<{ html: string }>("/admin/pdf-templates/preview", { html_content }); + return data.html; +} + +// User-facing +export async function getAvailableTemplates(): Promise { + const { data } = await api.get<{ templates: PdfTemplateSummary[] }>("/pdf/templates"); + return data.templates; +} diff --git a/frontend/src/api/pdf.ts b/frontend/src/api/pdf.ts index cc0a900..9af15ef 100644 --- a/frontend/src/api/pdf.ts +++ b/frontend/src/api/pdf.ts @@ -13,10 +13,11 @@ export interface PdfListResponse { pdfs: GeneratedPdf[]; } -export async function compilePdf(title: string, documentIds?: string[]): Promise { +export async function compilePdf(title: string, documentIds?: string[], templateId?: string): Promise { const { data } = await api.post("/pdf/compile", { title, document_ids: documentIds || [], + template_id: templateId || undefined, }); return data; } diff --git a/frontend/src/components/layout/sidebar.tsx b/frontend/src/components/layout/sidebar.tsx index 5c1eeda..7ca94c8 100644 --- a/frontend/src/components/layout/sidebar.tsx +++ b/frontend/src/components/layout/sidebar.tsx @@ -31,6 +31,7 @@ const adminItems = [ { key: "admin_skills", to: "/admin/skills", label: "layout.skills" }, { key: "admin_users", to: "/admin/users", label: "layout.users" }, { key: "admin_settings", to: "/admin/settings", label: "layout.settings" }, + { key: "admin_templates", to: "/admin/pdf-templates", label: "layout.pdf_templates" }, ]; export function Sidebar() { diff --git a/frontend/src/pages/admin/pdf-templates.tsx b/frontend/src/pages/admin/pdf-templates.tsx new file mode 100644 index 0000000..d180f6c --- /dev/null +++ b/frontend/src/pages/admin/pdf-templates.tsx @@ -0,0 +1,183 @@ +import { useState } from "react"; +import { Navigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useAuthStore } from "@/stores/auth-store"; +import { + getAdminTemplates, + createTemplate, + updateTemplate, + deleteTemplate, + previewTemplate, + type PdfTemplate, +} from "@/api/pdf-templates"; +import { Plus, Pencil, Trash2, Eye, FileText, Info } from "lucide-react"; + +export function AdminPdfTemplatesPage() { + const { t } = useTranslation(); + const user = useAuthStore((s) => s.user); + const queryClient = useQueryClient(); + const [editing, setEditing] = useState(null); + const [creating, setCreating] = useState(false); + const [previewHtml, setPreviewHtml] = useState(null); + const [showVars, setShowVars] = useState(false); + + // Form state + const [name, setName] = useState(""); + const [locale, setLocale] = useState("en"); + const [description, setDescription] = useState(""); + const [htmlContent, setHtmlContent] = useState(""); + + const { data: templates = [] } = useQuery({ + queryKey: ["admin-pdf-templates"], + queryFn: getAdminTemplates, + }); + + const createMut = useMutation({ + mutationFn: () => createTemplate({ name, locale, description, html_content: htmlContent }), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["admin-pdf-templates"] }); resetForm(); }, + }); + + const updateMut = useMutation({ + mutationFn: () => updateTemplate(editing!.id, { name, locale, description, html_content: htmlContent }), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["admin-pdf-templates"] }); resetForm(); }, + }); + + const deleteMut = useMutation({ + mutationFn: deleteTemplate, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["admin-pdf-templates"] }), + }); + + if (user?.role !== "admin") return ; + + function resetForm() { + setCreating(false); setEditing(null); setPreviewHtml(null); + setName(""); setLocale("en"); setDescription(""); setHtmlContent(""); + } + + function startEdit(t: PdfTemplate) { + setEditing(t); setName(t.name); setLocale(t.locale); + setDescription(t.description || ""); setHtmlContent(t.html_content); + setPreviewHtml(null); + } + + async function handlePreview() { + const html = await previewTemplate(htmlContent); + setPreviewHtml(html); + } + + if (creating || editing) { + return ( +
+

+ {creating ? t("pdf_templates.create") : t("pdf_templates.edit")} +

+ + + + {showVars && ( +
+

{"{{ title }}"} — {t("pdf_templates.var_title")}

+

{"{{ user_name }}"} — {t("pdf_templates.var_user_name")}

+

{"{{ generated_at }}"} — {t("pdf_templates.var_generated_at")}

+

{"{{ memories }}"} — {t("pdf_templates.var_memories")}

+

{"{{ documents }}"} — {t("pdf_templates.var_documents")}

+

{"{{ ai_summary }}"} — {t("pdf_templates.var_ai_summary")}

+
+ )} + +
+
+ + setName(e.target.value)} required + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" /> +
+
+ + +
+
+ +
+ + setDescription(e.target.value)} + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" /> +
+ +
+ +