Phase 8: Customizable PDF Templates — locale support, admin editor, seed templates
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
42
backend/alembic/versions/007_create_pdf_templates.py
Normal file
42
backend/alembic/versions/007_create_pdf_templates.py
Normal file
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
16
backend/app/models/pdf_template.py
Normal file
16
backend/app/models/pdf_template.py
Normal file
@@ -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)
|
||||
@@ -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}
|
||||
|
||||
59
backend/app/schemas/pdf_template.py
Normal file
59
backend/app/schemas/pdf_template.py
Normal file
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
114
backend/app/services/pdf_template_service.py
Normal file
114
backend/app/services/pdf_template_service.py
Normal file
@@ -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.",
|
||||
)
|
||||
70
backend/app/templates/pdf/seeds/basic_en.html
Normal file
70
backend/app/templates/pdf/seeds/basic_en.html
Normal file
@@ -0,0 +1,70 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: 'Helvetica', 'Arial', sans-serif; font-size: 12pt; color: #333; margin: 40px; }
|
||||
h1 { color: #1a7a8a; border-bottom: 2px solid #1a7a8a; padding-bottom: 8px; }
|
||||
h2 { color: #2a5a6a; margin-top: 24px; }
|
||||
.header { margin-bottom: 24px; }
|
||||
.header p { margin: 2px 0; color: #666; }
|
||||
.section { margin-bottom: 20px; }
|
||||
.memory-entry { background: #f5f9fa; padding: 10px 14px; border-radius: 6px; margin-bottom: 8px; }
|
||||
.memory-entry .category { font-size: 10pt; color: #888; text-transform: uppercase; }
|
||||
.memory-entry .title { font-weight: bold; }
|
||||
.memory-entry .importance { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 9pt; }
|
||||
.importance-critical { background: #fee; color: #c33; }
|
||||
.importance-high { background: #fff3e0; color: #e65100; }
|
||||
.importance-medium { background: #e3f2fd; color: #1565c0; }
|
||||
.importance-low { background: #f5f5f5; color: #666; }
|
||||
.document { border: 1px solid #ddd; padding: 12px; border-radius: 6px; margin-bottom: 10px; }
|
||||
.document .filename { font-weight: bold; color: #1a7a8a; }
|
||||
.document .excerpt { font-size: 10pt; color: #555; white-space: pre-wrap; max-height: 200px; overflow: hidden; }
|
||||
.footer { margin-top: 40px; padding-top: 12px; border-top: 1px solid #ddd; font-size: 9pt; color: #999; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>{{ title }}</h1>
|
||||
<p>Prepared for: {{ user_name }}</p>
|
||||
<p>Generated: {{ generated_at }}</p>
|
||||
</div>
|
||||
|
||||
{% if memories %}
|
||||
<div class="section">
|
||||
<h2>Key Information</h2>
|
||||
{% for m in memories %}
|
||||
<div class="memory-entry">
|
||||
<span class="category">{{ m.category }}</span>
|
||||
<span class="importance importance-{{ m.importance }}">{{ m.importance }}</span>
|
||||
<div class="title">{{ m.title }}</div>
|
||||
<div>{{ m.content }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if documents %}
|
||||
<div class="section">
|
||||
<h2>Document Summaries</h2>
|
||||
{% for d in documents %}
|
||||
<div class="document">
|
||||
<div class="filename">{{ d.original_filename }} ({{ d.doc_type }})</div>
|
||||
<div class="excerpt">{{ d.excerpt }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ai_summary %}
|
||||
<div class="section">
|
||||
<h2>AI Summary</h2>
|
||||
<p>{{ ai_summary }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="footer">
|
||||
Generated by AI Assistant • This document is for informational purposes only.
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
70
backend/app/templates/pdf/seeds/basic_ru.html
Normal file
70
backend/app/templates/pdf/seeds/basic_ru.html
Normal file
@@ -0,0 +1,70 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: 'Helvetica', 'Arial', sans-serif; font-size: 12pt; color: #333; margin: 40px; }
|
||||
h1 { color: #1a7a8a; border-bottom: 2px solid #1a7a8a; padding-bottom: 8px; }
|
||||
h2 { color: #2a5a6a; margin-top: 24px; }
|
||||
.header { margin-bottom: 24px; }
|
||||
.header p { margin: 2px 0; color: #666; }
|
||||
.section { margin-bottom: 20px; }
|
||||
.entry { background: #f5f9fa; padding: 10px 14px; border-radius: 6px; margin-bottom: 8px; }
|
||||
.entry .cat { font-size: 10pt; color: #888; text-transform: uppercase; }
|
||||
.entry .title { font-weight: bold; }
|
||||
.entry .badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 9pt; }
|
||||
.badge-critical { background: #fee; color: #c33; }
|
||||
.badge-high { background: #fff3e0; color: #e65100; }
|
||||
.badge-medium { background: #e3f2fd; color: #1565c0; }
|
||||
.badge-low { background: #f5f5f5; color: #666; }
|
||||
.doc { border: 1px solid #ddd; padding: 12px; border-radius: 6px; margin-bottom: 10px; }
|
||||
.doc .fname { font-weight: bold; color: #1a7a8a; }
|
||||
.doc .excerpt { font-size: 10pt; color: #555; white-space: pre-wrap; max-height: 200px; overflow: hidden; }
|
||||
.footer { margin-top: 40px; padding-top: 12px; border-top: 1px solid #ddd; font-size: 9pt; color: #999; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>{{ title }}</h1>
|
||||
<p>Подготовлено для: {{ user_name }}</p>
|
||||
<p>Дата: {{ generated_at }}</p>
|
||||
</div>
|
||||
|
||||
{% if memories %}
|
||||
<div class="section">
|
||||
<h2>Ключевая информация</h2>
|
||||
{% for m in memories %}
|
||||
<div class="entry">
|
||||
<span class="cat">{{ m.category }}</span>
|
||||
<span class="badge badge-{{ m.importance }}">{{ m.importance }}</span>
|
||||
<div class="title">{{ m.title }}</div>
|
||||
<div>{{ m.content }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if documents %}
|
||||
<div class="section">
|
||||
<h2>Сводка по документам</h2>
|
||||
{% for d in documents %}
|
||||
<div class="doc">
|
||||
<div class="fname">{{ d.original_filename }} ({{ d.doc_type }})</div>
|
||||
<div class="excerpt">{{ d.excerpt }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ai_summary %}
|
||||
<div class="section">
|
||||
<h2>Резюме ИИ</h2>
|
||||
<p>{{ ai_summary }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="footer">
|
||||
Сгенерировано ИИ-ассистентом
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
75
backend/app/templates/pdf/seeds/medical_en.html
Normal file
75
backend/app/templates/pdf/seeds/medical_en.html
Normal file
@@ -0,0 +1,75 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: 'Georgia', serif; font-size: 11pt; color: #222; margin: 30px; }
|
||||
h1 { color: #c0392b; border-bottom: 3px double #c0392b; padding-bottom: 10px; }
|
||||
h2 { color: #8e2323; margin-top: 20px; border-left: 4px solid #c0392b; padding-left: 10px; }
|
||||
.header { margin-bottom: 20px; }
|
||||
.header p { margin: 2px 0; color: #555; }
|
||||
.warning { background: #fff3cd; border: 1px solid #ffc107; padding: 10px; border-radius: 4px; margin-bottom: 16px; font-size: 10pt; }
|
||||
.section { margin-bottom: 16px; }
|
||||
.entry { border-bottom: 1px solid #eee; padding: 8px 0; }
|
||||
.entry .cat { font-size: 9pt; color: #999; text-transform: uppercase; letter-spacing: 1px; }
|
||||
.entry .title { font-weight: bold; font-size: 12pt; }
|
||||
.entry .badge { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 8pt; font-weight: bold; }
|
||||
.badge-critical { background: #e74c3c; color: white; }
|
||||
.badge-high { background: #e67e22; color: white; }
|
||||
.badge-medium { background: #3498db; color: white; }
|
||||
.badge-low { background: #95a5a6; color: white; }
|
||||
.doc { background: #f8f9fa; padding: 10px; margin-bottom: 8px; border-left: 3px solid #3498db; }
|
||||
.doc .fname { font-weight: bold; }
|
||||
.doc .excerpt { font-size: 10pt; color: #444; white-space: pre-wrap; }
|
||||
.footer { margin-top: 30px; padding-top: 10px; border-top: 2px solid #eee; font-size: 8pt; color: #999; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Medical Report: {{ title }}</h1>
|
||||
<p>Patient: {{ user_name }}</p>
|
||||
<p>Date: {{ generated_at }}</p>
|
||||
</div>
|
||||
|
||||
<div class="warning">
|
||||
This report is generated by AI and is for informational purposes only. Always consult a healthcare professional for medical decisions.
|
||||
</div>
|
||||
|
||||
{% if memories %}
|
||||
<div class="section">
|
||||
<h2>Health Profile</h2>
|
||||
{% for m in memories %}
|
||||
<div class="entry">
|
||||
<span class="cat">{{ m.category }}</span>
|
||||
<span class="badge badge-{{ m.importance }}">{{ m.importance }}</span>
|
||||
<div class="title">{{ m.title }}</div>
|
||||
<div>{{ m.content }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if documents %}
|
||||
<div class="section">
|
||||
<h2>Medical Documents</h2>
|
||||
{% for d in documents %}
|
||||
<div class="doc">
|
||||
<div class="fname">{{ d.original_filename }} ({{ d.doc_type }})</div>
|
||||
<div class="excerpt">{{ d.excerpt }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ai_summary %}
|
||||
<div class="section">
|
||||
<h2>AI Clinical Summary</h2>
|
||||
<p>{{ ai_summary }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="footer">
|
||||
Generated by AI Assistant • For informational purposes only • Not a substitute for professional medical advice
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
75
backend/app/templates/pdf/seeds/medical_ru.html
Normal file
75
backend/app/templates/pdf/seeds/medical_ru.html
Normal file
@@ -0,0 +1,75 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: 'Georgia', serif; font-size: 11pt; color: #222; margin: 30px; }
|
||||
h1 { color: #c0392b; border-bottom: 3px double #c0392b; padding-bottom: 10px; }
|
||||
h2 { color: #8e2323; margin-top: 20px; border-left: 4px solid #c0392b; padding-left: 10px; }
|
||||
.header { margin-bottom: 20px; }
|
||||
.header p { margin: 2px 0; color: #555; }
|
||||
.warning { background: #fff3cd; border: 1px solid #ffc107; padding: 10px; border-radius: 4px; margin-bottom: 16px; font-size: 10pt; }
|
||||
.section { margin-bottom: 16px; }
|
||||
.entry { border-bottom: 1px solid #eee; padding: 8px 0; }
|
||||
.entry .cat { font-size: 9pt; color: #999; text-transform: uppercase; letter-spacing: 1px; }
|
||||
.entry .title { font-weight: bold; font-size: 12pt; }
|
||||
.entry .badge { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 8pt; font-weight: bold; }
|
||||
.badge-critical { background: #e74c3c; color: white; }
|
||||
.badge-high { background: #e67e22; color: white; }
|
||||
.badge-medium { background: #3498db; color: white; }
|
||||
.badge-low { background: #95a5a6; color: white; }
|
||||
.doc { background: #f8f9fa; padding: 10px; margin-bottom: 8px; border-left: 3px solid #3498db; }
|
||||
.doc .fname { font-weight: bold; }
|
||||
.doc .excerpt { font-size: 10pt; color: #444; white-space: pre-wrap; }
|
||||
.footer { margin-top: 30px; padding-top: 10px; border-top: 2px solid #eee; font-size: 8pt; color: #999; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Медицинский отчёт: {{ title }}</h1>
|
||||
<p>Пациент: {{ user_name }}</p>
|
||||
<p>Дата: {{ generated_at }}</p>
|
||||
</div>
|
||||
|
||||
<div class="warning">
|
||||
Этот отчёт сгенерирован ИИ и предназначен только для информационных целей. Всегда консультируйтесь с врачом для принятия медицинских решений.
|
||||
</div>
|
||||
|
||||
{% if memories %}
|
||||
<div class="section">
|
||||
<h2>Профиль здоровья</h2>
|
||||
{% for m in memories %}
|
||||
<div class="entry">
|
||||
<span class="cat">{{ m.category }}</span>
|
||||
<span class="badge badge-{{ m.importance }}">{{ m.importance }}</span>
|
||||
<div class="title">{{ m.title }}</div>
|
||||
<div>{{ m.content }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if documents %}
|
||||
<div class="section">
|
||||
<h2>Медицинские документы</h2>
|
||||
{% for d in documents %}
|
||||
<div class="doc">
|
||||
<div class="fname">{{ d.original_filename }} ({{ d.doc_type }})</div>
|
||||
<div class="excerpt">{{ d.excerpt }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ai_summary %}
|
||||
<div class="section">
|
||||
<h2>Клиническое резюме ИИ</h2>
|
||||
<p>{{ ai_summary }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="footer">
|
||||
Сгенерировано ИИ-ассистентом • Только для информационных целей • Не заменяет консультацию врача
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
51
backend/scripts/seed_templates.py
Normal file
51
backend/scripts/seed_templates.py
Normal file
@@ -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())
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Управление пользователями",
|
||||
|
||||
59
frontend/src/api/pdf-templates.ts
Normal file
59
frontend/src/api/pdf-templates.ts
Normal file
@@ -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<PdfTemplate[]> {
|
||||
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<PdfTemplate> {
|
||||
const { data } = await api.post<PdfTemplate>("/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<PdfTemplate> {
|
||||
const { data } = await api.patch<PdfTemplate>(`/admin/pdf-templates/${id}`, updates);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteTemplate(id: string): Promise<void> {
|
||||
await api.delete(`/admin/pdf-templates/${id}`);
|
||||
}
|
||||
|
||||
export async function previewTemplate(html_content: string): Promise<string> {
|
||||
const { data } = await api.post<{ html: string }>("/admin/pdf-templates/preview", { html_content });
|
||||
return data.html;
|
||||
}
|
||||
|
||||
// User-facing
|
||||
export async function getAvailableTemplates(): Promise<PdfTemplateSummary[]> {
|
||||
const { data } = await api.get<{ templates: PdfTemplateSummary[] }>("/pdf/templates");
|
||||
return data.templates;
|
||||
}
|
||||
@@ -13,10 +13,11 @@ export interface PdfListResponse {
|
||||
pdfs: GeneratedPdf[];
|
||||
}
|
||||
|
||||
export async function compilePdf(title: string, documentIds?: string[]): Promise<GeneratedPdf> {
|
||||
export async function compilePdf(title: string, documentIds?: string[], templateId?: string): Promise<GeneratedPdf> {
|
||||
const { data } = await api.post<GeneratedPdf>("/pdf/compile", {
|
||||
title,
|
||||
document_ids: documentIds || [],
|
||||
template_id: templateId || undefined,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
183
frontend/src/pages/admin/pdf-templates.tsx
Normal file
183
frontend/src/pages/admin/pdf-templates.tsx
Normal file
@@ -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<PdfTemplate | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [previewHtml, setPreviewHtml] = useState<string | null>(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 <Navigate to="/" replace />;
|
||||
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{creating ? t("pdf_templates.create") : t("pdf_templates.edit")}
|
||||
</h1>
|
||||
|
||||
<button onClick={() => setShowVars(!showVars)} className="inline-flex items-center gap-1 text-sm text-primary hover:underline">
|
||||
<Info className="h-4 w-4" /> {t("pdf_templates.variables_ref")}
|
||||
</button>
|
||||
|
||||
{showVars && (
|
||||
<div className="rounded-lg border bg-muted/50 p-4 text-sm font-mono space-y-1">
|
||||
<p><strong>{"{{ title }}"}</strong> — {t("pdf_templates.var_title")}</p>
|
||||
<p><strong>{"{{ user_name }}"}</strong> — {t("pdf_templates.var_user_name")}</p>
|
||||
<p><strong>{"{{ generated_at }}"}</strong> — {t("pdf_templates.var_generated_at")}</p>
|
||||
<p><strong>{"{{ memories }}"}</strong> — {t("pdf_templates.var_memories")}</p>
|
||||
<p><strong>{"{{ documents }}"}</strong> — {t("pdf_templates.var_documents")}</p>
|
||||
<p><strong>{"{{ ai_summary }}"}</strong> — {t("pdf_templates.var_ai_summary")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t("pdf_templates.name")}</label>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} required
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t("pdf_templates.locale")}</label>
|
||||
<select value={locale} onChange={(e) => setLocale(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
|
||||
<option value="en">English</option>
|
||||
<option value="ru">Русский</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t("pdf_templates.description")}</label>
|
||||
<input value={description} onChange={(e) => setDescription(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t("pdf_templates.html_content")}</label>
|
||||
<textarea value={htmlContent} onChange={(e) => setHtmlContent(e.target.value)} required rows={16}
|
||||
className="w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-xs font-mono" />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button onClick={resetForm} className="h-9 rounded-md border px-4 text-sm">{t("common.cancel")}</button>
|
||||
<button onClick={handlePreview} disabled={!htmlContent.trim()}
|
||||
className="inline-flex h-9 items-center gap-1 rounded-md border px-4 text-sm hover:bg-accent">
|
||||
<Eye className="h-4 w-4" /> {t("pdf_templates.preview")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editing ? updateMut.mutate() : createMut.mutate()}
|
||||
disabled={!name.trim() || !htmlContent.trim()}
|
||||
className="h-9 rounded-md bg-primary px-4 text-sm text-primary-foreground disabled:opacity-50">
|
||||
{t("common.save")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{previewHtml && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">{t("pdf_templates.preview")}</h3>
|
||||
<iframe srcDoc={previewHtml} className="w-full h-96 rounded-lg border" sandbox="" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold">{t("pdf_templates.title")}</h1>
|
||||
<button onClick={() => setCreating(true)}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground">
|
||||
<Plus className="h-4 w-4" /> {t("common.create")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{templates.length === 0 && (
|
||||
<p className="text-center text-muted-foreground py-8">{t("pdf_templates.no_templates")}</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{templates.map((tmpl) => (
|
||||
<div key={tmpl.id} className="flex items-center gap-3 rounded-lg border bg-card p-4">
|
||||
<FileText className="h-5 w-5 text-primary shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium">{tmpl.name}</p>
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs">{tmpl.locale.toUpperCase()}</span>
|
||||
{tmpl.is_default && <span className="rounded-full bg-primary/10 text-primary px-2 py-0.5 text-xs">{t("pdf_templates.default")}</span>}
|
||||
{!tmpl.is_active && <span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">{t("pdf_templates.inactive")}</span>}
|
||||
</div>
|
||||
{tmpl.description && <p className="text-sm text-muted-foreground truncate">{tmpl.description}</p>}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => startEdit(tmpl)} className="rounded p-2 hover:bg-accent"><Pencil className="h-4 w-4" /></button>
|
||||
{!tmpl.is_default && (
|
||||
<button onClick={() => deleteMut.mutate(tmpl.id)} className="rounded p-2 hover:bg-destructive/10 hover:text-destructive">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { listPdfs, compilePdf, downloadPdf } from "@/api/pdf";
|
||||
import { getAvailableTemplates } from "@/api/pdf-templates";
|
||||
import { FileText, Plus, Download } from "lucide-react";
|
||||
|
||||
export function PdfPage() {
|
||||
@@ -9,18 +10,25 @@ export function PdfPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [templateId, setTemplateId] = useState("");
|
||||
|
||||
const { data: pdfs = [] } = useQuery({
|
||||
queryKey: ["pdfs"],
|
||||
queryFn: listPdfs,
|
||||
});
|
||||
|
||||
const { data: templates = [] } = useQuery({
|
||||
queryKey: ["pdf-templates-available"],
|
||||
queryFn: getAvailableTemplates,
|
||||
});
|
||||
|
||||
const compileMut = useMutation({
|
||||
mutationFn: () => compilePdf(title),
|
||||
mutationFn: () => compilePdf(title, undefined, templateId || undefined),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["pdfs"] });
|
||||
setCreating(false);
|
||||
setTitle("");
|
||||
setTemplateId("");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -44,6 +52,22 @@ export function PdfPage() {
|
||||
placeholder={t("pdf.title_placeholder")}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">{t("pdf.template")}</label>
|
||||
<select
|
||||
value={templateId}
|
||||
onChange={(e) => setTemplateId(e.target.value)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
|
||||
>
|
||||
<option value="">{t("pdf.default_template")}</option>
|
||||
{templates.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name} ({t.locale.toUpperCase()})
|
||||
{t.is_default ? " *" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setCreating(false)} className="h-9 rounded-md border px-4 text-sm">{t("common.cancel")}</button>
|
||||
<button
|
||||
|
||||
@@ -15,6 +15,7 @@ import { NotificationsPage } from "@/pages/notifications";
|
||||
import { PdfPage } from "@/pages/pdf";
|
||||
import { AdminUsersPage } from "@/pages/admin/users";
|
||||
import { AdminSettingsPage } from "@/pages/admin/settings";
|
||||
import { AdminPdfTemplatesPage } from "@/pages/admin/pdf-templates";
|
||||
import { NotFoundPage } from "@/pages/not-found";
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
@@ -45,6 +46,7 @@ export const router = createBrowserRouter([
|
||||
{ path: "admin/skills", element: <AdminSkillsPage /> },
|
||||
{ path: "admin/users", element: <AdminUsersPage /> },
|
||||
{ path: "admin/settings", element: <AdminSettingsPage /> },
|
||||
{ path: "admin/pdf-templates", element: <AdminPdfTemplatesPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
48
plans/phase-8-pdf-templates.md
Normal file
48
plans/phase-8-pdf-templates.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Phase 8: Customizable PDF Templates — Subplan
|
||||
|
||||
## Goal
|
||||
|
||||
Allow admins to manage Jinja2 HTML PDF templates in the database, and let users (or AI) select a template when generating PDFs, with a live preview editor.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Phase 6-7 completed (PDF generation with WeasyPrint, admin patterns)
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] **8.1** Create `backend/app/models/pdf_template.py`: PdfTemplate model (name, description, html_content, is_default, is_active). Register in `models/__init__.py`.
|
||||
- [x] **8.2** Create migration `007_create_pdf_templates.py`: table + seed default template from current report.html. Add `template_id` FK to `generated_pdfs`.
|
||||
- [x] **8.3** Create `backend/app/services/pdf_template_service.py`: CRUD + `validate_jinja2()` helper.
|
||||
- [x] **8.4** Create `backend/app/schemas/pdf_template.py`. Add `template_id` to `GeneratePdfRequest` and `PdfResponse`.
|
||||
- [x] **8.5** Add admin endpoints to `admin.py`: CRUD `/admin/pdf-templates` + POST `/admin/pdf-templates/preview`.
|
||||
- [x] **8.6** Add `GET /pdf/templates` user endpoint (active templates list).
|
||||
- [x] **8.7** Update `pdf_service.generate_pdf_report`: accept template_id, load from DB, render via `from_string()`.
|
||||
- [x] **8.8** Update AI tool `generate_pdf` with optional template_id.
|
||||
- [x] **8.9** Frontend API: `admin-pdf-templates.ts` CRUD + update `pdf.ts` with template support.
|
||||
- [x] **8.10** Update `frontend/src/pages/pdf.tsx`: template selector dropdown.
|
||||
- [x] **8.11** Create `frontend/src/pages/admin/pdf-templates.tsx`: editor with live preview.
|
||||
- [x] **8.12** Template variables documentation panel in editor.
|
||||
- [x] **8.13** Update routes, sidebar, en/ru translations.
|
||||
- [x] **8.14** Tests + verification.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. Default template seeded by migration, identical output to current
|
||||
2. Admin CRUD for templates; cannot delete default
|
||||
3. Invalid Jinja2 rejected with clear error
|
||||
4. Users select template when generating PDF; default used if omitted
|
||||
5. AI tool works with/without template_id
|
||||
6. Live preview in admin editor
|
||||
7. generated_pdfs.template_id records which template was used
|
||||
8. All UI text in en/ru
|
||||
9. Frontend builds clean, backend tests pass
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
**COMPLETED**
|
||||
Reference in New Issue
Block a user