개선관리 요구사항과 Google 로그인을 완료

This commit is contained in:
Yun Chan 2026-08-28 16:07:09 +09:00
parent cc0a15b7c6
commit 2a39636163
112 changed files with 10166 additions and 527 deletions

View file

@ -39,6 +39,7 @@ class SessionUser:
consent_at: float | None
profile_completed_at: float | None
expires_at: float
learner_feedback_enabled: bool = True
@dataclass(slots=True)
@ -67,6 +68,7 @@ class ManagedUser:
privacy_version: str
created_at: float
last_seen_at: float
learner_feedback_enabled: bool = True
@dataclass(slots=True)
@ -93,6 +95,7 @@ class ManagedUserMemoryInput:
privacy_agreed_at: float | None = None
terms_version: str | None = None
privacy_version: str | None = None
learner_feedback_enabled: bool | None = None
reactivate: bool = False
@classmethod
@ -125,6 +128,7 @@ class ManagedUserMemoryInput:
privacy_agreed_at=user.privacy_agreed_at,
terms_version=user.terms_version,
privacy_version=user.privacy_version,
learner_feedback_enabled=user.learner_feedback_enabled,
reactivate=reactivate,
)
@ -140,6 +144,7 @@ class ManagedUserUpsertInput:
external_id: str | None = None
affiliation: str | None = None
account_status: AccountStatus | None = None
learner_feedback_enabled: bool | None = None
reactivate: bool = False
@ -162,6 +167,7 @@ class ManagedUserPatch:
complete_onboarding: bool = False
terms_version: str | None = None
privacy_version: str | None = None
learner_feedback_enabled: bool | None = None
class InactiveUserError(Exception):
@ -203,6 +209,21 @@ def _normalize_email(email: str) -> str:
return email.strip().lower()
def _email_domain(email: str) -> str:
normalized = _normalize_email(email)
if "@" not in normalized:
return ""
return normalized.rsplit("@", 1)[1]
def _allowed_email_domain_set() -> set[str]:
return {
normalized
for value in settings.auth_allowed_email_domains
if (normalized := str(value).strip().lower().lstrip("@"))
}
def _normalize_external_id(external_id: str | None, email: str) -> str:
value = (external_id or "").strip().lower()
return value or f"email:{_normalize_email(email)}"
@ -270,7 +291,13 @@ def _initial_account_status(
normalized_email = _normalize_email(email)
if reactivate or normalized_email in _auto_approved_email_set():
return "approved"
if settings.environment == "dev" and _is_dev_login_external_id(external_id):
# 로컬 E2E 자동 승인은 허용된 내부 도메인에만 적용한다. 관리자 exact-email로
# 사전등록한 외부 연구참여자의 pending 상태를 dev 로그인이 승격하면 안 된다.
if (
settings.environment == "dev"
and _is_dev_login_external_id(external_id)
and _email_domain(normalized_email) in _allowed_email_domain_set()
):
return "approved"
return _account_status(settings.auth_new_user_default_status)
@ -332,10 +359,11 @@ async def _runtime_tables_ready(conn) -> bool:
'last_seen_at',
'account_status',
'admin_access',
'learner_feedback_enabled',
'updated_at'
)
GROUP BY table_schema, table_name
HAVING count(*) = 19
HAVING count(*) = 20
) AS has_user_columns,
EXISTS (
SELECT 1 FROM information_schema.columns
@ -362,10 +390,11 @@ async def _runtime_tables_ready(conn) -> bool:
'persona_display_name',
'persona_difficulty',
'prev_rapport_credit',
'session_goals'
'session_goals',
'learner_feedback_enabled'
)
GROUP BY table_schema, table_name
HAVING count(*) = 6
HAVING count(*) = 7
) AS has_session_columns,
EXISTS (
SELECT 1 FROM information_schema.columns
@ -542,10 +571,19 @@ async def ensure_runtime_tables() -> None:
"""
DO $$
BEGIN
IF to_regclass('app.persona_card') IS NOT NULL THEN
IF to_regclass('app.persona_card') IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app'
AND table_name = 'persona_card'
AND column_name = 'triggers'
) THEN
ALTER TABLE app.persona_card
ADD COLUMN IF NOT EXISTS triggers JSONB NOT NULL DEFAULT '{}'::jsonb;
END IF;
IF to_regclass('app.persona_card') IS NOT NULL
AND to_regclass('app.persona_voice_map') IS NULL THEN
CREATE TABLE IF NOT EXISTS app.persona_voice_map (
persona_id UUID NOT NULL,
version INT NOT NULL,
@ -564,6 +602,7 @@ async def ensure_runtime_tables() -> None:
WHERE table_schema = 'app'
AND table_name = 'app_user'
AND column_name = 'affiliation'
AND column_default IS DISTINCT FROM ''''::text
) THEN
ALTER TABLE app.app_user ALTER COLUMN affiliation SET DEFAULT '';
END IF;
@ -593,6 +632,7 @@ async def ensure_runtime_tables() -> None:
ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ADD COLUMN IF NOT EXISTS account_status TEXT NOT NULL DEFAULT 'approved',
ADD COLUMN IF NOT EXISTS admin_access BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS learner_feedback_enabled BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
"""
)
@ -926,7 +966,8 @@ async def ensure_runtime_tables() -> None:
ADD COLUMN IF NOT EXISTS persona_display_name TEXT,
ADD COLUMN IF NOT EXISTS persona_difficulty TEXT,
ADD COLUMN IF NOT EXISTS prev_rapport_credit REAL NOT NULL DEFAULT 0.0,
ADD COLUMN IF NOT EXISTS session_goals JSONB NOT NULL DEFAULT '[]'::jsonb
ADD COLUMN IF NOT EXISTS session_goals JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS learner_feedback_enabled BOOLEAN NOT NULL DEFAULT true
"""
)
await conn.execute(
@ -1108,6 +1149,9 @@ def _managed_user_from_row(row) -> ManagedUser:
privacy_version=_row_value(row, "privacy_version", "") or "",
created_at=_ts(row["created_at"]),
last_seen_at=_ts(row["last_seen_at"]),
learner_feedback_enabled=bool(
_row_value(row, "learner_feedback_enabled", True)
),
)
@ -1133,8 +1177,14 @@ def _memory_upsert_managed_user(data: ManagedUserMemoryInput) -> ManagedUser:
admin_access=data.admin_access
if data.admin_access is not None
else (current.admin_access if current else False),
account_status=data.account_status
or (current.account_status if current else "approved"),
account_status=(
"suspended"
if current is not None
and current.account_status == "suspended"
and not data.reactivate
else data.account_status
or (current.account_status if current else "approved")
),
cohort_ids=(
list(data.cohort_ids)
if data.cohort_ids is not None
@ -1209,6 +1259,11 @@ def _memory_upsert_managed_user(data: ManagedUserMemoryInput) -> ManagedUser:
),
created_at=current.created_at if current else now,
last_seen_at=now,
learner_feedback_enabled=(
data.learner_feedback_enabled
if data.learner_feedback_enabled is not None
else (current.learner_feedback_enabled if current else True)
),
)
_users[uid] = user
_email_index[normalized_email] = uid
@ -1232,6 +1287,7 @@ def _sync_managed_user_sessions(user: ManagedUser) -> None:
session.cohort_ids = list(user.cohort_ids)
session.consent_at = user.consent_at
session.profile_completed_at = user.profile_completed_at
session.learner_feedback_enabled = user.learner_feedback_enabled
async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
@ -1267,6 +1323,11 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
WHEN $7::boolean IS NULL THEN admin_access
ELSE $7::boolean
END,
account_status = CASE
WHEN account_status = 'suspended' THEN 'suspended'
WHEN $8::text = 'approved' THEN 'approved'
ELSE account_status
END,
last_seen_at = now(),
updated_at = now()
WHERE user_id = (
@ -1288,6 +1349,7 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
display_name,
role,
admin_access,
learner_feedback_enabled,
account_status,
cohort,
affiliation,
@ -1319,6 +1381,7 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
data.affiliation or DEFAULT_AFFILIATION,
manual_external_id,
desired_admin_access,
desired_account_status,
)
if row is not None:
user = _managed_user_from_row(row)
@ -1334,13 +1397,17 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
display_name,
role,
admin_access,
learner_feedback_enabled,
cohort,
affiliation,
account_status,
last_seen_at,
updated_at
)
VALUES ($1, $2, $3, $4, COALESCE($9::boolean, false), $5, $6, $8, now(), now())
VALUES (
$1, $2, $3, $4, COALESCE($9::boolean, false),
COALESCE($10::boolean, true), $5, $6, $8, now(), now()
)
ON CONFLICT (external_id) DO UPDATE SET
email = EXCLUDED.email,
display_name = COALESCE(NULLIF(EXCLUDED.display_name, ''), app.app_user.display_name),
@ -1349,6 +1416,10 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
WHEN $9::boolean IS NULL THEN app.app_user.admin_access
ELSE EXCLUDED.admin_access
END,
learner_feedback_enabled = CASE
WHEN $10::boolean IS NULL THEN app.app_user.learner_feedback_enabled
ELSE EXCLUDED.learner_feedback_enabled
END,
cohort = COALESCE(EXCLUDED.cohort, app.app_user.cohort),
affiliation = COALESCE(NULLIF(EXCLUDED.affiliation, ''), app.app_user.affiliation),
is_active = CASE WHEN $7 THEN TRUE ELSE app.app_user.is_active END,
@ -1367,6 +1438,7 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
display_name,
role,
admin_access,
learner_feedback_enabled,
account_status,
cohort,
affiliation,
@ -1396,6 +1468,7 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
data.reactivate,
desired_account_status,
desired_admin_access,
data.learner_feedback_enabled,
)
if row is None:
_inactive_emails.add(normalized_email)
@ -1438,6 +1511,7 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
cohort_ids=data.cohort_ids,
user_id=fallback_uid,
affiliation=data.affiliation,
learner_feedback_enabled=data.learner_feedback_enabled,
reactivate=data.reactivate,
)
)
@ -1455,6 +1529,7 @@ async def get_managed_user(user_id: str) -> ManagedUser | None:
display_name,
role,
admin_access,
learner_feedback_enabled,
account_status,
cohort,
affiliation,
@ -1504,6 +1579,7 @@ async def get_managed_user_by_email(email: str) -> ManagedUser | None:
display_name,
role,
admin_access,
learner_feedback_enabled,
account_status,
cohort,
affiliation,
@ -1555,6 +1631,7 @@ async def list_managed_users() -> tuple[list[ManagedUser], bool]:
display_name,
role,
admin_access,
learner_feedback_enabled,
account_status,
cohort,
affiliation,
@ -1599,6 +1676,7 @@ async def update_managed_user(
role = COALESCE($3, role),
account_status = COALESCE($18, account_status),
admin_access = COALESCE($19, admin_access),
learner_feedback_enabled = COALESCE($20, learner_feedback_enabled),
cohort = CASE WHEN $4 THEN $5 ELSE cohort END,
affiliation = COALESCE($6, affiliation),
legal_name = COALESCE($7, legal_name),
@ -1623,6 +1701,7 @@ async def update_managed_user(
display_name,
role,
admin_access,
learner_feedback_enabled,
account_status,
cohort,
affiliation,
@ -1670,6 +1749,7 @@ async def update_managed_user(
else None,
patch.account_status,
patch.admin_access,
patch.learner_feedback_enabled,
)
if row is not None:
next_user = _managed_user_from_row(row)
@ -1698,6 +1778,9 @@ async def update_managed_user(
account_status=patch.account_status
if patch.account_status is not None
else current.account_status,
learner_feedback_enabled=patch.learner_feedback_enabled
if patch.learner_feedback_enabled is not None
else current.learner_feedback_enabled,
cohort_ids=list(patch.cohort_ids)
if patch.cohort_ids is not None
else current.cohort_ids,
@ -1934,6 +2017,7 @@ async def create_session(
cohort_ids: list[str] | None = None,
user_id: str | None = None,
external_id: str | None = None,
account_status: AccountStatus | None = None,
) -> tuple[str, SessionUser]:
raw_sid = secrets.token_urlsafe(32)
normalized_email = _normalize_email(email)
@ -1945,6 +2029,7 @@ async def create_session(
cohort_ids=cohort_ids,
user_id=user_id,
external_id=external_id,
account_status=account_status,
reactivate=False,
)
)
@ -1973,6 +2058,7 @@ async def create_session(
consent_at=managed.consent_at,
profile_completed_at=managed.profile_completed_at,
expires_at=expires_at,
learner_feedback_enabled=managed.learner_feedback_enabled,
)
sid_hash = _sid_hash(raw_sid)
try:
@ -2022,6 +2108,7 @@ async def get_session(raw_sid: str | None) -> SessionUser | None:
COALESCE(u.display_name, s.display_name, u.email) AS display_name,
u.role,
u.admin_access,
u.learner_feedback_enabled,
u.account_status,
u.cohort,
u.consent_at,
@ -2078,6 +2165,9 @@ async def get_session(raw_sid: str | None) -> SessionUser | None:
consent_at=_optional_ts(row["consent_at"]),
profile_completed_at=_optional_ts(row["profile_completed_at"]),
expires_at=_ts(row["expires_at"]),
learner_feedback_enabled=bool(
_row_value(row, "learner_feedback_enabled", True)
),
)
except Exception:
require_runtime_fallback_allowed("browser session")
@ -2103,6 +2193,7 @@ async def get_session(raw_sid: str | None) -> SessionUser | None:
user.cohort_ids = list(managed.cohort_ids)
user.consent_at = managed.consent_at
user.profile_completed_at = managed.profile_completed_at
user.learner_feedback_enabled = managed.learner_feedback_enabled
return user