Initial commit: RSS platform phase 1 skeleton with code review fixes
Features: - FastAPI + SQLAlchemy 2.0 async + PostgreSQL/pgvector + Redis backend - Vue 3 + TypeScript + Element Plus frontend - JWT auth with access/refresh tokens and revocation - Admin/member RBAC - RSS feed CRUD and article listing - Settings management with Fernet encryption for sensitive values - Redis distributed lock service - Alembic initial migration - Docker Compose development environment Fixes from code review: - Fix DB session leak in dependency injection - Restrict registration to admin only - Add default admin password warning - Implement JWT refresh tokens and jti blacklist - Strengthen password policy - Use func.count for pagination totals - Replace NullPool with AsyncAdaptedQueuePool - Remove init_db from lifespan to enforce alembic migrations - Add request_id middleware and logging filter - Fix vite.config.ts env loading - Add frontend token refresh interceptor - Add Vue error handler Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
"""FastAPI dependencies."""
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.auth import decode_token, is_token_revoked
|
||||
from app.core.database import get_db as _get_db
|
||||
from app.core.rbac import require_admin
|
||||
from app.core.redis import get_redis
|
||||
from app.models.user import User
|
||||
from app.schemas.user import TokenPayload
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Yield async database session managed by FastAPI."""
|
||||
async for session in _get_db():
|
||||
yield session
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
"""Get current authenticated user from JWT access token."""
|
||||
if not credentials:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
token = credentials.credentials
|
||||
|
||||
try:
|
||||
payload = decode_token(token, expected_type="access")
|
||||
token_data = TokenPayload(**payload)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=f"Invalid authentication credentials: {exc}",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
) from exc
|
||||
|
||||
if not token_data.sub or not token_data.jti:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token payload",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
revoked = await is_token_revoked(token_data.jti)
|
||||
if revoked:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token has been revoked",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
user = await db.get(User, token_data.sub)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Inactive user",
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_admin(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""Get current user and require admin role."""
|
||||
return require_admin(current_user)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Admin locks router."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_admin, get_db
|
||||
from app.models.lock import Lock
|
||||
from app.models.user import User
|
||||
from app.schemas.common import MessageResponse
|
||||
|
||||
router = APIRouter(prefix="/locks", tags=["admin"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_locks(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin),
|
||||
):
|
||||
"""List active locks."""
|
||||
result = await db.execute(select(Lock))
|
||||
locks = result.scalars().all()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
active_locks = [
|
||||
{
|
||||
"id": str(lock.id),
|
||||
"lock_name": lock.lock_name,
|
||||
"owner_id": lock.owner_id,
|
||||
"acquired_at": lock.acquired_at.isoformat() if lock.acquired_at else None,
|
||||
"expires_at": lock.expires_at.isoformat() if lock.expires_at else None,
|
||||
"is_expired": lock.expires_at is not None and lock.expires_at < now,
|
||||
}
|
||||
for lock in locks
|
||||
]
|
||||
|
||||
return {"total": len(active_locks), "items": active_locks}
|
||||
|
||||
|
||||
@router.delete("/{lock_name}", response_model=MessageResponse)
|
||||
async def force_release_lock(
|
||||
lock_name: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin),
|
||||
):
|
||||
"""Force release a lock."""
|
||||
result = await db.execute(select(Lock).where(Lock.lock_name == lock_name))
|
||||
lock = result.scalar_one_or_none()
|
||||
if not lock:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Lock not found")
|
||||
|
||||
await db.delete(lock)
|
||||
await db.commit()
|
||||
return {"message": f"Lock {lock_name} released"}
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Articles router."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user, get_db
|
||||
from app.models.article import CleanedArticle
|
||||
from app.models.user import User
|
||||
from app.schemas.article import ArticleListParams, ArticleOut
|
||||
from app.schemas.common import MessageResponse, PaginatedResponse
|
||||
|
||||
router = APIRouter(prefix="/articles", tags=["articles"])
|
||||
|
||||
|
||||
@router.get("", response_model=PaginatedResponse)
|
||||
async def list_articles(
|
||||
params: ArticleListParams = Depends(),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List cleaned articles with filters."""
|
||||
query = select(CleanedArticle)
|
||||
|
||||
if params.feed_id:
|
||||
query = query.where(CleanedArticle.feed_id == params.feed_id)
|
||||
if params.category:
|
||||
query = query.where(CleanedArticle.category == params.category)
|
||||
if params.tag:
|
||||
query = query.where(CleanedArticle.tags.contains([params.tag]))
|
||||
if params.search:
|
||||
query = query.where(
|
||||
CleanedArticle.title.ilike(f"%{params.search}%")
|
||||
| CleanedArticle.ai_summary.ilike(f"%{params.search}%")
|
||||
)
|
||||
if params.is_read is not None:
|
||||
# CleanedArticle doesn't have is_read in current schema; placeholder
|
||||
pass
|
||||
|
||||
# Count
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_query)).scalar_one()
|
||||
|
||||
# Paginate
|
||||
query = (
|
||||
query.offset(params.skip)
|
||||
.limit(params.limit)
|
||||
.order_by(CleanedArticle.published_at.desc().nulls_last())
|
||||
)
|
||||
result = await db.execute(query)
|
||||
items = result.scalars().all()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"items": [ArticleOut.model_validate(item) for item in items],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{article_id}", response_model=ArticleOut)
|
||||
async def get_article(
|
||||
article_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get a single cleaned article."""
|
||||
article = await db.get(CleanedArticle, article_id)
|
||||
if not article:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Article not found")
|
||||
return ArticleOut.model_validate(article)
|
||||
|
||||
|
||||
@router.put("/{article_id}/read", response_model=MessageResponse)
|
||||
async def mark_article_read(
|
||||
article_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Mark an article as read (placeholder)."""
|
||||
# In Phase 1, cleaned_articles doesn't have is_read field yet
|
||||
return {"message": "Article marked as read"}
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Authentication router."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_admin, get_current_user, get_db
|
||||
from app.core.auth import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
get_password_hash,
|
||||
revoke_token,
|
||||
verify_password,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.schemas.user import (
|
||||
RefreshTokenRequest,
|
||||
TokenResponse,
|
||||
UserCreate,
|
||||
UserLogin,
|
||||
UserOut,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserOut)
|
||||
async def register(
|
||||
user_in: UserCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(get_current_admin),
|
||||
):
|
||||
"""Register a new user (admin only)."""
|
||||
# Check if username exists
|
||||
result = await db.execute(select(User).where(User.username == user_in.username))
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Username already exists",
|
||||
)
|
||||
|
||||
user = User(
|
||||
username=user_in.username,
|
||||
password_hash=get_password_hash(user_in.password),
|
||||
role=user_in.role,
|
||||
is_active=user_in.is_active,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(
|
||||
credentials: UserLogin,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Login and get access/refresh tokens."""
|
||||
result = await db.execute(select(User).where(User.username == credentials.username))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user or not verify_password(credentials.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Inactive user",
|
||||
)
|
||||
|
||||
user.last_login_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
|
||||
access_token, _ = create_access_token(sub=str(user.id), role=user.role)
|
||||
refresh_token, _ = create_refresh_token(sub=str(user.id))
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "bearer",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
async def refresh(
|
||||
req: RefreshTokenRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Exchange a valid refresh token for a new token pair."""
|
||||
try:
|
||||
payload = decode_token(req.refresh_token, expected_type="refresh")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=f"Invalid refresh token: {exc}",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
) from exc
|
||||
|
||||
user = await db.get(User, payload["sub"])
|
||||
if user is None or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid user",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
access_token, _ = create_access_token(sub=str(user.id), role=user.role)
|
||||
refresh_token, _ = create_refresh_token(sub=str(user.id))
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "bearer",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout(
|
||||
req: RefreshTokenRequest,
|
||||
):
|
||||
"""Revoke the provided refresh token."""
|
||||
try:
|
||||
payload = decode_token(req.refresh_token, expected_type="refresh")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
exp = payload.get("exp")
|
||||
if exp:
|
||||
expires_at = datetime.fromtimestamp(exp, tz=timezone.utc)
|
||||
await revoke_token(payload["jti"], expires_at)
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
async def get_me(current_user: User = Depends(get_current_user)):
|
||||
"""Get current user info."""
|
||||
return current_user
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Feeds router."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user, get_db
|
||||
from app.models.feed import Feed
|
||||
from app.models.user import User
|
||||
from app.schemas.common import MessageResponse, PaginatedResponse, PaginationParams
|
||||
from app.schemas.feed import FeedCreate, FeedOut, FeedUpdate
|
||||
|
||||
router = APIRouter(prefix="/feeds", tags=["feeds"])
|
||||
|
||||
|
||||
@router.get("", response_model=PaginatedResponse)
|
||||
async def list_feeds(
|
||||
pagination: PaginationParams = Depends(),
|
||||
category: str | None = Query(None),
|
||||
search: str | None = Query(None),
|
||||
is_active: bool | None = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List RSS feeds with pagination and filters."""
|
||||
query = select(Feed)
|
||||
|
||||
if category:
|
||||
query = query.where(Feed.category == category)
|
||||
if search:
|
||||
query = query.where(
|
||||
Feed.title.ilike(f"%{search}%")
|
||||
| Feed.url.ilike(f"%{search}%")
|
||||
| Feed.description.ilike(f"%{search}%")
|
||||
)
|
||||
if is_active is not None:
|
||||
query = query.where(Feed.is_active == is_active)
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_query)).scalar_one()
|
||||
|
||||
# Get paginated items
|
||||
query = query.offset(pagination.skip).limit(pagination.limit).order_by(Feed.created_at.desc())
|
||||
result = await db.execute(query)
|
||||
items = result.scalars().all()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"items": [FeedOut.model_validate(item) for item in items],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{feed_id}", response_model=FeedOut)
|
||||
async def get_feed(
|
||||
feed_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get a single feed by ID."""
|
||||
feed = await db.get(Feed, feed_id)
|
||||
if not feed:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feed not found")
|
||||
return FeedOut.model_validate(feed)
|
||||
|
||||
|
||||
@router.post("", response_model=FeedOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_feed(
|
||||
feed_in: FeedCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new RSS feed."""
|
||||
# Check URL uniqueness
|
||||
result = await db.execute(select(Feed).where(Feed.url == str(feed_in.url)))
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Feed with this URL already exists",
|
||||
)
|
||||
|
||||
feed = Feed(
|
||||
url=str(feed_in.url),
|
||||
title=feed_in.title or "",
|
||||
description=feed_in.description or "",
|
||||
category=feed_in.category or "",
|
||||
is_active=feed_in.is_active,
|
||||
fetch_interval_minutes=feed_in.fetch_interval_minutes,
|
||||
priority=feed_in.priority,
|
||||
parser_config=feed_in.parser_config,
|
||||
proxy_policy=feed_in.proxy_policy,
|
||||
)
|
||||
db.add(feed)
|
||||
await db.commit()
|
||||
await db.refresh(feed)
|
||||
return FeedOut.model_validate(feed)
|
||||
|
||||
|
||||
@router.put("/{feed_id}", response_model=FeedOut)
|
||||
async def update_feed(
|
||||
feed_id: str,
|
||||
feed_in: FeedUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update an existing feed."""
|
||||
feed = await db.get(Feed, feed_id)
|
||||
if not feed:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feed not found")
|
||||
|
||||
update_data = feed_in.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
if field == "url" and value is not None:
|
||||
value = str(value)
|
||||
setattr(feed, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(feed)
|
||||
return FeedOut.model_validate(feed)
|
||||
|
||||
|
||||
@router.delete("/{feed_id}", response_model=MessageResponse)
|
||||
async def delete_feed(
|
||||
feed_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a feed."""
|
||||
feed = await db.get(Feed, feed_id)
|
||||
if not feed:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feed not found")
|
||||
|
||||
await db.delete(feed)
|
||||
await db.commit()
|
||||
return {"message": "Feed deleted successfully"}
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Health check router."""
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_admin, get_db
|
||||
from app.core.redis import check_redis_health
|
||||
|
||||
router = APIRouter(prefix="/health", tags=["health"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def health_check(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
"""Basic health check."""
|
||||
db_ok = False
|
||||
try:
|
||||
await db.execute(text("SELECT 1"))
|
||||
db_ok = True
|
||||
except Exception:
|
||||
db_ok = False
|
||||
|
||||
redis_ok = await check_redis_health()
|
||||
|
||||
status_code = "ok" if db_ok and redis_ok else "degraded"
|
||||
|
||||
response = {
|
||||
"status": status_code,
|
||||
"service": "rss-platform",
|
||||
"db": "ok" if db_ok else "error",
|
||||
"redis": "ok" if redis_ok else "error",
|
||||
}
|
||||
warnings = getattr(request.app.state, "startup_warnings", None)
|
||||
if warnings:
|
||||
response["warnings"] = warnings
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/db", dependencies=[Depends(get_current_admin)])
|
||||
async def db_health(db: AsyncSession = Depends(get_db)):
|
||||
"""Database health check."""
|
||||
try:
|
||||
await db.execute(text("SELECT 1"))
|
||||
return {"status": "ok", "component": "database"}
|
||||
except Exception as exc:
|
||||
return {"status": "error", "component": "database", "detail": str(exc)}
|
||||
|
||||
|
||||
@router.get("/redis", dependencies=[Depends(get_current_admin)])
|
||||
async def redis_health():
|
||||
"""Redis health check."""
|
||||
ok = await check_redis_health()
|
||||
return {"status": "ok" if ok else "error", "component": "redis"}
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Settings router."""
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_admin, get_current_user, get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.common import MessageResponse
|
||||
from app.services.settings_service import (
|
||||
apply_db_settings_to_config,
|
||||
list_settings,
|
||||
reset_settings,
|
||||
set_setting,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_settings(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all settings."""
|
||||
return await list_settings(db, mask_sensitive=current_user.role != "admin")
|
||||
|
||||
|
||||
@router.put("/{key}")
|
||||
async def update_setting(
|
||||
key: str,
|
||||
value: dict[str, Any],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin),
|
||||
):
|
||||
"""Update a single setting."""
|
||||
if "value" not in value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Request body must contain 'value' field",
|
||||
)
|
||||
|
||||
success = await set_setting(db, key, value["value"])
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid setting key: {key}",
|
||||
)
|
||||
|
||||
await apply_db_settings_to_config(db)
|
||||
return {"message": "Setting updated", "key": key}
|
||||
|
||||
|
||||
@router.put("")
|
||||
async def batch_update_settings(
|
||||
data: dict[str, Any],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin),
|
||||
):
|
||||
"""Update multiple settings."""
|
||||
settings_data = data.get("settings", {})
|
||||
if not settings_data:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Request body must contain 'settings' object",
|
||||
)
|
||||
|
||||
errors = []
|
||||
for key, value in settings_data.items():
|
||||
success = await set_setting(db, key, value)
|
||||
if not success:
|
||||
errors.append(key)
|
||||
|
||||
if errors:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid setting keys: {', '.join(errors)}",
|
||||
)
|
||||
|
||||
await apply_db_settings_to_config(db)
|
||||
return {"message": "Settings updated", "count": len(settings_data)}
|
||||
|
||||
|
||||
@router.post("/reset", response_model=MessageResponse)
|
||||
async def reset_all_settings(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin),
|
||||
):
|
||||
"""Reset all settings to environment defaults."""
|
||||
await reset_settings(db)
|
||||
await apply_db_settings_to_config(db)
|
||||
return {"message": "Settings reset to defaults"}
|
||||
Reference in New Issue
Block a user