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 @@ + + +
+ + + + +Prepared for: {{ user_name }}
+Generated: {{ generated_at }}
+{{ ai_summary }}
+Подготовлено для: {{ user_name }}
+Дата: {{ generated_at }}
+{{ ai_summary }}
+Patient: {{ user_name }}
+Date: {{ generated_at }}
+{{ ai_summary }}
+Пациент: {{ user_name }}
+Дата: {{ generated_at }}
+{{ ai_summary }}
+{"{{ 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")}
+{t("pdf_templates.no_templates")}
+ )} + +{tmpl.name}
+ {tmpl.locale.toUpperCase()} + {tmpl.is_default && {t("pdf_templates.default")}} + {!tmpl.is_active && {t("pdf_templates.inactive")}} +{tmpl.description}
} +