Stabilize runtime auth and E2E coverage

This commit is contained in:
Yun Chan 2026-06-26 14:47:00 +09:00
parent 6a3e3b541c
commit 188e899394
133 changed files with 55987 additions and 6775 deletions

View file

@ -0,0 +1,280 @@
"""Current-user profile, preference, and voice-preset routes."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel, Field
from ..auth_sessions import DEFAULT_AFFILIATION, get_managed_user, update_managed_user
from ..db import get_pool
from ..deps import CurrentPrincipal
from ..runtime_policy import require_runtime_fallback_allowed
from ..services.voice import PRESET_RATE, PRESET_TO_OPENAI_VOICE
router = APIRouter(prefix="/users", tags=["users"])
class UserProfileResponse(BaseModel):
user_id: str
email: str
display_name: str
role: str
cohort_ids: list[str]
affiliation: str
class UserProfilePatch(BaseModel):
display_name: str | None = Field(default=None, min_length=1, max_length=80)
affiliation: str | None = Field(default=None, max_length=120)
class NotificationPreferences(BaseModel):
session_done: bool = True
safety_signal: bool = True
learner_progress: bool = False
product_news: bool = False
class UserPreferencesResponse(BaseModel):
theme: str = "system"
voice_preset_id: str = "soft-young-fem"
voice_rate: float = 1.0
notifications: NotificationPreferences = Field(default_factory=NotificationPreferences)
class UserPreferencesPatch(BaseModel):
theme: str | None = None
voice_preset_id: str | None = None
voice_rate: float | None = Field(default=None, ge=0.8, le=1.2)
notifications: NotificationPreferences | None = None
class VoicePresetResponse(BaseModel):
id: str
voice_id: str
name: str
desc: str
persona_hint: str
_preferences: dict[str, UserPreferencesResponse] = {}
VOICE_PRESET_META = {
"soft-young-fem": {
"name": "서린",
"desc": "부드럽고 낮은 긴장감",
"persona_hint": "청소년 내담자",
},
"calm-adult-male": {
"name": "민재",
"desc": "차분하고 안정적인 성인 남성",
"persona_hint": "성인 남성",
},
"warm-adult-fem": {
"name": "지영",
"desc": "따뜻하지만 지친 성인 여성",
"persona_hint": "성인 여성",
},
"neutral": {
"name": "기본",
"desc": "중립적인 기본 음성",
"persona_hint": "범용",
},
}
def _voice_preset_ids() -> set[str]:
return set(PRESET_TO_OPENAI_VOICE.keys())
def _normalize_voice_preset(value: str | None) -> str:
if value in _voice_preset_ids():
return str(value)
return "soft-young-fem"
def _assert_voice_preset(value: str | None) -> None:
if value is None:
return
if value not in _voice_preset_ids():
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"unsupported voice preset {value}",
)
def _voice_presets() -> list[VoicePresetResponse]:
presets: list[VoicePresetResponse] = []
for preset_id in PRESET_TO_OPENAI_VOICE.keys():
meta = VOICE_PRESET_META.get(
preset_id,
{
"name": preset_id,
"desc": f"rate {PRESET_RATE.get(preset_id, 1.0):.2f}",
"persona_hint": "사용자 지정",
},
)
presets.append(
VoicePresetResponse(
id=preset_id,
voice_id=preset_id,
name=meta["name"],
desc=meta["desc"],
persona_hint=meta["persona_hint"],
)
)
return presets
def _preferences_from_row(row) -> UserPreferencesResponse:
return UserPreferencesResponse(
theme=row["theme"],
voice_preset_id=_normalize_voice_preset(row["voice_preset_id"]),
voice_rate=float(row["voice_rate"]),
notifications=NotificationPreferences.model_validate(row["notifications"] or {}),
)
async def _profile_for(principal: CurrentPrincipal) -> UserProfileResponse:
managed = await get_managed_user(principal.user_id)
return UserProfileResponse(
user_id=principal.user_id,
email=principal.email,
display_name=(
(managed.display_name if managed else "")
or principal.display_name
or principal.email
),
role=(managed.role if managed else principal.role.value),
cohort_ids=(managed.cohort_ids if managed else principal.cohort_ids),
affiliation=(managed.affiliation if managed else DEFAULT_AFFILIATION),
)
@router.get("/me", response_model=UserProfileResponse)
async def get_me(principal: CurrentPrincipal) -> UserProfileResponse:
return await _profile_for(principal)
@router.patch("/me", response_model=UserProfileResponse)
async def patch_me(body: UserProfilePatch, principal: CurrentPrincipal) -> UserProfileResponse:
profile = await _profile_for(principal)
await update_managed_user(
principal.user_id,
display_name=body.display_name if body.display_name is not None else profile.display_name,
affiliation=body.affiliation if body.affiliation is not None else profile.affiliation,
)
return await _profile_for(principal)
@router.get("/me/preferences", response_model=UserPreferencesResponse)
async def get_preferences(principal: CurrentPrincipal) -> UserPreferencesResponse:
try:
pool = get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO app.user_preferences (user_id, notifications)
VALUES ($1::uuid, $2::jsonb)
ON CONFLICT (user_id) DO NOTHING
""",
principal.user_id,
NotificationPreferences().model_dump(),
)
row = await conn.fetchrow(
"""
SELECT theme, voice_preset_id, voice_rate, notifications
FROM app.user_preferences
WHERE user_id = $1::uuid
""",
principal.user_id,
)
if row is not None:
return _preferences_from_row(row)
except Exception:
require_runtime_fallback_allowed("user preferences")
return _preferences.setdefault(principal.user_id, UserPreferencesResponse())
@router.patch("/me/preferences", response_model=UserPreferencesResponse)
async def patch_preferences(
body: UserPreferencesPatch,
principal: CurrentPrincipal,
) -> UserPreferencesResponse:
_assert_voice_preset(body.voice_preset_id)
try:
pool = get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO app.user_preferences (user_id, notifications)
VALUES ($1::uuid, $2::jsonb)
ON CONFLICT (user_id) DO NOTHING
""",
principal.user_id,
NotificationPreferences().model_dump(),
)
current = await conn.fetchrow(
"""
SELECT theme, voice_preset_id, voice_rate, notifications
FROM app.user_preferences
WHERE user_id = $1::uuid
""",
principal.user_id,
)
current_prefs = _preferences_from_row(current)
next_prefs = UserPreferencesResponse(
theme=body.theme if body.theme is not None else current_prefs.theme,
voice_preset_id=(
body.voice_preset_id
if body.voice_preset_id is not None
else current_prefs.voice_preset_id
),
voice_rate=body.voice_rate if body.voice_rate is not None else current_prefs.voice_rate,
notifications=(
body.notifications
if body.notifications is not None
else current_prefs.notifications
),
)
row = await conn.fetchrow(
"""
UPDATE app.user_preferences SET
theme = $2,
voice_preset_id = $3,
voice_rate = $4,
notifications = $5::jsonb,
updated_at = now()
WHERE user_id = $1::uuid
RETURNING theme, voice_preset_id, voice_rate, notifications
""",
principal.user_id,
next_prefs.theme,
next_prefs.voice_preset_id,
next_prefs.voice_rate,
next_prefs.notifications.model_dump(),
)
return _preferences_from_row(row)
except HTTPException:
raise
except Exception:
require_runtime_fallback_allowed("user preferences")
current = _preferences.setdefault(principal.user_id, UserPreferencesResponse())
data = current.model_dump()
if body.theme is not None:
data["theme"] = body.theme
if body.voice_preset_id is not None:
data["voice_preset_id"] = body.voice_preset_id
if body.voice_rate is not None:
data["voice_rate"] = body.voice_rate
if body.notifications is not None:
data["notifications"] = body.notifications.model_dump()
next_prefs = UserPreferencesResponse.model_validate(data)
_preferences[principal.user_id] = next_prefs
return next_prefs
@router.get("/me/voice-presets", response_model=list[VoicePresetResponse])
async def get_voice_presets(principal: CurrentPrincipal) -> list[VoicePresetResponse]:
return _voice_presets()