Files
disknext/routers/api/v1/user/settings/__init__.py
于小丘 d831c9c0d6 feat: implement PATCH /user/settings/{option} and fix timezone range to UTC-12~+14
- Add SettingOption StrEnum (nickname/language/timezone) for path param validation
- Add UserSettingUpdateRequest DTO with Pydantic constraints
- Implement endpoint: extract value by option name, validate non-null for required fields
- Fix timezone upper bound from 12 to 14 (UTC+14 exists, e.g. Line Islands)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 20:15:35 +08:00

292 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
import sqlmodels
from middleware.auth import auth_required
from middleware.dependencies import SessionDep
from sqlmodels import (
BUILTIN_DEFAULT_COLORS, ThemePreset, UserThemeUpdateRequest,
SettingOption, UserSettingUpdateRequest,
)
from sqlmodels.color import ThemeColorsBase
from utils import JWT, Password, http_exceptions
from utils.password.pwd import PasswordStatus, TwoFactorResponse, TwoFactorVerifyRequest
user_settings_router = APIRouter(
prefix='/settings',
tags=["user", "user_settings"],
dependencies=[Depends(auth_required)],
)
@user_settings_router.get(
path='/policies',
summary='获取用户可选存储策略',
description='Get user selectable storage policies.',
)
def router_user_settings_policies() -> sqlmodels.ResponseBase:
"""
Get user selectable storage policies.
Returns:
dict: A dictionary containing available storage policies for the user.
"""
http_exceptions.raise_not_implemented()
@user_settings_router.get(
path='/nodes',
summary='获取用户可选节点',
description='Get user selectable nodes.',
dependencies=[Depends(auth_required)],
)
def router_user_settings_nodes() -> sqlmodels.ResponseBase:
"""
Get user selectable nodes.
Returns:
dict: A dictionary containing available nodes for the user.
"""
http_exceptions.raise_not_implemented()
@user_settings_router.get(
path='/tasks',
summary='任务队列',
description='Get user task queue.',
dependencies=[Depends(auth_required)],
)
def router_user_settings_tasks() -> sqlmodels.ResponseBase:
"""
Get user task queue.
Returns:
dict: A dictionary containing the user's task queue information.
"""
http_exceptions.raise_not_implemented()
@user_settings_router.get(
path='/',
summary='获取当前用户设定',
description='Get current user settings.',
)
async def router_user_settings(
session: SessionDep,
user: Annotated[sqlmodels.user.User, Depends(auth_required)],
) -> sqlmodels.UserSettingResponse:
"""
获取当前用户设定
主题颜色合并策略:
1. 用户有颜色快照7个字段均有值→ 直接使用快照
2. 否则查找默认预设 → 使用默认预设颜色
3. 无默认预设 → 使用内置默认值
"""
# 计算主题颜色
has_snapshot = all([
user.color_primary, user.color_secondary, user.color_success,
user.color_info, user.color_warning, user.color_error, user.color_neutral,
])
if has_snapshot:
theme_colors = ThemeColorsBase(
primary=user.color_primary,
secondary=user.color_secondary,
success=user.color_success,
info=user.color_info,
warning=user.color_warning,
error=user.color_error,
neutral=user.color_neutral,
)
else:
default_preset: ThemePreset | None = await ThemePreset.get(
session, ThemePreset.is_default == True # noqa: E712
)
if default_preset:
theme_colors = ThemeColorsBase(
primary=default_preset.primary,
secondary=default_preset.secondary,
success=default_preset.success,
info=default_preset.info,
warning=default_preset.warning,
error=default_preset.error,
neutral=default_preset.neutral,
)
else:
theme_colors = BUILTIN_DEFAULT_COLORS
return sqlmodels.UserSettingResponse(
id=user.id,
email=user.email,
nickname=user.nickname,
created_at=user.created_at,
group_name=user.group.name,
language=user.language,
timezone=user.timezone,
group_expires=user.group_expires,
two_factor=user.two_factor is not None,
theme_preset_id=user.theme_preset_id,
theme_colors=theme_colors,
)
@user_settings_router.post(
path='/avatar',
summary='从文件上传头像',
description='Upload user avatar from file.',
dependencies=[Depends(auth_required)],
)
def router_user_settings_avatar() -> sqlmodels.ResponseBase:
"""
Upload user avatar from file.
Returns:
dict: A dictionary containing the result of the avatar upload.
"""
http_exceptions.raise_not_implemented()
@user_settings_router.put(
path='/avatar',
summary='设定为Gravatar头像',
description='Set user avatar to Gravatar.',
dependencies=[Depends(auth_required)],
status_code=204,
)
def router_user_settings_avatar_gravatar() -> None:
"""
Set user avatar to Gravatar.
Returns:
dict: A dictionary containing the result of setting the Gravatar avatar.
"""
http_exceptions.raise_not_implemented()
@user_settings_router.patch(
path='/theme',
summary='更新用户主题设置',
status_code=status.HTTP_204_NO_CONTENT,
)
async def router_user_settings_theme(
session: SessionDep,
user: Annotated[sqlmodels.user.User, Depends(auth_required)],
request: UserThemeUpdateRequest,
) -> None:
"""
更新用户主题设置
请求体(均可选):
- theme_preset_id: 主题预设UUID
- theme_colors: 颜色配置对象(写入颜色快照)
错误处理:
- 404: 指定的主题预设不存在
"""
# 验证 preset_id 存在性
if request.theme_preset_id is not None:
preset: ThemePreset | None = await ThemePreset.get(
session, ThemePreset.id == request.theme_preset_id
)
if not preset:
http_exceptions.raise_not_found("主题预设不存在")
user.theme_preset_id = request.theme_preset_id
# 将颜色解构到快照列
if request.theme_colors is not None:
user.color_primary = request.theme_colors.primary
user.color_secondary = request.theme_colors.secondary
user.color_success = request.theme_colors.success
user.color_info = request.theme_colors.info
user.color_warning = request.theme_colors.warning
user.color_error = request.theme_colors.error
user.color_neutral = request.theme_colors.neutral
await user.save(session)
@user_settings_router.patch(
path='/{option}',
summary='更新用户设定',
status_code=status.HTTP_204_NO_CONTENT,
)
async def router_user_settings_patch(
session: SessionDep,
user: Annotated[sqlmodels.user.User, Depends(auth_required)],
option: SettingOption,
request: UserSettingUpdateRequest,
) -> None:
"""
更新单个用户设置项
路径参数:
- option: 设置项名称nickname / language / timezone
请求体:
- 包含与 option 同名的字段及其新值
错误处理:
- 422: 无效的 option 或字段值不符合约束
- 400: 必填字段值缺失
"""
value = getattr(request, option.value)
# language / timezone 不允许设为 null
if value is None and option != SettingOption.NICKNAME:
http_exceptions.raise_bad_request(f"设置项 {option.value} 不允许为空")
setattr(user, option.value, value)
await user.save(session)
@user_settings_router.get(
path='/2fa',
summary='获取两步验证初始化信息',
description='Get two-factor authentication initialization information.',
dependencies=[Depends(auth_required)],
)
async def router_user_settings_2fa(
user: Annotated[sqlmodels.user.User, Depends(auth_required)],
) -> TwoFactorResponse:
"""
获取两步验证初始化信息
返回 setup_token用于后续验证请求和 uri用于生成二维码
"""
return await Password.generate_totp(name=user.email)
@user_settings_router.post(
path='/2fa',
summary='启用两步验证',
description='Enable two-factor authentication.',
dependencies=[Depends(auth_required)],
status_code=204,
)
async def router_user_settings_2fa_enable(
session: SessionDep,
user: Annotated[sqlmodels.user.User, Depends(auth_required)],
request: TwoFactorVerifyRequest,
) -> None:
"""
启用两步验证
请求体包含 setup_tokenGET /2fa 返回的令牌)和 code6 位验证码)。
"""
serializer = URLSafeTimedSerializer(JWT.SECRET_KEY)
try:
secret = serializer.loads(request.setup_token, salt="2fa-setup-salt", max_age=600)
except SignatureExpired:
raise HTTPException(status_code=400, detail="Setup session expired")
except BadSignature:
raise HTTPException(status_code=400, detail="Invalid token")
if Password.verify_totp(secret, request.code) != PasswordStatus.VALID:
raise HTTPException(status_code=400, detail="Invalid OTP code")
# 3. 将 secret 存储到用户的数据库记录中,启用 2FA
user.two_factor = secret
user = await user.save(session)