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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 .
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -25,6 +25,12 @@ async def register(
|
||||
request: Request,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
from app.services.setting_service import get_setting_value
|
||||
registration_enabled = await get_setting_value(db, "self_registration_enabled", True)
|
||||
if not registration_enabled:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Registration is currently disabled")
|
||||
|
||||
return await auth_service.register_user(
|
||||
db,
|
||||
data,
|
||||
|
||||
52
backend/app/api/v1/pdf.py
Normal file
52
backend/app/api/v1/pdf.py
Normal 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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
23
backend/app/models/generated_pdf.py
Normal file
23
backend/app/models/generated_pdf.py
Normal 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
|
||||
22
backend/app/models/setting.py
Normal file
22
backend/app/models/setting.py
Normal 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
|
||||
)
|
||||
@@ -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
|
||||
|
||||
40
backend/app/schemas/admin.py
Normal file
40
backend/app/schemas/admin.py
Normal 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
|
||||
25
backend/app/schemas/pdf.py
Normal file
25
backend/app/schemas/pdf.py
Normal 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]
|
||||
20
backend/app/schemas/setting.py
Normal file
20
backend/app/schemas/setting.py
Normal 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]
|
||||
57
backend/app/services/admin_user_service.py
Normal file
57
backend/app/services/admin_user_service.py
Normal 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
|
||||
@@ -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}"})
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
107
backend/app/services/pdf_service.py
Normal file
107
backend/app/services/pdf_service.py
Normal 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()
|
||||
33
backend/app/services/setting_service.py
Normal file
33
backend/app/services/setting_service.py
Normal 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
|
||||
70
backend/app/templates/pdf/health_report.html
Normal file
70
backend/app/templates/pdf/health_report.html
Normal file
@@ -0,0 +1,70 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: 'Helvetica', 'Arial', sans-serif; font-size: 12pt; color: #333; margin: 40px; }
|
||||
h1 { color: #1a7a8a; border-bottom: 2px solid #1a7a8a; padding-bottom: 8px; }
|
||||
h2 { color: #2a5a6a; margin-top: 24px; }
|
||||
.header { margin-bottom: 24px; }
|
||||
.header p { margin: 2px 0; color: #666; }
|
||||
.section { margin-bottom: 20px; }
|
||||
.memory-entry { background: #f5f9fa; padding: 10px 14px; border-radius: 6px; margin-bottom: 8px; }
|
||||
.memory-entry .category { font-size: 10pt; color: #888; text-transform: uppercase; }
|
||||
.memory-entry .title { font-weight: bold; }
|
||||
.memory-entry .importance { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 9pt; }
|
||||
.importance-critical { background: #fee; color: #c33; }
|
||||
.importance-high { background: #fff3e0; color: #e65100; }
|
||||
.importance-medium { background: #e3f2fd; color: #1565c0; }
|
||||
.importance-low { background: #f5f5f5; color: #666; }
|
||||
.document { border: 1px solid #ddd; padding: 12px; border-radius: 6px; margin-bottom: 10px; }
|
||||
.document .filename { font-weight: bold; color: #1a7a8a; }
|
||||
.document .excerpt { font-size: 10pt; color: #555; white-space: pre-wrap; max-height: 200px; overflow: hidden; }
|
||||
.footer { margin-top: 40px; padding-top: 12px; border-top: 1px solid #ddd; font-size: 9pt; color: #999; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>{{ title }}</h1>
|
||||
<p>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 • This document is for informational purposes only.
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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]
|
||||
|
||||
32
backend/tests/test_admin_users.py
Normal file
32
backend/tests/test_admin_users.py
Normal 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
45
backend/tests/test_pdf.py
Normal 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Произошла ошибка",
|
||||
|
||||
@@ -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<ContextFile | null> {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updatePrimaryContext(
|
||||
content: string
|
||||
): Promise<ContextFile> {
|
||||
export async function updatePrimaryContext(content: string): Promise<ContextFile> {
|
||||
const { data } = await api.put<ContextFile>("/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<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
39
frontend/src/api/pdf.ts
Normal 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);
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
81
frontend/src/pages/admin/settings.tsx
Normal file
81
frontend/src/pages/admin/settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
frontend/src/pages/admin/users.tsx
Normal file
130
frontend/src/pages/admin/users.tsx
Normal 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} · {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>
|
||||
);
|
||||
}
|
||||
83
frontend/src/pages/pdf.tsx
Normal file
83
frontend/src/pages/pdf.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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: <NotificationsPage /> },
|
||||
{ path: "skills", element: <SkillsPage /> },
|
||||
{ path: "profile/context", element: <PersonalContextPage /> },
|
||||
{ path: "pdf", element: <PdfPage /> },
|
||||
{ path: "admin/context", element: <AdminContextPage /> },
|
||||
{ path: "admin/skills", element: <AdminSkillsPage /> },
|
||||
{ path: "admin/users", element: <AdminUsersPage /> },
|
||||
{ path: "admin/settings", element: <AdminSettingsPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
71
plans/phase-6-pdf-polish.md
Normal file
71
plans/phase-6-pdf-polish.md
Normal 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 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**
|
||||
Reference in New Issue
Block a user