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 @@ + + +
+ + + + +Patient: {{ user_name }}
+Generated: {{ generated_at }}
+{{ ai_summary }}
+{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" + /> +{u.username} ({u.email})
+{u.role} · {t("admin_users.max_chats")}: {u.max_chats}
+{t("pdf.no_pdfs")}
+ )} + +{pdf.title}
+{new Date(pdf.created_at).toLocaleString()}
+