전 저장소 리팩터링과 SSOT 정비

This commit is contained in:
Yun Chan 2026-07-15 21:31:30 +09:00
parent 14ecbd4e7d
commit 3dfddcac6f
173 changed files with 19679 additions and 6952 deletions

View file

@ -15,10 +15,32 @@ from uuid import uuid4
from ..config import settings
from ..db import acquire, get_pool
from ..runtime_schema import (
NOTIFICATION_SCHEMA_CONTRACT,
runtime_schema_bootstrap_required,
schema_contract_ready,
)
logger = logging.getLogger(__name__)
NotificationKind = Literal["account_pending_approval", "session_review_ready", "admin_test_email"]
NotificationKind = Literal[
"account_pending_approval", "session_review_ready", "admin_test_email"
]
ApprovalRecipientScope = Literal["admin", "super_admin"]
ACTIVE_NOTIFICATION_RECIPIENTS_SQL = """
SELECT DISTINCT
u.user_id,
u.email,
COALESCE(NULLIF(u.display_name, ''), u.email) AS display_name,
u.role
FROM app.app_user u
LEFT JOIN app.user_preferences p ON p.user_id = u.user_id
WHERE u.is_active
AND u.account_status = 'approved'
AND u.email IS NOT NULL
AND u.email <> ''
"""
@dataclass(slots=True)
@ -38,9 +60,14 @@ class RenderedEmail:
async def ensure_notification_tables() -> None:
"""Create notification queue tables when the DB role allows DDL."""
"""Verify notification tables, with DDL repair restricted to local development."""
get_pool()
async with acquire(role="admin") as conn:
ready = await schema_contract_ready(conn, NOTIFICATION_SCHEMA_CONTRACT)
if not runtime_schema_bootstrap_required(
NOTIFICATION_SCHEMA_CONTRACT, ready=ready
):
return
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS app.notification_event (
@ -120,6 +147,10 @@ async def ensure_notification_tables() -> None:
WITH CHECK (app.current_role_name() = 'admin');
"""
)
if not await schema_contract_ready(conn, NOTIFICATION_SCHEMA_CONTRACT):
raise RuntimeError(
"notification development schema bootstrap did not satisfy readiness"
)
def schedule_delivery_flush() -> None:
@ -127,7 +158,9 @@ def schedule_delivery_flush() -> None:
if settings.notification_email_provider == "disabled":
return
try:
asyncio.get_running_loop().create_task(process_queued_email_notifications(limit=10))
asyncio.get_running_loop().create_task(
process_queued_email_notifications(limit=10)
)
except RuntimeError:
return
@ -321,57 +354,39 @@ async def _enqueue_event(
async def _admin_approval_recipients() -> list[NotificationRecipient]:
super_admin_emails = sorted({_normalize_email(value) for value in settings.auth_super_admin_emails})
return await _approval_recipients(scope="admin")
async def _super_admin_recipients() -> list[NotificationRecipient]:
return await _approval_recipients(scope="super_admin")
async def _approval_recipients(
*, scope: ApprovalRecipientScope
) -> list[NotificationRecipient]:
"""가입 승인 알림 수신 정책을 한 쿼리에서 소유한다."""
super_admin_emails = sorted(
{_normalize_email(value) for value in settings.auth_super_admin_emails}
)
super_admin_only = scope == "super_admin"
if super_admin_only and not super_admin_emails:
return []
async with acquire(role="admin") as conn:
rows = await conn.fetch(
"""
SELECT DISTINCT
u.user_id,
u.email,
COALESCE(NULLIF(u.display_name, ''), u.email) AS display_name,
u.role
FROM app.app_user u
LEFT JOIN app.user_preferences p ON p.user_id = u.user_id
WHERE u.is_active
AND u.account_status = 'approved'
AND u.email IS NOT NULL
AND u.email <> ''
ACTIVE_NOTIFICATION_RECIPIENTS_SQL
+ """
AND (
u.role = 'admin'
OR u.admin_access
OR lower(u.email) = ANY($1::text[])
lower(u.email) = ANY($1::text[])
OR (
NOT $2::boolean
AND (u.role = 'admin' OR u.admin_access)
)
)
AND COALESCE((p.notifications->>'account_approval')::boolean, true)
ORDER BY u.email
""",
super_admin_emails,
)
return [_recipient_from_row(row) for row in rows]
async def _super_admin_recipients() -> list[NotificationRecipient]:
super_admin_emails = sorted({_normalize_email(value) for value in settings.auth_super_admin_emails})
if not super_admin_emails:
return []
async with acquire(role="admin") as conn:
rows = await conn.fetch(
"""
SELECT DISTINCT
u.user_id,
u.email,
COALESCE(NULLIF(u.display_name, ''), u.email) AS display_name,
u.role
FROM app.app_user u
LEFT JOIN app.user_preferences p ON p.user_id = u.user_id
WHERE u.is_active
AND u.account_status = 'approved'
AND u.email IS NOT NULL
AND u.email <> ''
AND lower(u.email) = ANY($1::text[])
AND COALESCE((p.notifications->>'account_approval')::boolean, true)
ORDER BY u.email
""",
super_admin_emails,
super_admin_only,
)
return [_recipient_from_row(row) for row in rows]
@ -379,7 +394,9 @@ async def _super_admin_recipients() -> list[NotificationRecipient]:
async def _session_review_payload_and_recipients(
session_id: str,
) -> tuple[dict[str, Any] | None, list[NotificationRecipient]]:
super_admin_emails = sorted({_normalize_email(value) for value in settings.auth_super_admin_emails})
super_admin_emails = sorted(
{_normalize_email(value) for value in settings.auth_super_admin_emails}
)
async with acquire(role="admin") as conn:
session = await conn.fetchrow(
"""
@ -407,18 +424,8 @@ async def _session_review_payload_and_recipients(
return None, []
learner_cohort = str(session["learner_cohort"] or "")
rows = await conn.fetch(
"""
SELECT DISTINCT
u.user_id,
u.email,
COALESCE(NULLIF(u.display_name, ''), u.email) AS display_name,
u.role
FROM app.app_user u
LEFT JOIN app.user_preferences p ON p.user_id = u.user_id
WHERE u.is_active
AND u.account_status = 'approved'
AND u.email IS NOT NULL
AND u.email <> ''
ACTIVE_NOTIFICATION_RECIPIENTS_SQL
+ """
AND (
u.role = 'admin'
OR lower(u.email) = ANY($1::text[])
@ -457,7 +464,9 @@ def _send_rendered_email(
if settings.notification_email_provider == "disabled":
raise NotificationSkipped("email provider is disabled")
if settings.notification_email_provider != "smtp":
raise NotificationSkipped(f"unsupported provider: {settings.notification_email_provider}")
raise NotificationSkipped(
f"unsupported provider: {settings.notification_email_provider}"
)
if not settings.smtp_host.strip() or not settings.smtp_from_email.strip():
raise NotificationSkipped("SMTP host/from email is not configured")
@ -472,7 +481,9 @@ def _send_rendered_email(
if settings.smtp_ssl:
context = ssl.create_default_context()
with smtplib.SMTP_SSL(settings.smtp_host, settings.smtp_port, context=context, timeout=15) as smtp:
with smtplib.SMTP_SSL(
settings.smtp_host, settings.smtp_port, context=context, timeout=15
) as smtp:
_smtp_login_if_needed(smtp)
smtp.send_message(msg)
else:
@ -553,7 +564,9 @@ async def _mark_delivery_failed(delivery_id: str, error: str) -> None:
def _render_account_pending_approval(payload: dict[str, Any]) -> RenderedEmail:
display_name = str(payload.get("display_name") or payload.get("email") or "신규 사용자")
display_name = str(
payload.get("display_name") or payload.get("email") or "신규 사용자"
)
email = str(payload.get("email") or "")
role = _role_label(str(payload.get("role") or "learner"))
approval_url = str(payload.get("approval_url") or _frontend_url("/admin/users"))
@ -563,11 +576,15 @@ def _render_account_pending_approval(payload: dict[str, Any]) -> RenderedEmail:
<p style="margin:0 0 20px;font-size:15px;line-height:1.7;color:#5a6663;">
승인 대기 중인 신규 사용자가 있습니다. 가입 승인 화면에서 수업 또는 연구 참여 범위를 확인한 승인 상태를 결정해 주세요.
</p>
{_info_box([
("사용자", display_name),
("이메일", email),
("요청 역할", role),
])}
{
_info_box(
[
("사용자", display_name),
("이메일", email),
("요청 역할", role),
]
)
}
{_button("가입 승인 확인하기", approval_url)}
<p style="margin:24px 0 0;font-size:12px;line-height:1.6;color:#93a09c;">
계정 승인 전까지 해당 사용자는 Vignette 대기 화면만 있습니다.
@ -602,12 +619,16 @@ def _render_session_review_ready(payload: dict[str, Any]) -> RenderedEmail:
<p style="margin:0 0 20px;font-size:15px;line-height:1.7;color:#5a6663;">
종료된 학습 회기가 교수자 검토 대기 상태입니다. 회기 리뷰 화면에서 요약과 근거를 확인한 검토 상태를 남겨 주세요.
</p>
{_info_box([
("학습자", learner_label),
("내담자", persona_name),
("회기", f"{session_no}회기" if session_no else "종료 회기"),
("종료 시각", ended_at or "기록됨"),
])}
{
_info_box(
[
("학습자", learner_label),
("내담자", persona_name),
("회기", f"{session_no}회기" if session_no else "종료 회기"),
("종료 시각", ended_at or "기록됨"),
]
)
}
{_button("회기 검토하기", review_url)}
<p style="margin:24px 0 0;font-size:12px;line-height:1.6;color:#93a09c;">
민감한 회기 내용은 메일에 포함하지 않았습니다. 로그인 Vignette에서 확인해 주세요.
@ -641,10 +662,14 @@ def _render_admin_test_email(payload: dict[str, Any]) -> RenderedEmail:
<p style="margin:0 0 20px;font-size:15px;line-height:1.7;color:#5a6663;">
관리자 메일 알림이 정상적으로 연결되었습니다. 메일은 실제 가입 승인이나 회기 검토 요청이 아니라 발송 경로 확인용 테스트입니다.
</p>
{_info_box([
("요청자", requested_by),
("용도", "운영 메일 발송 테스트"),
])}
{
_info_box(
[
("요청자", requested_by),
("용도", "운영 메일 발송 테스트"),
]
)
}
{_button("알림 상태 확인하기", notifications_url)}
<p style="margin:24px 0 0;font-size:12px;line-height:1.6;color:#93a09c;">
이후 가입 승인 요청과 회기 검토 요청도 같은 메일 템플릿과 발송 큐를 사용합니다.