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

@@ -234,8 +234,8 @@ Daily scheduled job (APScheduler, 8 AM) reviews each user's memory + recent docs
- Summary: Notifications table, WebSocket + email + Telegram channels, APScheduler, AI schedule_notification tool, proactive health review job, frontend notification UI - Summary: Notifications table, WebSocket + email + Telegram channels, APScheduler, AI schedule_notification tool, proactive health review job, frontend notification UI
### Phase 6: PDF & Polish ### Phase 6: PDF & Polish
- **Status**: NOT STARTED - **Status**: IN PROGRESS
- [ ] Subplan created (`plans/phase-6-pdf-polish.md`) - [x] Subplan created (`plans/phase-6-pdf-polish.md`)
- [ ] Phase completed - [ ] Phase completed
- Summary: PDF generation (WeasyPrint), AI generate_pdf tool, OAuth, account switching, admin user management + settings, rate limiting, responsive pass - Summary: PDF generation (WeasyPrint), AI generate_pdf tool, OAuth, account switching, admin user management + settings, rate limiting, responsive pass

View File

@@ -4,6 +4,7 @@ WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libpq-dev \ gcc libpq-dev \
libpango-1.0-0 libcairo2 libgdk-pixbuf-2.0-0 libffi-dev \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY pyproject.toml . 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 import uuid
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, status from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import require_admin from app.api.deps import require_admin
@@ -14,7 +14,14 @@ from app.schemas.skill import (
SkillResponse, SkillResponse,
UpdateSkillRequest, 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"]) router = APIRouter(prefix="/admin", tags=["admin"])
@@ -81,3 +88,65 @@ async def delete_general_skill(
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_db)],
): ):
await skill_service.delete_general_skill(db, skill_id) 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, request: Request,
db: Annotated[AsyncSession, Depends(get_db)], 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( return await auth_service.register_user(
db, db,
data, 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.memory import router as memory_router
from app.api.v1.notifications import router as notifications_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.ws import router as ws_router
from app.api.v1.pdf import router as pdf_router
api_v1_router = APIRouter(prefix="/api/v1") 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(memory_router)
api_v1_router.include_router(notifications_router) api_v1_router.include_router(notifications_router)
api_v1_router.include_router(ws_router) api_v1_router.include_router(ws_router)
api_v1_router.include_router(pdf_router)
@api_v1_router.get("/health") @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.document import Document
from app.models.memory_entry import MemoryEntry from app.models.memory_entry import MemoryEntry
from app.models.notification import Notification 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 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 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 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"], "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, "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}"}) 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", 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( user = User(
email=data.email, email=data.email,
username=data.username, username=data.username,
hashed_password=hash_password(data.password), hashed_password=hash_password(data.password),
full_name=data.full_name, full_name=data.full_name,
max_chats=int(default_max_chats),
) )
db.add(user) db.add(user)
await db.flush() 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", "pymupdf>=1.24.0",
"aiofiles>=24.0.0", "aiofiles>=24.0.0",
"apscheduler>=3.10.0", "apscheduler>=3.10.0",
"weasyprint>=62.0",
"jinja2>=3.1.0",
] ]
[project.optional-dependencies] [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

View File

@@ -37,7 +37,8 @@
"users": "Users", "users": "Users",
"context": "Context", "context": "Context",
"skills": "Skills", "skills": "Skills",
"personal_context": "My Context" "personal_context": "My Context",
"pdf": "PDF Reports"
}, },
"dashboard": { "dashboard": {
"welcome": "Welcome, {{name}}", "welcome": "Welcome, {{name}}",
@@ -152,6 +153,27 @@
"low": "Low" "low": "Low"
} }
}, },
"pdf": {
"title": "PDF Reports",
"generate": "Generate PDF",
"title_placeholder": "Report title...",
"no_pdfs": "No PDF reports generated yet."
},
"admin_users": {
"title": "User Management",
"create": "Create User",
"edit": "Edit User",
"max_chats": "Max Chats",
"deactivate": "Deactivate",
"activate": "Activate"
},
"admin_settings": {
"title": "App Settings",
"self_registration": "Self Registration",
"self_registration_desc": "Allow users to register accounts themselves",
"default_max_chats": "Default Max Chats",
"default_max_chats_desc": "Default chat limit for new users"
},
"common": { "common": {
"loading": "Loading...", "loading": "Loading...",
"error": "An error occurred", "error": "An error occurred",

View File

@@ -37,7 +37,8 @@
"users": "Пользователи", "users": "Пользователи",
"context": "Контекст", "context": "Контекст",
"skills": "Навыки", "skills": "Навыки",
"personal_context": "Мой контекст" "personal_context": "Мой контекст",
"pdf": "PDF отчёты"
}, },
"dashboard": { "dashboard": {
"welcome": "Добро пожаловать, {{name}}", "welcome": "Добро пожаловать, {{name}}",
@@ -152,6 +153,27 @@
"low": "Низкая" "low": "Низкая"
} }
}, },
"pdf": {
"title": "PDF отчёты",
"generate": "Сгенерировать PDF",
"title_placeholder": "Название отчёта...",
"no_pdfs": "PDF отчёты ещё не создавались."
},
"admin_users": {
"title": "Управление пользователями",
"create": "Создать пользователя",
"edit": "Редактировать пользователя",
"max_chats": "Макс. чатов",
"deactivate": "Деактивировать",
"activate": "Активировать"
},
"admin_settings": {
"title": "Настройки приложения",
"self_registration": "Самостоятельная регистрация",
"self_registration_desc": "Разрешить пользователям самим создавать аккаунты",
"default_max_chats": "Лимит чатов по умолчанию",
"default_max_chats_desc": "Лимит чатов для новых пользователей"
},
"common": { "common": {
"loading": "Загрузка...", "loading": "Загрузка...",
"error": "Произошла ошибка", "error": "Произошла ошибка",

View File

@@ -1,5 +1,7 @@
import api from "./client"; import api from "./client";
// --- Context ---
export interface ContextFile { export interface ContextFile {
id: string; id: string;
type: string; type: string;
@@ -13,9 +15,68 @@ export async function getPrimaryContext(): Promise<ContextFile | null> {
return data; return data;
} }
export async function updatePrimaryContext( export async function updatePrimaryContext(content: string): Promise<ContextFile> {
content: string
): Promise<ContextFile> {
const { data } = await api.put<ContextFile>("/admin/context", { content }); const { data } = await api.put<ContextFile>("/admin/context", { content });
return data; return data;
} }
// --- Users ---
export interface AdminUser {
id: string;
email: string;
username: string;
full_name: string | null;
role: string;
is_active: boolean;
max_chats: number;
created_at: string;
}
export interface AdminUserListResponse {
users: AdminUser[];
total: number;
}
export async function listUsers(limit = 50, offset = 0): Promise<AdminUserListResponse> {
const { data } = await api.get<AdminUserListResponse>("/admin/users", { params: { limit, offset } });
return data;
}
export async function createUser(user: {
email: string;
username: string;
password: string;
full_name?: string;
role?: string;
max_chats?: number;
}): Promise<AdminUser> {
const { data } = await api.post<AdminUser>("/admin/users", user);
return data;
}
export async function updateUser(
userId: string,
updates: { role?: string; is_active?: boolean; max_chats?: number; full_name?: string }
): Promise<AdminUser> {
const { data } = await api.patch<AdminUser>(`/admin/users/${userId}`, updates);
return data;
}
// --- Settings ---
export interface AppSetting {
key: string;
value: unknown;
updated_at: string;
}
export async function getSettings(): Promise<AppSetting[]> {
const { data } = await api.get<{ settings: AppSetting[] }>("/admin/settings");
return data.settings;
}
export async function updateSetting(key: string, value: unknown): Promise<AppSetting> {
const { data } = await api.patch<AppSetting>(`/admin/settings/${key}`, { value });
return data;
}

39
frontend/src/api/pdf.ts Normal file
View File

@@ -0,0 +1,39 @@
import api from "./client";
export interface GeneratedPdf {
id: string;
user_id: string;
title: string;
source_document_ids: string[] | null;
source_chat_id: string | null;
created_at: string;
}
export interface PdfListResponse {
pdfs: GeneratedPdf[];
}
export async function compilePdf(title: string, documentIds?: string[]): Promise<GeneratedPdf> {
const { data } = await api.post<GeneratedPdf>("/pdf/compile", {
title,
document_ids: documentIds || [],
});
return data;
}
export async function listPdfs(): Promise<GeneratedPdf[]> {
const { data } = await api.get<PdfListResponse>("/pdf/");
return data.pdfs;
}
export async function downloadPdf(pdfId: string, title: string): Promise<void> {
const { data } = await api.get(`/pdf/${pdfId}/download`, { responseType: "blob" });
const url = URL.createObjectURL(data);
const a = document.createElement("a");
a.href = url;
a.download = `${title}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}

View File

@@ -9,6 +9,7 @@ import {
Bell, Bell,
Shield, Shield,
BookOpen, BookOpen,
FileOutput,
} from "lucide-react"; } from "lucide-react";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useUIStore } from "@/stores/ui-store"; import { useUIStore } from "@/stores/ui-store";
@@ -22,11 +23,14 @@ const navItems = [
{ key: "documents", to: "/documents", icon: FileText, enabled: true, end: true }, { key: "documents", to: "/documents", icon: FileText, enabled: true, end: true },
{ key: "memory", to: "/memory", icon: Brain, enabled: true, end: true }, { key: "memory", to: "/memory", icon: Brain, enabled: true, end: true },
{ key: "notifications", to: "/notifications", icon: Bell, enabled: true, end: true }, { key: "notifications", to: "/notifications", icon: Bell, enabled: true, end: true },
{ key: "pdf", to: "/pdf", icon: FileOutput, enabled: true, end: true },
]; ];
const adminItems = [ const adminItems = [
{ key: "admin_context", to: "/admin/context", label: "layout.context" }, { key: "admin_context", to: "/admin/context", label: "layout.context" },
{ key: "admin_skills", to: "/admin/skills", label: "layout.skills" }, { 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" },
]; ];
export function Sidebar() { export function Sidebar() {

View File

@@ -0,0 +1,81 @@
import { useState, useEffect } 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 { getSettings, updateSetting } from "@/api/admin";
import { Save } from "lucide-react";
export function AdminSettingsPage() {
const { t } = useTranslation();
const user = useAuthStore((s) => s.user);
const queryClient = useQueryClient();
const [selfReg, setSelfReg] = useState(true);
const [defaultMaxChats, setDefaultMaxChats] = useState(10);
const { data: settings } = useQuery({
queryKey: ["admin-settings"],
queryFn: getSettings,
});
useEffect(() => {
if (settings) {
const reg = settings.find((s) => s.key === "self_registration_enabled");
const chats = settings.find((s) => s.key === "default_max_chats");
if (reg) setSelfReg(Boolean(reg.value));
if (chats) setDefaultMaxChats(Number(chats.value));
}
}, [settings]);
const mutation = useMutation({
mutationFn: async () => {
await updateSetting("self_registration_enabled", selfReg);
await updateSetting("default_max_chats", defaultMaxChats);
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["admin-settings"] }),
});
if (user?.role !== "admin") return <Navigate to="/" replace />;
return (
<div className="max-w-lg space-y-6">
<h1 className="text-2xl font-semibold">{t("admin_settings.title")}</h1>
<div className="space-y-4">
<div className="flex items-center justify-between rounded-lg border bg-card p-4">
<div>
<p className="font-medium">{t("admin_settings.self_registration")}</p>
<p className="text-sm text-muted-foreground">{t("admin_settings.self_registration_desc")}</p>
</div>
<button
onClick={() => setSelfReg(!selfReg)}
className={`relative h-6 w-11 rounded-full transition-colors ${selfReg ? "bg-primary" : "bg-muted"}`}
>
<span className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform ${selfReg ? "translate-x-5" : "translate-x-0.5"}`} />
</button>
</div>
<div className="rounded-lg border bg-card p-4 space-y-2">
<p className="font-medium">{t("admin_settings.default_max_chats")}</p>
<p className="text-sm text-muted-foreground">{t("admin_settings.default_max_chats_desc")}</p>
<input
type="number"
value={defaultMaxChats}
onChange={(e) => setDefaultMaxChats(Number(e.target.value))}
min={1}
max={100}
className="flex h-10 w-24 rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</div>
</div>
<button
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
className="inline-flex h-9 items-center gap-2 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
<Save className="h-4 w-4" /> {mutation.isPending ? t("common.loading") : t("common.save")}
</button>
</div>
);
}

View File

@@ -0,0 +1,130 @@
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 { listUsers, createUser, updateUser, type AdminUser } from "@/api/admin";
import { Plus, Pencil, UserCheck, UserX } from "lucide-react";
import { cn } from "@/lib/utils";
export function AdminUsersPage() {
const { t } = useTranslation();
const user = useAuthStore((s) => s.user);
const queryClient = useQueryClient();
const [creating, setCreating] = useState(false);
const [editing, setEditing] = useState<AdminUser | null>(null);
// Form state
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [fullName, setFullName] = useState("");
const [role, setRole] = useState("user");
const [maxChats, setMaxChats] = useState(10);
const { data } = useQuery({
queryKey: ["admin-users"],
queryFn: () => listUsers(200),
});
const createMut = useMutation({
mutationFn: () => createUser({ email, username, password, full_name: fullName || undefined, role, max_chats: maxChats }),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["admin-users"] }); resetForm(); },
});
const updateMut = useMutation({
mutationFn: (data: { id: string; role?: string; is_active?: boolean; max_chats?: number }) => {
const { id, ...updates } = data;
return updateUser(id, updates);
},
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["admin-users"] }); setEditing(null); },
});
function resetForm() {
setCreating(false); setEditing(null);
setEmail(""); setUsername(""); setPassword(""); setFullName(""); setRole("user"); setMaxChats(10);
}
function startEdit(u: AdminUser) {
setEditing(u); setRole(u.role); setMaxChats(u.max_chats); setFullName(u.full_name || "");
}
if (user?.role !== "admin") return <Navigate to="/" replace />;
if (creating) {
return (
<div className="max-w-lg space-y-4">
<h1 className="text-2xl font-semibold">{t("admin_users.create")}</h1>
<form onSubmit={(e) => { e.preventDefault(); createMut.mutate(); }} className="space-y-3">
<input value={email} onChange={(e) => setEmail(e.target.value)} required type="email" placeholder={t("auth.email")} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
<input value={username} onChange={(e) => setUsername(e.target.value)} required placeholder={t("auth.username")} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
<input value={password} onChange={(e) => setPassword(e.target.value)} required type="password" placeholder={t("auth.password")} minLength={8} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
<input value={fullName} onChange={(e) => setFullName(e.target.value)} placeholder={t("auth.fullName")} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
<select value={role} onChange={(e) => setRole(e.target.value)} className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<div className="flex gap-2">
<button type="button" onClick={resetForm} className="h-9 rounded-md border px-4 text-sm">{t("common.cancel")}</button>
<button type="submit" disabled={createMut.isPending} className="h-9 rounded-md bg-primary px-4 text-sm text-primary-foreground">{t("common.create")}</button>
</div>
</form>
</div>
);
}
if (editing) {
return (
<div className="max-w-lg space-y-4">
<h1 className="text-2xl font-semibold">{t("admin_users.edit")}: {editing.username}</h1>
<form onSubmit={(e) => { e.preventDefault(); updateMut.mutate({ id: editing.id, role, max_chats: maxChats }); }} className="space-y-3">
<select value={role} onChange={(e) => setRole(e.target.value)} className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<div className="space-y-1">
<label className="text-sm font-medium">{t("admin_users.max_chats")}</label>
<input type="number" value={maxChats} onChange={(e) => setMaxChats(Number(e.target.value))} min={1} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
</div>
<div className="flex gap-2">
<button type="button" onClick={resetForm} className="h-9 rounded-md border px-4 text-sm">{t("common.cancel")}</button>
<button type="submit" disabled={updateMut.isPending} className="h-9 rounded-md bg-primary px-4 text-sm text-primary-foreground">{t("common.save")}</button>
</div>
</form>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">{t("admin_users.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>
<div className="space-y-2">
{(data?.users || []).map((u) => (
<div key={u.id} className="flex items-center gap-3 rounded-lg border bg-card p-4">
<div className={cn("h-2 w-2 rounded-full", u.is_active ? "bg-green-500" : "bg-red-500")} />
<div className="flex-1 min-w-0">
<p className="font-medium">{u.username} <span className="text-xs text-muted-foreground">({u.email})</span></p>
<p className="text-xs text-muted-foreground">{u.role} &middot; {t("admin_users.max_chats")}: {u.max_chats}</p>
</div>
<div className="flex gap-1">
<button onClick={() => startEdit(u)} className="rounded p-2 hover:bg-accent"><Pencil className="h-4 w-4" /></button>
<button
onClick={() => updateMut.mutate({ id: u.id, is_active: !u.is_active })}
className="rounded p-2 hover:bg-accent"
title={u.is_active ? t("admin_users.deactivate") : t("admin_users.activate")}
>
{u.is_active ? <UserX className="h-4 w-4" /> : <UserCheck className="h-4 w-4" />}
</button>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,83 @@
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 { FileText, Plus, Download } from "lucide-react";
export function PdfPage() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [creating, setCreating] = useState(false);
const [title, setTitle] = useState("");
const { data: pdfs = [] } = useQuery({
queryKey: ["pdfs"],
queryFn: listPdfs,
});
const compileMut = useMutation({
mutationFn: () => compilePdf(title),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["pdfs"] });
setCreating(false);
setTitle("");
},
});
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">{t("pdf.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 hover:bg-primary/90"
>
<Plus className="h-4 w-4" /> {t("pdf.generate")}
</button>
</div>
{creating && (
<div className="rounded-lg border bg-card p-4 space-y-3">
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
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="flex gap-2">
<button onClick={() => setCreating(false)} className="h-9 rounded-md border px-4 text-sm">{t("common.cancel")}</button>
<button
onClick={() => compileMut.mutate()}
disabled={!title.trim() || compileMut.isPending}
className="h-9 rounded-md bg-primary px-4 text-sm text-primary-foreground disabled:opacity-50"
>
{compileMut.isPending ? t("common.loading") : t("pdf.generate")}
</button>
</div>
</div>
)}
{pdfs.length === 0 && !creating && (
<p className="text-center text-muted-foreground py-8">{t("pdf.no_pdfs")}</p>
)}
<div className="space-y-2">
{pdfs.map((pdf) => (
<div key={pdf.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">
<p className="font-medium">{pdf.title}</p>
<p className="text-xs text-muted-foreground">{new Date(pdf.created_at).toLocaleString()}</p>
</div>
<button
onClick={() => downloadPdf(pdf.id, pdf.title)}
className="inline-flex h-8 items-center gap-1 rounded-md border px-3 text-sm hover:bg-accent"
>
<Download className="h-4 w-4" /> {t("documents.download")}
</button>
</div>
))}
</div>
</div>
);
}

View File

@@ -12,6 +12,9 @@ import { AdminSkillsPage } from "@/pages/admin/skills";
import { DocumentsPage } from "@/pages/documents"; import { DocumentsPage } from "@/pages/documents";
import { MemoryPage } from "@/pages/memory"; import { MemoryPage } from "@/pages/memory";
import { NotificationsPage } from "@/pages/notifications"; import { NotificationsPage } from "@/pages/notifications";
import { PdfPage } from "@/pages/pdf";
import { AdminUsersPage } from "@/pages/admin/users";
import { AdminSettingsPage } from "@/pages/admin/settings";
import { NotFoundPage } from "@/pages/not-found"; import { NotFoundPage } from "@/pages/not-found";
export const router = createBrowserRouter([ export const router = createBrowserRouter([
@@ -37,8 +40,11 @@ export const router = createBrowserRouter([
{ path: "notifications", element: <NotificationsPage /> }, { path: "notifications", element: <NotificationsPage /> },
{ path: "skills", element: <SkillsPage /> }, { path: "skills", element: <SkillsPage /> },
{ path: "profile/context", element: <PersonalContextPage /> }, { path: "profile/context", element: <PersonalContextPage /> },
{ path: "pdf", element: <PdfPage /> },
{ path: "admin/context", element: <AdminContextPage /> }, { path: "admin/context", element: <AdminContextPage /> },
{ path: "admin/skills", element: <AdminSkillsPage /> }, { path: "admin/skills", element: <AdminSkillsPage /> },
{ path: "admin/users", element: <AdminUsersPage /> },
{ path: "admin/settings", element: <AdminSettingsPage /> },
], ],
}, },
], ],

View File

@@ -0,0 +1,71 @@
# Phase 6: PDF & Polish — Subplan
## Goal
Deliver PDF health-compilation generation (WeasyPrint + AI tool), admin user management CRUD, admin app settings with self-registration toggle, and generated_pdfs tracking.
## Prerequisites
- Phase 5 completed (notifications, scheduler, all AI tools except generate_pdf)
---
## Tasks
### A. Backend Models & Migration (Tasks 13)
- [x] **A1.** Create `backend/app/models/setting.py`: Setting model (key PK, value JSONB, updated_by, updated_at).
- [x] **A2.** Create `backend/app/models/generated_pdf.py`: GeneratedPdf model (user_id, title, storage_path, source_document_ids, source_chat_id).
- [x] **A3.** Create migration `006`. Seed default settings. Update `models/__init__.py` + User relationships.
### B. Backend Schemas (Tasks 45)
- [x] **B4.** Create `backend/app/schemas/setting.py` and `backend/app/schemas/pdf.py`.
- [x] **B5.** Create `backend/app/schemas/admin.py` (AdminUserResponse, AdminUserCreateRequest, AdminUserUpdateRequest).
### C. Backend Services (Tasks 610)
- [x] **C6.** Add `weasyprint`, `jinja2` to pyproject.toml. Update Dockerfile for WeasyPrint deps.
- [x] **C7.** Create `backend/app/services/pdf_service.py` + HTML template.
- [x] **C8.** Create `backend/app/services/setting_service.py`.
- [x] **C9.** Create `backend/app/services/admin_user_service.py`.
- [x] **C10.** Wire `self_registration_enabled` check into auth register endpoint.
### D. Backend API (Tasks 1113)
- [x] **D11.** Create `backend/app/api/v1/pdf.py`: compile, list, download. Register in router.
- [x] **D12.** Extend `backend/app/api/v1/admin.py`: users CRUD + settings endpoints.
- [x] **D13.** Add `generate_pdf` AI tool to ai_service.py.
### E. Frontend (Tasks 1419)
- [x] **E14.** Create `frontend/src/api/pdf.ts`. Extend `admin.ts` with user/settings functions.
- [x] **E15.** Create `frontend/src/pages/admin/users.tsx`.
- [x] **E16.** Create `frontend/src/pages/admin/settings.tsx`.
- [x] **E17.** Create `frontend/src/pages/pdf.tsx`.
- [x] **E18.** Update routes + sidebar (PDF nav, admin users/settings).
- [x] **E19.** Update en/ru translations.
### F. Tests & Verification (Tasks 2021)
- [x] **F20.** Create backend tests: test_pdf.py, test_admin_users.py, test_settings.py.
- [x] **F21.** Verify frontend builds cleanly.
---
## Acceptance Criteria
1. PDF compilation generates downloadable PDF from documents + memory
2. AI tool `generate_pdf` works in chat
3. Admin can CRUD users (role, max_chats, is_active)
4. Admin can manage app settings (self_registration_enabled, default_max_chats)
5. Registration respects self_registration_enabled setting
6. Frontend admin pages + PDF page functional
7. All UI text in English and Russian
8. Backend tests pass, frontend builds clean
---
## Status
**COMPLETED**