203 lines
7.6 KiB
Python
203 lines
7.6 KiB
Python
"""Admin-configurable system-wide runtime settings."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Literal
|
|
|
|
from fastapi import APIRouter, HTTPException, Request
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.gateway.auth.password import hash_password_async
|
|
from app.gateway.deps import get_optional_user_from_request
|
|
from deerflow.config.system_settings import (
|
|
PromptPrefixSettings,
|
|
SystemSettings,
|
|
load_system_settings,
|
|
save_system_settings,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/system-settings", tags=["system-settings"])
|
|
|
|
|
|
class PromptPrefixResponse(BaseModel):
|
|
enabled: bool = Field(..., description="Whether the toggle is exposed to users")
|
|
switch_label: str = Field(..., description="Toggle label shown in the chat input")
|
|
prompt_prefix: str = Field(..., description="Prefix text prepended to the first user message")
|
|
|
|
|
|
class PromptPrefixUpdateRequest(BaseModel):
|
|
enabled: bool | None = None
|
|
switch_label: str | None = None
|
|
prompt_prefix: str | None = None
|
|
|
|
|
|
class PrivateSquareStatusResponse(BaseModel):
|
|
"""Status of the private-square feature, readable by any logged-in user.
|
|
|
|
The password hash itself is **never** returned — the response only signals
|
|
whether the feature is enabled and whether a password has been set.
|
|
"""
|
|
|
|
enabled: bool = Field(..., description="Whether the private square is exposed to users")
|
|
password_set: bool = Field(..., description="Whether a password is currently configured")
|
|
|
|
|
|
class PrivateSquareUpdateRequest(BaseModel):
|
|
enabled: bool | None = Field(default=None, description="Toggle the private-square feature")
|
|
password: str | None = Field(default=None, description="New password in plain text. Pass an empty string to clear.")
|
|
|
|
|
|
class CitationDisplayResponse(BaseModel):
|
|
cleanup_words: list[str] = Field(default_factory=list, description="Global citation cleanup words")
|
|
|
|
|
|
class CitationDisplayUpdateRequest(BaseModel):
|
|
cleanup_words: list[str] | None = None
|
|
|
|
|
|
def _to_response(settings: PromptPrefixSettings) -> PromptPrefixResponse:
|
|
return PromptPrefixResponse(
|
|
enabled=settings.enabled,
|
|
switch_label=settings.switch_label,
|
|
prompt_prefix=settings.prompt_prefix,
|
|
)
|
|
|
|
|
|
async def _require_admin(request: Request) -> None:
|
|
user = await get_optional_user_from_request(request)
|
|
if user is None:
|
|
return
|
|
if getattr(user, "system_role", None) != "admin":
|
|
raise HTTPException(status_code=403, detail="System settings can only be modified by an admin")
|
|
|
|
|
|
@router.get("/prompt-prefix", response_model=PromptPrefixResponse)
|
|
async def get_prompt_prefix() -> PromptPrefixResponse:
|
|
"""Return the current prompt-prefix configuration (readable by any user)."""
|
|
return _to_response(load_system_settings().prompt_prefix)
|
|
|
|
|
|
@router.put("/prompt-prefix", response_model=PromptPrefixResponse)
|
|
async def update_prompt_prefix(body: PromptPrefixUpdateRequest, request: Request) -> PromptPrefixResponse:
|
|
"""Update the prompt-prefix configuration. Admin only."""
|
|
await _require_admin(request)
|
|
|
|
current = load_system_settings()
|
|
fields = body.model_dump(exclude_unset=True)
|
|
updated_prefix = current.prompt_prefix.model_copy(update=fields)
|
|
saved = save_system_settings(current.model_copy(update={"prompt_prefix": updated_prefix}))
|
|
return _to_response(saved.prompt_prefix)
|
|
|
|
|
|
@router.get("/citation-display", response_model=CitationDisplayResponse)
|
|
async def get_citation_display() -> CitationDisplayResponse:
|
|
"""Return global citation display cleanup settings."""
|
|
settings = load_system_settings().citation_display
|
|
return CitationDisplayResponse(cleanup_words=settings.cleanup_words)
|
|
|
|
|
|
@router.put("/citation-display", response_model=CitationDisplayResponse)
|
|
async def update_citation_display(
|
|
body: CitationDisplayUpdateRequest,
|
|
request: Request,
|
|
) -> CitationDisplayResponse:
|
|
"""Update global citation display cleanup settings. Admin only."""
|
|
await _require_admin(request)
|
|
|
|
current = load_system_settings()
|
|
words = [
|
|
str(item).strip()
|
|
for item in (body.cleanup_words or [])
|
|
if str(item).strip()
|
|
]
|
|
updated = current.citation_display.model_copy(update={"cleanup_words": words})
|
|
saved = save_system_settings(
|
|
current.model_copy(update={"citation_display": updated})
|
|
)
|
|
return CitationDisplayResponse(cleanup_words=saved.citation_display.cleanup_words)
|
|
|
|
|
|
@router.get("/private-square", response_model=PrivateSquareStatusResponse)
|
|
async def get_private_square_status() -> PrivateSquareStatusResponse:
|
|
"""Return whether the private square is enabled and a password is set."""
|
|
settings = load_system_settings().private_square
|
|
return PrivateSquareStatusResponse(
|
|
enabled=settings.enabled,
|
|
password_set=bool(settings.password_hash),
|
|
)
|
|
|
|
|
|
@router.put("/private-square", response_model=PrivateSquareStatusResponse)
|
|
async def update_private_square(body: PrivateSquareUpdateRequest, request: Request) -> PrivateSquareStatusResponse:
|
|
"""Update the private-square configuration. Admin only.
|
|
|
|
Setting ``password`` to a non-empty string rehashes and stores it.
|
|
Passing an empty string clears the password (effectively locking the
|
|
private square so no one can read it).
|
|
"""
|
|
await _require_admin(request)
|
|
|
|
current = load_system_settings()
|
|
updated = current.private_square
|
|
fields = body.model_dump(exclude_unset=True)
|
|
|
|
update: dict[str, object] = {}
|
|
if "enabled" in fields and fields["enabled"] is not None:
|
|
update["enabled"] = bool(fields["enabled"])
|
|
if "password" in fields and fields["password"] is not None:
|
|
plain = str(fields["password"])
|
|
if plain == "":
|
|
update["password_hash"] = ""
|
|
else:
|
|
update["password_hash"] = await hash_password_async(plain)
|
|
if update:
|
|
updated = updated.model_copy(update=update)
|
|
saved = save_system_settings(current.model_copy(update={"private_square": updated}))
|
|
else:
|
|
saved = current
|
|
return PrivateSquareStatusResponse(
|
|
enabled=saved.private_square.enabled,
|
|
password_set=bool(saved.private_square.password_hash),
|
|
)
|
|
|
|
|
|
class AppearanceResponse(BaseModel):
|
|
"""Effective system-wide appearance defaults."""
|
|
|
|
default_theme: Literal["light", "dark"] = Field(..., description="System default theme — light or dark")
|
|
|
|
|
|
class AppearanceUpdateRequest(BaseModel):
|
|
default_theme: Literal["light", "dark"] | None = Field(default=None)
|
|
|
|
|
|
@router.get("/appearance", response_model=AppearanceResponse)
|
|
async def get_appearance_settings() -> AppearanceResponse:
|
|
"""Return the global default theme. Any logged-in user may read this."""
|
|
settings = load_system_settings().appearance
|
|
return AppearanceResponse(default_theme=settings.default_theme)
|
|
|
|
|
|
@router.put("/appearance", response_model=AppearanceResponse)
|
|
async def update_appearance_settings(
|
|
body: AppearanceUpdateRequest, request: Request
|
|
) -> AppearanceResponse:
|
|
"""Update the global appearance defaults. Admin only."""
|
|
await _require_admin(request)
|
|
|
|
current = load_system_settings()
|
|
fields = body.model_dump(exclude_unset=True)
|
|
update: dict[str, object] = {}
|
|
if "default_theme" in fields and fields["default_theme"] is not None:
|
|
update["default_theme"] = fields["default_theme"]
|
|
if update:
|
|
updated_appearance = current.appearance.model_copy(update=update)
|
|
saved = save_system_settings(
|
|
current.model_copy(update={"appearance": updated_appearance})
|
|
)
|
|
else:
|
|
saved = current
|
|
return AppearanceResponse(default_theme=saved.appearance.default_theme)
|