From fed6a3df1b4493d582da8b1997a848a7fa5e4d94 Mon Sep 17 00:00:00 2001 From: "dolgolyov.alexei" Date: Thu, 19 Mar 2026 14:37:43 +0300 Subject: [PATCH] =?UTF-8?q?Phase=206:=20PDF=20&=20Polish=20=E2=80=94=20PDF?= =?UTF-8?q?=20generation,=20admin=20users/settings,=20AI=20tool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- GeneralPlan.md | 4 +- backend/Dockerfile | 1 + .../006_create_settings_and_generated_pdfs.py | 52 +++++++ backend/app/api/v1/admin.py | 73 +++++++++- backend/app/api/v1/auth.py | 6 + backend/app/api/v1/pdf.py | 52 +++++++ backend/app/api/v1/router.py | 2 + backend/app/models/__init__.py | 7 +- backend/app/models/generated_pdf.py | 23 ++++ backend/app/models/setting.py | 22 +++ backend/app/models/user.py | 1 + backend/app/schemas/admin.py | 40 ++++++ backend/app/schemas/pdf.py | 25 ++++ backend/app/schemas/setting.py | 20 +++ backend/app/services/admin_user_service.py | 57 ++++++++ backend/app/services/ai_service.py | 22 +++ backend/app/services/auth_service.py | 4 + backend/app/services/pdf_service.py | 107 ++++++++++++++ backend/app/services/setting_service.py | 33 +++++ backend/app/templates/pdf/health_report.html | 70 ++++++++++ backend/pyproject.toml | 2 + backend/tests/test_admin_users.py | 32 +++++ backend/tests/test_pdf.py | 45 ++++++ frontend/public/locales/en/translation.json | 24 +++- frontend/public/locales/ru/translation.json | 24 +++- frontend/src/api/admin.ts | 67 ++++++++- frontend/src/api/pdf.ts | 39 ++++++ frontend/src/components/layout/sidebar.tsx | 4 + frontend/src/pages/admin/settings.tsx | 81 +++++++++++ frontend/src/pages/admin/users.tsx | 130 ++++++++++++++++++ frontend/src/pages/pdf.tsx | 83 +++++++++++ frontend/src/routes.tsx | 6 + plans/phase-6-pdf-polish.md | 71 ++++++++++ 33 files changed, 1219 insertions(+), 10 deletions(-) create mode 100644 backend/alembic/versions/006_create_settings_and_generated_pdfs.py create mode 100644 backend/app/api/v1/pdf.py create mode 100644 backend/app/models/generated_pdf.py create mode 100644 backend/app/models/setting.py create mode 100644 backend/app/schemas/admin.py create mode 100644 backend/app/schemas/pdf.py create mode 100644 backend/app/schemas/setting.py create mode 100644 backend/app/services/admin_user_service.py create mode 100644 backend/app/services/pdf_service.py create mode 100644 backend/app/services/setting_service.py create mode 100644 backend/app/templates/pdf/health_report.html create mode 100644 backend/tests/test_admin_users.py create mode 100644 backend/tests/test_pdf.py create mode 100644 frontend/src/api/pdf.ts create mode 100644 frontend/src/pages/admin/settings.tsx create mode 100644 frontend/src/pages/admin/users.tsx create mode 100644 frontend/src/pages/pdf.tsx create mode 100644 plans/phase-6-pdf-polish.md diff --git a/GeneralPlan.md b/GeneralPlan.md index 0f3040c..96869c7 100644 --- a/GeneralPlan.md +++ b/GeneralPlan.md @@ -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 ### Phase 6: PDF & Polish -- **Status**: NOT STARTED -- [ ] Subplan created (`plans/phase-6-pdf-polish.md`) +- **Status**: IN PROGRESS +- [x] Subplan created (`plans/phase-6-pdf-polish.md`) - [ ] Phase completed - Summary: PDF generation (WeasyPrint), AI generate_pdf tool, OAuth, account switching, admin user management + settings, rate limiting, responsive pass diff --git a/backend/Dockerfile b/backend/Dockerfile index 0df4d8a..ecd09a8 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 . diff --git a/backend/alembic/versions/006_create_settings_and_generated_pdfs.py b/backend/alembic/versions/006_create_settings_and_generated_pdfs.py new file mode 100644 index 0000000..236bb78 --- /dev/null +++ b/backend/alembic/versions/006_create_settings_and_generated_pdfs.py @@ -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") diff --git a/backend/app/api/v1/admin.py b/backend/app/api/v1/admin.py index db95509..c07d90f 100644 --- a/backend/app/api/v1/admin.py +++ b/backend/app/api/v1/admin.py @@ -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) diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 44d4c78..edb44a6 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -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, diff --git a/backend/app/api/v1/pdf.py b/backend/app/api/v1/pdf.py new file mode 100644 index 0000000..9a68164 --- /dev/null +++ b/backend/app/api/v1/pdf.py @@ -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) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index a165129..9bfaa2a 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -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") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index a064337..cae1774 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/generated_pdf.py b/backend/app/models/generated_pdf.py new file mode 100644 index 0000000..39faaa0 --- /dev/null +++ b/backend/app/models/generated_pdf.py @@ -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 diff --git a/backend/app/models/setting.py b/backend/app/models/setting.py new file mode 100644 index 0000000..54f7523 --- /dev/null +++ b/backend/app/models/setting.py @@ -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 + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 17e8c11..8fbf26f 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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 diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py new file mode 100644 index 0000000..e311c16 --- /dev/null +++ b/backend/app/schemas/admin.py @@ -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 diff --git a/backend/app/schemas/pdf.py b/backend/app/schemas/pdf.py new file mode 100644 index 0000000..e868325 --- /dev/null +++ b/backend/app/schemas/pdf.py @@ -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] diff --git a/backend/app/schemas/setting.py b/backend/app/schemas/setting.py new file mode 100644 index 0000000..36f844d --- /dev/null +++ b/backend/app/schemas/setting.py @@ -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] diff --git a/backend/app/services/admin_user_service.py b/backend/app/services/admin_user_service.py new file mode 100644 index 0000000..4f880db --- /dev/null +++ b/backend/app/services/admin_user_service.py @@ -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 diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py index d6ca471..7d45cdd 100644 --- a/backend/app/services/ai_service.py +++ b/backend/app/services/ai_service.py @@ -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}"}) diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index 85c4e9e..4992546 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -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() diff --git a/backend/app/services/pdf_service.py b/backend/app/services/pdf_service.py new file mode 100644 index 0000000..e85d815 --- /dev/null +++ b/backend/app/services/pdf_service.py @@ -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() diff --git a/backend/app/services/setting_service.py b/backend/app/services/setting_service.py new file mode 100644 index 0000000..eb243ea --- /dev/null +++ b/backend/app/services/setting_service.py @@ -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 diff --git a/backend/app/templates/pdf/health_report.html b/backend/app/templates/pdf/health_report.html new file mode 100644 index 0000000..b307288 --- /dev/null +++ b/backend/app/templates/pdf/health_report.html @@ -0,0 +1,70 @@ + + + + + + + +
+

{{ title }}

+

Patient: {{ user_name }}

+

Generated: {{ generated_at }}

+
+ + {% if memories %} +
+

Health Profile

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

Document Summaries

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

AI Summary

+

{{ ai_summary }}

+
+ {% endif %} + + + + diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 61acbd0..d39b680 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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] diff --git a/backend/tests/test_admin_users.py b/backend/tests/test_admin_users.py new file mode 100644 index 0000000..8c3ce14 --- /dev/null +++ b/backend/tests/test_admin_users.py @@ -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 diff --git a/backend/tests/test_pdf.py b/backend/tests/test_pdf.py new file mode 100644 index 0000000..b9e8bda --- /dev/null +++ b/backend/tests/test_pdf.py @@ -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 diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index c7ecc6b..d3cffbf 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -37,7 +37,8 @@ "users": "Users", "context": "Context", "skills": "Skills", - "personal_context": "My Context" + "personal_context": "My Context", + "pdf": "PDF Reports" }, "dashboard": { "welcome": "Welcome, {{name}}", @@ -152,6 +153,27 @@ "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": { "loading": "Loading...", "error": "An error occurred", diff --git a/frontend/public/locales/ru/translation.json b/frontend/public/locales/ru/translation.json index e57e00b..26423c2 100644 --- a/frontend/public/locales/ru/translation.json +++ b/frontend/public/locales/ru/translation.json @@ -37,7 +37,8 @@ "users": "Пользователи", "context": "Контекст", "skills": "Навыки", - "personal_context": "Мой контекст" + "personal_context": "Мой контекст", + "pdf": "PDF отчёты" }, "dashboard": { "welcome": "Добро пожаловать, {{name}}", @@ -152,6 +153,27 @@ "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": { "loading": "Загрузка...", "error": "Произошла ошибка", diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index f2b3aef..832239e 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -1,5 +1,7 @@ import api from "./client"; +// --- Context --- + export interface ContextFile { id: string; type: string; @@ -13,9 +15,68 @@ export async function getPrimaryContext(): Promise { return data; } -export async function updatePrimaryContext( - content: string -): Promise { +export async function updatePrimaryContext(content: string): Promise { const { data } = await api.put("/admin/context", { content }); 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 { + const { data } = await api.get("/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 { + const { data } = await api.post("/admin/users", user); + return data; +} + +export async function updateUser( + userId: string, + updates: { role?: string; is_active?: boolean; max_chats?: number; full_name?: string } +): Promise { + const { data } = await api.patch(`/admin/users/${userId}`, updates); + return data; +} + +// --- Settings --- + +export interface AppSetting { + key: string; + value: unknown; + updated_at: string; +} + +export async function getSettings(): Promise { + const { data } = await api.get<{ settings: AppSetting[] }>("/admin/settings"); + return data.settings; +} + +export async function updateSetting(key: string, value: unknown): Promise { + const { data } = await api.patch(`/admin/settings/${key}`, { value }); + return data; +} diff --git a/frontend/src/api/pdf.ts b/frontend/src/api/pdf.ts new file mode 100644 index 0000000..cc0a900 --- /dev/null +++ b/frontend/src/api/pdf.ts @@ -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 { + const { data } = await api.post("/pdf/compile", { + title, + document_ids: documentIds || [], + }); + return data; +} + +export async function listPdfs(): Promise { + const { data } = await api.get("/pdf/"); + return data.pdfs; +} + +export async function downloadPdf(pdfId: string, title: string): Promise { + 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); +} diff --git a/frontend/src/components/layout/sidebar.tsx b/frontend/src/components/layout/sidebar.tsx index fd59538..5c1eeda 100644 --- a/frontend/src/components/layout/sidebar.tsx +++ b/frontend/src/components/layout/sidebar.tsx @@ -9,6 +9,7 @@ import { Bell, Shield, BookOpen, + FileOutput, } from "lucide-react"; import { useAuthStore } from "@/stores/auth-store"; import { useUIStore } from "@/stores/ui-store"; @@ -22,11 +23,14 @@ const navItems = [ { key: "documents", to: "/documents", icon: FileText, 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: "pdf", to: "/pdf", icon: FileOutput, enabled: true, end: true }, ]; const adminItems = [ { key: "admin_context", to: "/admin/context", label: "layout.context" }, { 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() { diff --git a/frontend/src/pages/admin/settings.tsx b/frontend/src/pages/admin/settings.tsx new file mode 100644 index 0000000..ce483e9 --- /dev/null +++ b/frontend/src/pages/admin/settings.tsx @@ -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 ; + + return ( +
+

{t("admin_settings.title")}

+ +
+
+
+

{t("admin_settings.self_registration")}

+

{t("admin_settings.self_registration_desc")}

+
+ +
+ +
+

{t("admin_settings.default_max_chats")}

+

{t("admin_settings.default_max_chats_desc")}

+ 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" + /> +
+
+ + +
+ ); +} diff --git a/frontend/src/pages/admin/users.tsx b/frontend/src/pages/admin/users.tsx new file mode 100644 index 0000000..5421e54 --- /dev/null +++ b/frontend/src/pages/admin/users.tsx @@ -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(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 ; + + if (creating) { + return ( +
+

{t("admin_users.create")}

+
{ e.preventDefault(); createMut.mutate(); }} className="space-y-3"> + 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" /> + 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" /> + 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" /> + 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" /> + +
+ + +
+
+
+ ); + } + + if (editing) { + return ( +
+

{t("admin_users.edit")}: {editing.username}

+
{ e.preventDefault(); updateMut.mutate({ id: editing.id, role, max_chats: maxChats }); }} className="space-y-3"> + +
+ + 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" /> +
+
+ + +
+
+
+ ); + } + + return ( +
+
+

{t("admin_users.title")}

+ +
+ +
+ {(data?.users || []).map((u) => ( +
+
+
+

{u.username} ({u.email})

+

{u.role} · {t("admin_users.max_chats")}: {u.max_chats}

+
+
+ + +
+
+ ))} +
+
+ ); +} diff --git a/frontend/src/pages/pdf.tsx b/frontend/src/pages/pdf.tsx new file mode 100644 index 0000000..42e09d1 --- /dev/null +++ b/frontend/src/pages/pdf.tsx @@ -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 ( +
+
+

{t("pdf.title")}

+ +
+ + {creating && ( +
+ 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" + /> +
+ + +
+
+ )} + + {pdfs.length === 0 && !creating && ( +

{t("pdf.no_pdfs")}

+ )} + +
+ {pdfs.map((pdf) => ( +
+ +
+

{pdf.title}

+

{new Date(pdf.created_at).toLocaleString()}

+
+ +
+ ))} +
+
+ ); +} diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index 2e92fc7..ebefdf7 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -12,6 +12,9 @@ import { AdminSkillsPage } from "@/pages/admin/skills"; import { DocumentsPage } from "@/pages/documents"; import { MemoryPage } from "@/pages/memory"; 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"; export const router = createBrowserRouter([ @@ -37,8 +40,11 @@ export const router = createBrowserRouter([ { path: "notifications", element: }, { path: "skills", element: }, { path: "profile/context", element: }, + { path: "pdf", element: }, { path: "admin/context", element: }, { path: "admin/skills", element: }, + { path: "admin/users", element: }, + { path: "admin/settings", element: }, ], }, ], diff --git a/plans/phase-6-pdf-polish.md b/plans/phase-6-pdf-polish.md new file mode 100644 index 0000000..0824c67 --- /dev/null +++ b/plans/phase-6-pdf-polish.md @@ -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 1–3) + +- [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 4–5) + +- [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 6–10) + +- [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 11–13) + +- [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 14–19) + +- [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 20–21) + +- [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**