Phase 6: PDF & Polish — PDF generation, admin users/settings, AI tool

Backend:
- Setting + GeneratedPdf models, Alembic migration with default settings seed
- PDF generation service (WeasyPrint + Jinja2 with autoescape)
- Health report HTML template with memory entries + document excerpts
- Admin user management: list, create, update (role/max_chats/is_active)
- Admin settings: self_registration_enabled, default_max_chats
- Self-registration check wired into auth register endpoint
- default_max_chats applied to new user registrations
- AI tool: generate_pdf creates health compilation PDFs
- PDF compile/list/download API endpoints
- WeasyPrint system deps added to Dockerfile

Frontend:
- PDF reports page with generate + download
- Admin users page with create/edit/activate/deactivate
- Admin settings page with self-registration toggle + max chats
- Extended sidebar with PDF reports + admin users/settings links
- English + Russian translations for all new UI

Review fixes applied:
- Jinja2 autoescape enabled (XSS prevention in PDFs)
- db.refresh after flush (created_at populated correctly)
- storage_path removed from API response (no internal path leak)
- Role field uses Literal["user", "admin"] validation
- React hooks called before conditional returns (rules of hooks)
- default_max_chats setting now applied during registration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 14:37:43 +03:00
parent 04e3ae8319
commit fed6a3df1b
33 changed files with 1219 additions and 10 deletions

View File

@@ -4,6 +4,7 @@ WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libpq-dev \
libpango-1.0-0 libcairo2 libgdk-pixbuf-2.0-0 libffi-dev \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml .

View File

@@ -0,0 +1,52 @@
"""Create settings and generated_pdfs tables
Revision ID: 006
Revises: 005
Create Date: 2026-03-19
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID, JSONB, ARRAY
revision: str = "006"
down_revision: Union[str, None] = "005"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"settings",
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("key", sa.String(100), unique=True, nullable=False, index=True),
sa.Column("value", JSONB, nullable=False),
sa.Column("updated_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
# Seed default settings
op.execute("""
INSERT INTO settings (id, key, value) VALUES
(gen_random_uuid(), 'self_registration_enabled', 'true'),
(gen_random_uuid(), 'default_max_chats', '10')
""")
op.create_table(
"generated_pdfs",
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("title", sa.String(255), nullable=False),
sa.Column("storage_path", sa.Text, nullable=False),
sa.Column("source_document_ids", ARRAY(UUID(as_uuid=True)), nullable=True),
sa.Column("source_chat_id", UUID(as_uuid=True), sa.ForeignKey("chats.id", ondelete="SET NULL"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
def downgrade() -> None:
op.drop_table("generated_pdfs")
op.drop_table("settings")

View File

@@ -1,7 +1,7 @@
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, status
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import require_admin
@@ -14,7 +14,14 @@ from app.schemas.skill import (
SkillResponse,
UpdateSkillRequest,
)
from app.services import context_service, skill_service
from app.schemas.admin import (
AdminUserCreateRequest,
AdminUserListResponse,
AdminUserResponse,
AdminUserUpdateRequest,
)
from app.schemas.setting import SettingResponse, SettingsListResponse, UpdateSettingRequest
from app.services import context_service, skill_service, setting_service, admin_user_service
router = APIRouter(prefix="/admin", tags=["admin"])
@@ -81,3 +88,65 @@ async def delete_general_skill(
db: Annotated[AsyncSession, Depends(get_db)],
):
await skill_service.delete_general_skill(db, skill_id)
# --- Users ---
@router.get("/users", response_model=AdminUserListResponse)
async def list_users(
_admin: Annotated[User, Depends(require_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
limit: int = Query(default=50, le=200),
offset: int = Query(default=0),
):
users, total = await admin_user_service.list_users(db, limit, offset)
return AdminUserListResponse(
users=[AdminUserResponse.model_validate(u) for u in users],
total=total,
)
@router.post("/users", response_model=AdminUserResponse, status_code=status.HTTP_201_CREATED)
async def create_user(
data: AdminUserCreateRequest,
_admin: Annotated[User, Depends(require_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
user = await admin_user_service.create_user(
db, data.email, data.username, data.password,
data.full_name, data.role, data.max_chats,
)
return AdminUserResponse.model_validate(user)
@router.patch("/users/{user_id}", response_model=AdminUserResponse)
async def update_user(
user_id: uuid.UUID,
data: AdminUserUpdateRequest,
_admin: Annotated[User, Depends(require_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
user = await admin_user_service.update_user(db, user_id, **data.model_dump(exclude_unset=True))
return AdminUserResponse.model_validate(user)
# --- Settings ---
@router.get("/settings", response_model=SettingsListResponse)
async def get_settings(
_admin: Annotated[User, Depends(require_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
settings = await setting_service.get_all_settings(db)
return SettingsListResponse(settings=[SettingResponse.model_validate(s) for s in settings])
@router.patch("/settings/{key}", response_model=SettingResponse)
async def update_setting(
key: str,
data: UpdateSettingRequest,
admin: Annotated[User, Depends(require_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
setting = await setting_service.upsert_setting(db, key, data.value, admin.id)
return SettingResponse.model_validate(setting)

View File

@@ -25,6 +25,12 @@ async def register(
request: Request,
db: Annotated[AsyncSession, Depends(get_db)],
):
from app.services.setting_service import get_setting_value
registration_enabled = await get_setting_value(db, "self_registration_enabled", True)
if not registration_enabled:
from fastapi import HTTPException
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Registration is currently disabled")
return await auth_service.register_user(
db,
data,

52
backend/app/api/v1/pdf.py Normal file
View File

@@ -0,0 +1,52 @@
import uuid
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
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
router = APIRouter(prefix="/pdf", tags=["pdf"])
@router.post("/compile", response_model=PdfResponse, status_code=status.HTTP_201_CREATED)
async def compile_pdf(
data: GeneratePdfRequest,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
pdf = await pdf_service.generate_health_pdf(
db, user.id, data.title, data.document_ids or None, data.chat_id,
)
return PdfResponse.model_validate(pdf)
@router.get("/", response_model=PdfListResponse)
async def list_pdfs(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
pdfs = await pdf_service.get_user_pdfs(db, user.id)
return PdfListResponse(pdfs=[PdfResponse.model_validate(p) for p in pdfs])
@router.get("/{pdf_id}/download")
async def download_pdf(
pdf_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
pdf = await pdf_service.get_pdf(db, pdf_id, user.id)
if not pdf:
raise HTTPException(status_code=404, detail="PDF not found")
file_path = Path(pdf.storage_path)
if not file_path.exists():
raise HTTPException(status_code=404, detail="PDF file not found on disk")
media_type = "application/pdf" if file_path.suffix == ".pdf" else "text/html"
return FileResponse(path=str(file_path), filename=f"{pdf.title}.pdf", media_type=media_type)

View File

@@ -9,6 +9,7 @@ from app.api.v1.documents import router as documents_router
from app.api.v1.memory import router as memory_router
from app.api.v1.notifications import router as notifications_router
from app.api.v1.ws import router as ws_router
from app.api.v1.pdf import router as pdf_router
api_v1_router = APIRouter(prefix="/api/v1")
@@ -21,6 +22,7 @@ api_v1_router.include_router(documents_router)
api_v1_router.include_router(memory_router)
api_v1_router.include_router(notifications_router)
api_v1_router.include_router(ws_router)
api_v1_router.include_router(pdf_router)
@api_v1_router.get("/health")

View File

@@ -7,5 +7,10 @@ from app.models.skill import Skill
from app.models.document import Document
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
__all__ = ["User", "Session", "Chat", "Message", "ContextFile", "Skill", "Document", "MemoryEntry", "Notification"]
__all__ = [
"User", "Session", "Chat", "Message", "ContextFile", "Skill",
"Document", "MemoryEntry", "Notification", "Setting", "GeneratedPdf",
]

View File

@@ -0,0 +1,23 @@
import uuid
from sqlalchemy import ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import ARRAY, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class GeneratedPdf(Base):
__tablename__ = "generated_pdfs"
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
title: Mapped[str] = mapped_column(String(255), nullable=False)
storage_path: Mapped[str] = mapped_column(Text, nullable=False)
source_document_ids: Mapped[list[uuid.UUID] | None] = mapped_column(ARRAY(UUID(as_uuid=True)), nullable=True)
source_chat_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("chats.id", ondelete="SET NULL"), nullable=True
)
user: Mapped["User"] = relationship(back_populates="generated_pdfs") # noqa: F821

View File

@@ -0,0 +1,22 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from app.database import Base
class Setting(Base):
__tablename__ = "settings"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
value: Mapped[dict] = mapped_column(JSONB, nullable=False)
updated_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)

View File

@@ -30,3 +30,4 @@ class User(Base):
documents: Mapped[list["Document"]] = relationship(back_populates="user", cascade="all, delete-orphan") # noqa: F821
memory_entries: Mapped[list["MemoryEntry"]] = relationship(back_populates="user", cascade="all, delete-orphan") # noqa: F821
notifications: Mapped[list["Notification"]] = relationship(back_populates="user", cascade="all, delete-orphan") # noqa: F821
generated_pdfs: Mapped[list["GeneratedPdf"]] = relationship(back_populates="user", cascade="all, delete-orphan") # noqa: F821

View File

@@ -0,0 +1,40 @@
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, EmailStr, Field
class AdminUserResponse(BaseModel):
id: uuid.UUID
email: str
username: str
full_name: str | None
role: str
is_active: bool
max_chats: int
created_at: datetime
model_config = {"from_attributes": True}
class AdminUserCreateRequest(BaseModel):
email: EmailStr
username: str = Field(min_length=3, max_length=50, pattern=r"^[a-zA-Z0-9_-]+$")
password: str = Field(min_length=8, max_length=128)
full_name: str | None = None
role: Literal["user", "admin"] = "user"
max_chats: int = 10
class AdminUserUpdateRequest(BaseModel):
role: Literal["user", "admin"] | None = None
is_active: bool | None = None
max_chats: int | None = None
full_name: str | None = None
class AdminUserListResponse(BaseModel):
users: list[AdminUserResponse]
total: int

View File

@@ -0,0 +1,25 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, Field
class GeneratePdfRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
document_ids: list[uuid.UUID] = []
chat_id: uuid.UUID | None = None
class PdfResponse(BaseModel):
id: uuid.UUID
user_id: uuid.UUID
title: str
source_document_ids: list[uuid.UUID] | None
source_chat_id: uuid.UUID | None
created_at: datetime
model_config = {"from_attributes": True}
class PdfListResponse(BaseModel):
pdfs: list[PdfResponse]

View File

@@ -0,0 +1,20 @@
import uuid
from datetime import datetime
from pydantic import BaseModel
class SettingResponse(BaseModel):
key: str
value: dict | bool | int | str
updated_at: datetime
model_config = {"from_attributes": True}
class UpdateSettingRequest(BaseModel):
value: dict | bool | int | str
class SettingsListResponse(BaseModel):
settings: list[SettingResponse]

View File

@@ -0,0 +1,57 @@
import uuid
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import hash_password
from app.models.user import User
async def list_users(db: AsyncSession, limit: int = 50, offset: int = 0) -> tuple[list[User], int]:
total = await db.scalar(select(func.count()).select_from(User))
result = await db.execute(
select(User).order_by(User.created_at.desc()).limit(limit).offset(offset)
)
return list(result.scalars().all()), total or 0
async def get_user(db: AsyncSession, user_id: uuid.UUID) -> User:
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return user
async def create_user(
db: AsyncSession, email: str, username: str, password: str,
full_name: str | None = None, role: str = "user", max_chats: int = 10,
) -> User:
existing = await db.execute(
select(User).where((User.email == email) | (User.username == username))
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already exists")
user = User(
email=email,
username=username,
hashed_password=hash_password(password),
full_name=full_name,
role=role,
max_chats=max_chats,
)
db.add(user)
await db.flush()
return user
async def update_user(db: AsyncSession, user_id: uuid.UUID, **kwargs) -> User:
user = await get_user(db, user_id)
allowed = {"role", "is_active", "max_chats", "full_name"}
for key, value in kwargs.items():
if key in allowed and value is not None:
setattr(user, key, value)
await db.flush()
return user

View File

@@ -92,6 +92,17 @@ AI_TOOLS = [
"required": ["title", "body"],
},
},
{
"name": "generate_pdf",
"description": "Generate a PDF health report compilation from the user's data. Use this when the user asks for a document or summary of their health information.",
"input_schema": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "Title for the PDF report"},
},
"required": ["title"],
},
},
]
@@ -161,6 +172,17 @@ async def _execute_tool(
"title": notif.title,
})
elif tool_name == "generate_pdf":
from app.services.pdf_service import generate_health_pdf
pdf = await generate_health_pdf(db, user_id, title=tool_input["title"])
await db.commit()
return json.dumps({
"status": "generated",
"id": str(pdf.id),
"title": pdf.title,
"download_url": f"/api/v1/pdf/{pdf.id}/download",
})
return json.dumps({"error": f"Unknown tool: {tool_name}"})

View File

@@ -61,11 +61,15 @@ async def register_user(
detail="User with this email or username already exists",
)
from app.services.setting_service import get_setting_value
default_max_chats = await get_setting_value(db, "default_max_chats", 10)
user = User(
email=data.email,
username=data.username,
hashed_password=hash_password(data.password),
full_name=data.full_name,
max_chats=int(default_max_chats),
)
db.add(user)
await db.flush()

View File

@@ -0,0 +1,107 @@
import uuid
from datetime import datetime, timezone
from pathlib import Path
from jinja2 import Environment, FileSystemLoader, select_autoescape
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.document import Document
from app.models.generated_pdf import GeneratedPdf
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(
loader=FileSystemLoader(str(TEMPLATE_DIR)),
autoescape=select_autoescape(["html"]),
)
async def generate_health_pdf(
db: AsyncSession,
user_id: uuid.UUID,
title: str,
document_ids: list[uuid.UUID] | None = None,
chat_id: uuid.UUID | None = None,
) -> GeneratedPdf:
# Load user
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one()
# Load memories
memories = await get_user_memories(db, user_id, is_active=True)
memory_data = [
{"category": m.category, "title": m.title, "content": m.content, "importance": m.importance}
for m in memories
]
# Load documents
doc_data = []
if document_ids:
for doc_id in document_ids:
result = await db.execute(
select(Document).where(Document.id == doc_id, Document.user_id == user_id)
)
doc = result.scalar_one_or_none()
if doc:
doc_data.append({
"original_filename": doc.original_filename,
"doc_type": doc.doc_type,
"excerpt": (doc.extracted_text or "")[:2000],
})
# Render HTML
template = jinja_env.get_template("health_report.html")
html = template.render(
title=title,
user_name=user.full_name or user.username,
generated_at=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
memories=memory_data,
documents=doc_data,
ai_summary=None,
)
# Generate PDF
pdf_id = uuid.uuid4()
pdf_dir = Path(settings.UPLOAD_DIR).parent / "pdfs" / str(user_id)
pdf_dir.mkdir(parents=True, exist_ok=True)
pdf_path = pdf_dir / f"{pdf_id}.pdf"
try:
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")
# Save record
generated = GeneratedPdf(
id=pdf_id,
user_id=user_id,
title=title,
storage_path=str(pdf_path),
source_document_ids=document_ids,
source_chat_id=chat_id,
)
db.add(generated)
await db.flush()
await db.refresh(generated)
return generated
async def get_user_pdfs(db: AsyncSession, user_id: uuid.UUID) -> list[GeneratedPdf]:
result = await db.execute(
select(GeneratedPdf).where(GeneratedPdf.user_id == user_id)
.order_by(GeneratedPdf.created_at.desc())
)
return list(result.scalars().all())
async def get_pdf(db: AsyncSession, pdf_id: uuid.UUID, user_id: uuid.UUID) -> GeneratedPdf | None:
result = await db.execute(
select(GeneratedPdf).where(GeneratedPdf.id == pdf_id, GeneratedPdf.user_id == user_id)
)
return result.scalar_one_or_none()

View File

@@ -0,0 +1,33 @@
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.setting import Setting
async def get_setting(db: AsyncSession, key: str) -> Setting | None:
result = await db.execute(select(Setting).where(Setting.key == key))
return result.scalar_one_or_none()
async def get_setting_value(db: AsyncSession, key: str, default=None):
setting = await get_setting(db, key)
return setting.value if setting else default
async def get_all_settings(db: AsyncSession) -> list[Setting]:
result = await db.execute(select(Setting).order_by(Setting.key))
return list(result.scalars().all())
async def upsert_setting(db: AsyncSession, key: str, value, admin_user_id: uuid.UUID) -> Setting:
setting = await get_setting(db, key)
if setting:
setting.value = value
setting.updated_by = admin_user_id
else:
setting = Setting(key=key, value=value, updated_by=admin_user_id)
db.add(setting)
await db.flush()
return setting

View 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>Patient: {{ user_name }}</p>
<p>Generated: {{ generated_at }}</p>
</div>
{% if memories %}
<div class="section">
<h2>Health Profile</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 &bull; This document is for informational purposes only.
</div>
</body>
</html>

View File

@@ -19,6 +19,8 @@ dependencies = [
"pymupdf>=1.24.0",
"aiofiles>=24.0.0",
"apscheduler>=3.10.0",
"weasyprint>=62.0",
"jinja2>=3.1.0",
]
[project.optional-dependencies]

View File

@@ -0,0 +1,32 @@
import pytest
from httpx import AsyncClient
@pytest.fixture
async def user_headers(client: AsyncClient):
resp = await client.post("/api/v1/auth/register", json={
"email": "regularuser@example.com",
"username": "regularuser",
"password": "testpass123",
})
assert resp.status_code == 201
return {"Authorization": f"Bearer {resp.json()['access_token']}"}
async def test_non_admin_cannot_list_users(client: AsyncClient, user_headers: dict):
resp = await client.get("/api/v1/admin/users", headers=user_headers)
assert resp.status_code == 403
async def test_non_admin_cannot_create_user(client: AsyncClient, user_headers: dict):
resp = await client.post("/api/v1/admin/users", json={
"email": "new@example.com",
"username": "newuser",
"password": "testpass123",
}, headers=user_headers)
assert resp.status_code == 403
async def test_non_admin_cannot_get_settings(client: AsyncClient, user_headers: dict):
resp = await client.get("/api/v1/admin/settings", headers=user_headers)
assert resp.status_code == 403

45
backend/tests/test_pdf.py Normal file
View File

@@ -0,0 +1,45 @@
import pytest
from httpx import AsyncClient
@pytest.fixture
async def auth_headers(client: AsyncClient):
resp = await client.post("/api/v1/auth/register", json={
"email": "pdfuser@example.com",
"username": "pdfuser",
"password": "testpass123",
})
assert resp.status_code == 201
return {"Authorization": f"Bearer {resp.json()['access_token']}"}
async def test_compile_pdf(client: AsyncClient, auth_headers: dict):
resp = await client.post("/api/v1/pdf/compile", json={
"title": "My Health Report",
}, headers=auth_headers)
assert resp.status_code == 201
data = resp.json()
assert data["title"] == "My Health Report"
async def test_list_pdfs(client: AsyncClient, auth_headers: dict):
await client.post("/api/v1/pdf/compile", json={"title": "Test"}, headers=auth_headers)
resp = await client.get("/api/v1/pdf/", headers=auth_headers)
assert resp.status_code == 200
assert len(resp.json()["pdfs"]) >= 1
async def test_pdf_ownership_isolation(client: AsyncClient, auth_headers: dict):
resp = await client.post("/api/v1/pdf/compile", json={"title": "Private"}, headers=auth_headers)
pdf_id = resp.json()["id"]
resp2 = await client.post("/api/v1/auth/register", json={
"email": "pdfother@example.com",
"username": "pdfother",
"password": "testpass123",
})
other_headers = {"Authorization": f"Bearer {resp2.json()['access_token']}"}
resp = await client.get(f"/api/v1/pdf/{pdf_id}/download", headers=other_headers)
assert resp.status_code == 404