전 저장소 리팩터링과 SSOT 정비
This commit is contained in:
parent
14ecbd4e7d
commit
3dfddcac6f
173 changed files with 19679 additions and 6952 deletions
|
|
@ -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;">
|
||||
이후 가입 승인 요청과 회기 검토 요청도 같은 메일 템플릿과 발송 큐를 사용합니다.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue