Stabilize runtime auth and E2E coverage
This commit is contained in:
parent
6a3e3b541c
commit
188e899394
133 changed files with 55987 additions and 6775 deletions
280
apps/api/app/routes/users.py
Normal file
280
apps/api/app/routes/users.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue