280 lines
9.3 KiB
Python
280 lines
9.3 KiB
Python
"""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()
|