mirror of
https://github.com/Cccc-owo/CheckInApp.git
synced 2026-06-17 05:56:29 +00:00
feat(email): require verified approval email
Backfill approved legacy users with verified emails and replace the old unverified-email warning setting with a single approval email policy.
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.engine import Connection
|
||||
|
||||
|
||||
def _table_columns(conn: Connection, table_name: str) -> set[str]:
|
||||
rows = conn.execute(text(f"PRAGMA table_info({table_name})")).fetchall()
|
||||
return {str(row[1]) for row in rows}
|
||||
|
||||
|
||||
def apply(conn: Connection) -> None:
|
||||
columns = _table_columns(conn, "email_notification_settings")
|
||||
if "require_verified_email_for_approval" not in columns:
|
||||
conn.execute(
|
||||
text(
|
||||
"ALTER TABLE email_notification_settings "
|
||||
"ADD COLUMN require_verified_email_for_approval BOOLEAN NOT NULL DEFAULT 1"
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
columns = _table_columns(conn, "email_notification_settings")
|
||||
|
||||
if "warn_unverified_email_before_approval" in columns:
|
||||
conn.execute(
|
||||
text(
|
||||
"ALTER TABLE email_notification_settings "
|
||||
"DROP COLUMN warn_unverified_email_before_approval"
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
@@ -18,7 +18,7 @@ def apply(conn: Connection) -> None:
|
||||
notify_token_expiring BOOLEAN NOT NULL DEFAULT 1,
|
||||
notify_check_in_success BOOLEAN NOT NULL DEFAULT 1,
|
||||
require_admin_approval_for_registration BOOLEAN NOT NULL DEFAULT 1,
|
||||
warn_unverified_email_before_approval BOOLEAN NOT NULL DEFAULT 1,
|
||||
require_verified_email_for_approval BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME
|
||||
)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.engine import Connection
|
||||
|
||||
|
||||
def apply(conn: Connection) -> None:
|
||||
verified_at = datetime.now(timezone.utc).isoformat()
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE users
|
||||
SET email_verified_at = :verified_at
|
||||
WHERE email_verified_at IS NULL
|
||||
AND email IS NOT NULL
|
||||
AND email != ''
|
||||
AND is_approved = 1
|
||||
"""
|
||||
),
|
||||
{"verified_at": verified_at},
|
||||
)
|
||||
conn.commit()
|
||||
@@ -19,6 +19,16 @@ def _add_column_if_missing(
|
||||
return columns
|
||||
|
||||
|
||||
def _drop_column_if_present(
|
||||
conn: Connection, table_name: str, columns: set[str], column_name: str
|
||||
) -> set[str]:
|
||||
if column_name in columns:
|
||||
conn.execute(text(f"ALTER TABLE {table_name} DROP COLUMN {column_name}"))
|
||||
conn.commit()
|
||||
return _table_columns(conn, table_name)
|
||||
return columns
|
||||
|
||||
|
||||
def apply(conn: Connection) -> None:
|
||||
user_columns = _table_columns(conn, "users")
|
||||
user_columns = _add_column_if_missing(
|
||||
@@ -51,10 +61,16 @@ def apply(conn: Connection) -> None:
|
||||
"require_admin_approval_for_registration",
|
||||
"require_admin_approval_for_registration BOOLEAN NOT NULL DEFAULT 1",
|
||||
)
|
||||
_add_column_if_missing(
|
||||
settings_columns = _add_column_if_missing(
|
||||
conn,
|
||||
"email_notification_settings",
|
||||
settings_columns,
|
||||
"require_verified_email_for_approval",
|
||||
"require_verified_email_for_approval BOOLEAN NOT NULL DEFAULT 1",
|
||||
)
|
||||
_drop_column_if_present(
|
||||
conn,
|
||||
"email_notification_settings",
|
||||
settings_columns,
|
||||
"warn_unverified_email_before_approval",
|
||||
"warn_unverified_email_before_approval BOOLEAN NOT NULL DEFAULT 1",
|
||||
)
|
||||
|
||||
@@ -12,6 +12,12 @@ from backend.migration_steps.account_lockout import apply as apply_account_locko
|
||||
from backend.migration_steps.email_notification_settings import (
|
||||
apply as apply_email_notification_settings,
|
||||
)
|
||||
from backend.migration_steps.email_approval_policy import (
|
||||
apply as apply_email_approval_policy,
|
||||
)
|
||||
from backend.migration_steps.legacy_user_email_verification import (
|
||||
apply as apply_legacy_user_email_verification,
|
||||
)
|
||||
from backend.migration_steps.task_thread_id import apply as apply_task_thread_id
|
||||
from backend.migration_steps.user_email_verification import (
|
||||
apply as apply_user_email_verification,
|
||||
@@ -102,6 +108,16 @@ MIGRATIONS: tuple[Migration, ...] = (
|
||||
description="Add user email verification fields and registration approval policy flags.",
|
||||
apply=apply_user_email_verification,
|
||||
),
|
||||
Migration(
|
||||
id="2026050602_backfill_legacy_verified_emails",
|
||||
description="Trust existing approved user email addresses after verification rollout.",
|
||||
apply=apply_legacy_user_email_verification,
|
||||
),
|
||||
Migration(
|
||||
id="2026050603_remove_legacy_email_approval_warning",
|
||||
description="Replace legacy unverified-email warning setting with approval email policy.",
|
||||
apply=apply_email_approval_policy,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ class EmailNotificationSettings(Base):
|
||||
require_admin_approval_for_registration: Mapped[bool] = mapped_column(
|
||||
Boolean, default=True, nullable=False
|
||||
)
|
||||
warn_unverified_email_before_approval: Mapped[bool] = mapped_column(
|
||||
require_verified_email_for_approval: Mapped[bool] = mapped_column(
|
||||
Boolean, default=True, nullable=False
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
|
||||
@@ -16,8 +16,8 @@ class EmailNotificationSettingsBase(BaseModel):
|
||||
require_admin_approval_for_registration: bool = Field(
|
||||
True, description="新注册是否需要管理员审批"
|
||||
)
|
||||
warn_unverified_email_before_approval: bool = Field(
|
||||
True, description="审批未验证邮箱用户时是否警告"
|
||||
require_verified_email_for_approval: bool = Field(
|
||||
True, description="审批前是否要求用户完成邮箱验证"
|
||||
)
|
||||
|
||||
@field_validator("smtp_server", "smtp_sender_email", mode="before")
|
||||
|
||||
@@ -20,7 +20,7 @@ class AdminService:
|
||||
email_changed = next_email is not None and next_email != user.email
|
||||
has_verified_email = bool(user.email and user.email_verified_at and not email_changed)
|
||||
should_warn = (
|
||||
EmailSettingsService.should_warn_unverified_email_before_approval()
|
||||
EmailSettingsService.is_verified_email_required_for_approval()
|
||||
and not has_verified_email
|
||||
)
|
||||
if should_warn and not allow_unverified_email:
|
||||
|
||||
@@ -28,7 +28,7 @@ class EmailSettingsSnapshot:
|
||||
notify_token_expiring: bool
|
||||
notify_check_in_success: bool
|
||||
require_admin_approval_for_registration: bool
|
||||
warn_unverified_email_before_approval: bool
|
||||
require_verified_email_for_approval: bool
|
||||
has_smtp_sender_password: bool
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
@@ -49,7 +49,7 @@ class EmailSettingsService:
|
||||
notify_token_expiring=True,
|
||||
notify_check_in_success=True,
|
||||
require_admin_approval_for_registration=True,
|
||||
warn_unverified_email_before_approval=True,
|
||||
require_verified_email_for_approval=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -65,7 +65,7 @@ class EmailSettingsService:
|
||||
notify_token_expiring=True,
|
||||
notify_check_in_success=True,
|
||||
require_admin_approval_for_registration=True,
|
||||
warn_unverified_email_before_approval=True,
|
||||
require_verified_email_for_approval=True,
|
||||
has_smtp_sender_password=bool(password),
|
||||
created_at=None,
|
||||
updated_at=None,
|
||||
@@ -102,7 +102,7 @@ class EmailSettingsService:
|
||||
require_admin_approval_for_registration=bool(
|
||||
row.require_admin_approval_for_registration
|
||||
),
|
||||
warn_unverified_email_before_approval=bool(row.warn_unverified_email_before_approval),
|
||||
require_verified_email_for_approval=bool(row.require_verified_email_for_approval),
|
||||
has_smtp_sender_password=bool(password),
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
@@ -119,7 +119,7 @@ class EmailSettingsService:
|
||||
notify_token_expiring=snapshot.notify_token_expiring,
|
||||
notify_check_in_success=snapshot.notify_check_in_success,
|
||||
require_admin_approval_for_registration=snapshot.require_admin_approval_for_registration,
|
||||
warn_unverified_email_before_approval=snapshot.warn_unverified_email_before_approval,
|
||||
require_verified_email_for_approval=snapshot.require_verified_email_for_approval,
|
||||
has_smtp_sender_password=snapshot.has_smtp_sender_password,
|
||||
created_at=snapshot.created_at,
|
||||
updated_at=snapshot.updated_at,
|
||||
@@ -147,7 +147,7 @@ class EmailSettingsService:
|
||||
row.require_admin_approval_for_registration = (
|
||||
payload.require_admin_approval_for_registration
|
||||
)
|
||||
row.warn_unverified_email_before_approval = payload.warn_unverified_email_before_approval
|
||||
row.require_verified_email_for_approval = payload.require_verified_email_for_approval
|
||||
|
||||
if payload.clear_smtp_sender_password:
|
||||
row.smtp_sender_password = ""
|
||||
@@ -223,14 +223,12 @@ class EmailSettingsService:
|
||||
db.close()
|
||||
|
||||
@staticmethod
|
||||
def should_warn_unverified_email_before_approval() -> bool:
|
||||
def is_verified_email_required_for_approval() -> bool:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
try:
|
||||
return EmailSettingsService.get_snapshot(db).warn_unverified_email_before_approval
|
||||
return EmailSettingsService.get_snapshot(db).require_verified_email_for_approval
|
||||
except SQLAlchemyError:
|
||||
return (
|
||||
EmailSettingsService._default_snapshot().warn_unverified_email_before_approval
|
||||
)
|
||||
return EmailSettingsService._default_snapshot().require_verified_email_for_approval
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -257,13 +257,11 @@ class UserService:
|
||||
user.alias = update_data["alias"]
|
||||
logger.info(f"用户 ID {user_id} 别名更新: {user.alias}")
|
||||
|
||||
# 更新邮箱
|
||||
# 邮箱必须走 /users/me/email 的验证码流程,不能从普通资料保存绕过。
|
||||
if "email" in update_data:
|
||||
next_email = str(update_data["email"]) if update_data["email"] is not None else None
|
||||
if next_email != user.email:
|
||||
UserService._clear_email_verification(user)
|
||||
user.email = next_email
|
||||
logger.info(f"用户 ID {user_id} 邮箱更新: {user.email}")
|
||||
raise ValueError("邮箱修改需要先完成验证码验证")
|
||||
|
||||
# 更新密码
|
||||
if "new_password" in update_data and update_data["new_password"]:
|
||||
|
||||
Reference in New Issue
Block a user