mirror of
https://github.com/Cccc-owo/CheckInApp.git
synced 2026-06-17 05:56:29 +00:00
feat(email): add admin notification settings
This commit is contained in:
@@ -6,9 +6,14 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from backend.models import get_db, User, CheckInTask
|
from backend.models import get_db, User, CheckInTask
|
||||||
from backend.schemas.check_in import BatchCheckInRequest
|
from backend.schemas.check_in import BatchCheckInRequest
|
||||||
|
from backend.schemas.email_settings import (
|
||||||
|
EmailNotificationSettingsResponse,
|
||||||
|
EmailNotificationSettingsUpdate,
|
||||||
|
)
|
||||||
from backend.schemas.user import UserResponse
|
from backend.schemas.user import UserResponse
|
||||||
from backend.services.check_in_service import CheckInService
|
from backend.services.check_in_service import CheckInService
|
||||||
from backend.services.admin_service import AdminService
|
from backend.services.admin_service import AdminService
|
||||||
|
from backend.services.email_settings_service import EmailSettingsService
|
||||||
from backend.dependencies import get_current_admin_user
|
from backend.dependencies import get_current_admin_user
|
||||||
from backend.config import settings
|
from backend.config import settings
|
||||||
from backend.exceptions import BaseAPIException
|
from backend.exceptions import BaseAPIException
|
||||||
@@ -25,6 +30,49 @@ class BatchToggleTasksRequest(BaseModel):
|
|||||||
is_active: bool
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/email_settings",
|
||||||
|
response_model=EmailNotificationSettingsResponse,
|
||||||
|
summary="获取邮件与通知设置",
|
||||||
|
)
|
||||||
|
async def get_email_settings(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_admin_user),
|
||||||
|
):
|
||||||
|
"""获取管理员可配置的 SMTP 与通知策略设置。"""
|
||||||
|
try:
|
||||||
|
return EmailSettingsService.get_response(db)
|
||||||
|
except EXPECTED_API_EXCEPTIONS:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"获取邮件设置失败: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/email_settings",
|
||||||
|
response_model=EmailNotificationSettingsResponse,
|
||||||
|
summary="更新邮件与通知设置",
|
||||||
|
)
|
||||||
|
async def update_email_settings(
|
||||||
|
request: EmailNotificationSettingsUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_admin_user),
|
||||||
|
):
|
||||||
|
"""更新管理员可配置的 SMTP 与通知策略设置。"""
|
||||||
|
try:
|
||||||
|
return EmailSettingsService.update_response(db, request)
|
||||||
|
except EXPECTED_API_EXCEPTIONS:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"更新邮件设置失败: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/batch_toggle_tasks", summary="批量启用/禁用任务")
|
@router.post("/batch_toggle_tasks", summary="批量启用/禁用任务")
|
||||||
async def batch_toggle_tasks(
|
async def batch_toggle_tasks(
|
||||||
request: BatchToggleTasksRequest,
|
request: BatchToggleTasksRequest,
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
|
||||||
|
|
||||||
|
def apply(conn: Connection) -> None:
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS email_notification_settings (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
smtp_server VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
smtp_port INTEGER NOT NULL DEFAULT 465,
|
||||||
|
smtp_sender_email VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
smtp_sender_password VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
smtp_use_ssl BOOLEAN NOT NULL DEFAULT 1,
|
||||||
|
notify_token_expiring BOOLEAN NOT NULL DEFAULT 1,
|
||||||
|
notify_check_in_success BOOLEAN NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
@@ -9,6 +9,9 @@ from sqlalchemy import Engine, text
|
|||||||
from sqlalchemy.engine import Connection
|
from sqlalchemy.engine import Connection
|
||||||
|
|
||||||
from backend.migration_steps.account_lockout import apply as apply_account_lockout
|
from backend.migration_steps.account_lockout import apply as apply_account_lockout
|
||||||
|
from backend.migration_steps.email_notification_settings import (
|
||||||
|
apply as apply_email_notification_settings,
|
||||||
|
)
|
||||||
from backend.migration_steps.task_thread_id import apply as apply_task_thread_id
|
from backend.migration_steps.task_thread_id import apply as apply_task_thread_id
|
||||||
from backend.models.database import engine as default_engine
|
from backend.models.database import engine as default_engine
|
||||||
|
|
||||||
@@ -86,6 +89,11 @@ MIGRATIONS: tuple[Migration, ...] = (
|
|||||||
description="Add and backfill check-in task thread identity.",
|
description="Add and backfill check-in task thread identity.",
|
||||||
apply=apply_task_thread_id,
|
apply=apply_task_thread_id,
|
||||||
),
|
),
|
||||||
|
Migration(
|
||||||
|
id="2026050501_add_email_notification_settings",
|
||||||
|
description="Add admin-managed email notification settings.",
|
||||||
|
apply=apply_email_notification_settings,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,5 +3,15 @@ from backend.models.user import User
|
|||||||
from backend.models.check_in_task import CheckInTask
|
from backend.models.check_in_task import CheckInTask
|
||||||
from backend.models.check_in_record import CheckInRecord
|
from backend.models.check_in_record import CheckInRecord
|
||||||
from backend.models.task_template import TaskTemplate
|
from backend.models.task_template import TaskTemplate
|
||||||
|
from backend.models.email_settings import EmailNotificationSettings
|
||||||
|
|
||||||
__all__ = ["Base", "get_db", "init_db", "User", "CheckInTask", "CheckInRecord", "TaskTemplate"]
|
__all__ = [
|
||||||
|
"Base",
|
||||||
|
"get_db",
|
||||||
|
"init_db",
|
||||||
|
"User",
|
||||||
|
"CheckInTask",
|
||||||
|
"CheckInRecord",
|
||||||
|
"TaskTemplate",
|
||||||
|
"EmailNotificationSettings",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from backend.models.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class EmailNotificationSettings(Base):
|
||||||
|
"""系统邮件配置与通知策略设置。"""
|
||||||
|
|
||||||
|
__tablename__ = "email_notification_settings"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
||||||
|
smtp_server: Mapped[str] = mapped_column(String(255), default="", nullable=False)
|
||||||
|
smtp_port: Mapped[int] = mapped_column(Integer, default=465, nullable=False)
|
||||||
|
smtp_sender_email: Mapped[str] = mapped_column(String(255), default="", nullable=False)
|
||||||
|
smtp_sender_password: Mapped[str] = mapped_column(String(500), default="", nullable=False)
|
||||||
|
smtp_use_ssl: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
notify_token_expiring: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
notify_check_in_success: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), comment="创建时间"
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), onupdate=func.now(), comment="更新时间"
|
||||||
|
)
|
||||||
@@ -37,6 +37,10 @@ from backend.schemas.template import (
|
|||||||
TaskFromTemplateRequest,
|
TaskFromTemplateRequest,
|
||||||
TemplatePreviewResponse,
|
TemplatePreviewResponse,
|
||||||
)
|
)
|
||||||
|
from backend.schemas.email_settings import (
|
||||||
|
EmailNotificationSettingsResponse,
|
||||||
|
EmailNotificationSettingsUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"UserBase",
|
"UserBase",
|
||||||
@@ -68,4 +72,6 @@ __all__ = [
|
|||||||
"TemplateResponse",
|
"TemplateResponse",
|
||||||
"TaskFromTemplateRequest",
|
"TaskFromTemplateRequest",
|
||||||
"TemplatePreviewResponse",
|
"TemplatePreviewResponse",
|
||||||
|
"EmailNotificationSettingsResponse",
|
||||||
|
"EmailNotificationSettingsUpdate",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from email_validator import EmailNotValidError, validate_email
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class EmailNotificationSettingsBase(BaseModel):
|
||||||
|
smtp_server: str = Field("", max_length=255, description="SMTP 服务器地址")
|
||||||
|
smtp_port: int = Field(465, ge=1, le=65535, description="SMTP 端口")
|
||||||
|
smtp_sender_email: str = Field("", max_length=255, description="发件邮箱")
|
||||||
|
smtp_use_ssl: bool = Field(True, description="是否使用 SMTP SSL")
|
||||||
|
notify_token_expiring: bool = Field(True, description="是否通知 Token 即将过期")
|
||||||
|
notify_check_in_success: bool = Field(True, description="是否通知打卡成功")
|
||||||
|
|
||||||
|
@field_validator("smtp_server", "smtp_sender_email", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def strip_text(cls, value: object) -> object:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.strip()
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("smtp_sender_email")
|
||||||
|
@classmethod
|
||||||
|
def validate_sender_email(cls, value: str) -> str:
|
||||||
|
if value == "":
|
||||||
|
return value
|
||||||
|
try:
|
||||||
|
return str(validate_email(value, check_deliverability=False).normalized)
|
||||||
|
except EmailNotValidError as exc:
|
||||||
|
raise ValueError("发件邮箱格式无效") from exc
|
||||||
|
|
||||||
|
|
||||||
|
class EmailNotificationSettingsUpdate(EmailNotificationSettingsBase):
|
||||||
|
smtp_sender_password: str | None = Field(
|
||||||
|
None,
|
||||||
|
max_length=500,
|
||||||
|
description="新 SMTP 密码;省略表示保留现有值",
|
||||||
|
)
|
||||||
|
clear_smtp_sender_password: bool = Field(False, description="是否清空 SMTP 密码")
|
||||||
|
|
||||||
|
@field_validator("smtp_sender_password")
|
||||||
|
@classmethod
|
||||||
|
def normalize_password(cls, value: str | None) -> str | None:
|
||||||
|
if value == "":
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class EmailNotificationSettingsResponse(EmailNotificationSettingsBase):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
has_smtp_sender_password: bool
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
@@ -18,6 +18,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from backend.config import settings
|
from backend.config import settings
|
||||||
from backend.models import User
|
from backend.models import User
|
||||||
|
from backend.services.email_settings_service import EmailSettingsService
|
||||||
from backend.services.email_templates import EmailTemplate, SafeHtml, render_email_template
|
from backend.services.email_templates import EmailTemplate, SafeHtml, render_email_template
|
||||||
from backend.utils.time_helpers import minutes_until_expiry, parse_jwt_exp
|
from backend.utils.time_helpers import minutes_until_expiry, parse_jwt_exp
|
||||||
from backend.workers.email_notifier import EmailNotifier
|
from backend.workers.email_notifier import EmailNotifier
|
||||||
@@ -229,6 +230,10 @@ class EmailService:
|
|||||||
Returns:
|
Returns:
|
||||||
是否发送成功
|
是否发送成功
|
||||||
"""
|
"""
|
||||||
|
if not EmailSettingsService.is_token_expiring_notification_enabled():
|
||||||
|
logger.info("Token 即将过期通知已关闭,跳过发送")
|
||||||
|
return False
|
||||||
|
|
||||||
user_email = user.email
|
user_email = user.email
|
||||||
if user_email is None:
|
if user_email is None:
|
||||||
logger.info(f"用户 {user.alias} 未设置邮箱,跳过 Token 过期通知")
|
logger.info(f"用户 {user.alias} 未设置邮箱,跳过 Token 过期通知")
|
||||||
@@ -291,6 +296,10 @@ class EmailService:
|
|||||||
Returns:
|
Returns:
|
||||||
是否发送成功
|
是否发送成功
|
||||||
"""
|
"""
|
||||||
|
if success and not EmailSettingsService.is_check_in_success_notification_enabled():
|
||||||
|
logger.info("打卡成功通知已关闭,跳过发送")
|
||||||
|
return False
|
||||||
|
|
||||||
user_email = user.email
|
user_email = user.email
|
||||||
if user_email is None:
|
if user_email is None:
|
||||||
logger.info(f"用户 {user.alias} 未设置邮箱,跳过打卡通知")
|
logger.info(f"用户 {user.alias} 未设置邮箱,跳过打卡通知")
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from backend.config import settings
|
||||||
|
from backend.models import EmailNotificationSettings
|
||||||
|
from backend.models.database import SessionLocal
|
||||||
|
from backend.schemas.email_settings import (
|
||||||
|
EmailNotificationSettingsResponse,
|
||||||
|
EmailNotificationSettingsUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
SETTINGS_ROW_ID = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EmailSettingsSnapshot:
|
||||||
|
id: int
|
||||||
|
smtp_server: str
|
||||||
|
smtp_port: int
|
||||||
|
smtp_sender_email: str
|
||||||
|
smtp_sender_password: str
|
||||||
|
smtp_use_ssl: bool
|
||||||
|
notify_token_expiring: bool
|
||||||
|
notify_check_in_success: bool
|
||||||
|
has_smtp_sender_password: bool
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSettingsService:
|
||||||
|
"""读取和更新系统邮件配置。"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _defaults() -> EmailNotificationSettings:
|
||||||
|
return EmailNotificationSettings(
|
||||||
|
id=SETTINGS_ROW_ID,
|
||||||
|
smtp_server=settings.SMTP_SERVER,
|
||||||
|
smtp_port=settings.SMTP_PORT,
|
||||||
|
smtp_sender_email=settings.SMTP_SENDER_EMAIL,
|
||||||
|
smtp_sender_password=settings.SMTP_SENDER_PASSWORD,
|
||||||
|
smtp_use_ssl=settings.SMTP_USE_SSL,
|
||||||
|
notify_token_expiring=True,
|
||||||
|
notify_check_in_success=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _default_snapshot() -> EmailSettingsSnapshot:
|
||||||
|
password = str(settings.SMTP_SENDER_PASSWORD or "")
|
||||||
|
return EmailSettingsSnapshot(
|
||||||
|
id=SETTINGS_ROW_ID,
|
||||||
|
smtp_server=settings.SMTP_SERVER,
|
||||||
|
smtp_port=settings.SMTP_PORT,
|
||||||
|
smtp_sender_email=settings.SMTP_SENDER_EMAIL,
|
||||||
|
smtp_sender_password=password,
|
||||||
|
smtp_use_ssl=settings.SMTP_USE_SSL,
|
||||||
|
notify_token_expiring=True,
|
||||||
|
notify_check_in_success=True,
|
||||||
|
has_smtp_sender_password=bool(password),
|
||||||
|
created_at=None,
|
||||||
|
updated_at=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_or_create(db: Session) -> EmailNotificationSettings:
|
||||||
|
row = (
|
||||||
|
db.query(EmailNotificationSettings)
|
||||||
|
.filter(EmailNotificationSettings.id == SETTINGS_ROW_ID)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if row:
|
||||||
|
return row
|
||||||
|
|
||||||
|
row = EmailSettingsService._defaults()
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _to_snapshot(row: EmailNotificationSettings) -> EmailSettingsSnapshot:
|
||||||
|
password = str(row.smtp_sender_password or "")
|
||||||
|
return EmailSettingsSnapshot(
|
||||||
|
id=row.id,
|
||||||
|
smtp_server=str(row.smtp_server or ""),
|
||||||
|
smtp_port=int(row.smtp_port or 0),
|
||||||
|
smtp_sender_email=str(row.smtp_sender_email or ""),
|
||||||
|
smtp_sender_password=password,
|
||||||
|
smtp_use_ssl=bool(row.smtp_use_ssl),
|
||||||
|
notify_token_expiring=bool(row.notify_token_expiring),
|
||||||
|
notify_check_in_success=bool(row.notify_check_in_success),
|
||||||
|
has_smtp_sender_password=bool(password),
|
||||||
|
created_at=row.created_at,
|
||||||
|
updated_at=row.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _to_response(snapshot: EmailSettingsSnapshot) -> EmailNotificationSettingsResponse:
|
||||||
|
return EmailNotificationSettingsResponse(
|
||||||
|
id=snapshot.id,
|
||||||
|
smtp_server=snapshot.smtp_server,
|
||||||
|
smtp_port=snapshot.smtp_port,
|
||||||
|
smtp_sender_email=snapshot.smtp_sender_email,
|
||||||
|
smtp_use_ssl=snapshot.smtp_use_ssl,
|
||||||
|
notify_token_expiring=snapshot.notify_token_expiring,
|
||||||
|
notify_check_in_success=snapshot.notify_check_in_success,
|
||||||
|
has_smtp_sender_password=snapshot.has_smtp_sender_password,
|
||||||
|
created_at=snapshot.created_at,
|
||||||
|
updated_at=snapshot.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_snapshot(db: Session) -> EmailSettingsSnapshot:
|
||||||
|
return EmailSettingsService._to_snapshot(EmailSettingsService._get_or_create(db))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_response(db: Session) -> EmailNotificationSettingsResponse:
|
||||||
|
return EmailSettingsService._to_response(EmailSettingsService.get_snapshot(db))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_settings(
|
||||||
|
db: Session, payload: EmailNotificationSettingsUpdate
|
||||||
|
) -> EmailSettingsSnapshot:
|
||||||
|
row = EmailSettingsService._get_or_create(db)
|
||||||
|
row.smtp_server = payload.smtp_server
|
||||||
|
row.smtp_port = payload.smtp_port
|
||||||
|
row.smtp_sender_email = str(payload.smtp_sender_email)
|
||||||
|
row.smtp_use_ssl = payload.smtp_use_ssl
|
||||||
|
row.notify_token_expiring = payload.notify_token_expiring
|
||||||
|
row.notify_check_in_success = payload.notify_check_in_success
|
||||||
|
|
||||||
|
if payload.clear_smtp_sender_password:
|
||||||
|
row.smtp_sender_password = ""
|
||||||
|
elif payload.smtp_sender_password is not None:
|
||||||
|
row.smtp_sender_password = payload.smtp_sender_password
|
||||||
|
|
||||||
|
row.updated_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
return EmailSettingsService._to_snapshot(row)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_response(
|
||||||
|
db: Session, payload: EmailNotificationSettingsUpdate
|
||||||
|
) -> EmailNotificationSettingsResponse:
|
||||||
|
return EmailSettingsService._to_response(EmailSettingsService.update_settings(db, payload))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_smtp_config() -> dict[str, object] | None:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
snapshot = EmailSettingsService.get_snapshot(db)
|
||||||
|
except SQLAlchemyError:
|
||||||
|
snapshot = EmailSettingsService._default_snapshot()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
if not snapshot.smtp_server or not snapshot.smtp_sender_email or not snapshot.smtp_port:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"smtp_server": snapshot.smtp_server,
|
||||||
|
"smtp_port": snapshot.smtp_port,
|
||||||
|
"sender_email": snapshot.smtp_sender_email,
|
||||||
|
"sender_password": snapshot.smtp_sender_password,
|
||||||
|
"use_ssl": snapshot.smtp_use_ssl,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_token_expiring_notification_enabled() -> bool:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
return EmailSettingsService.get_snapshot(db).notify_token_expiring
|
||||||
|
except SQLAlchemyError:
|
||||||
|
return EmailSettingsService._default_snapshot().notify_token_expiring
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_check_in_success_notification_enabled() -> bool:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
return EmailSettingsService.get_snapshot(db).notify_check_in_success
|
||||||
|
except SQLAlchemyError:
|
||||||
|
return EmailSettingsService._default_snapshot().notify_check_in_success
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -14,7 +14,7 @@ from email.mime.multipart import MIMEMultipart
|
|||||||
import logging
|
import logging
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from backend.config import settings
|
from backend.services.email_settings_service import EmailSettingsService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -25,30 +25,19 @@ class EmailNotifier:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def get_email_config() -> Optional[dict]:
|
def get_email_config() -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
从环境变量读取邮件配置
|
读取有效邮件配置
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: 邮件配置,如果配置不完整则返回 None
|
dict: 邮件配置,如果配置不完整则返回 None
|
||||||
"""
|
"""
|
||||||
# 检查必要的邮件配置是否存在
|
email_config = EmailSettingsService.get_smtp_config()
|
||||||
if not settings.SMTP_SERVER or not settings.SMTP_SENDER_EMAIL:
|
if not email_config:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"邮件配置未完成(SMTP_SERVER 或 SMTP_SENDER_EMAIL 为空),邮件发送功能已禁用"
|
"邮件配置未完成(SMTP_SERVER 或 SMTP_SENDER_EMAIL 为空),邮件发送功能已禁用"
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if not settings.SMTP_PORT:
|
return email_config
|
||||||
logger.debug("邮件配置未完成(SMTP_PORT 为空),邮件发送功能已禁用")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 返回配置字典
|
|
||||||
return {
|
|
||||||
"smtp_server": settings.SMTP_SERVER,
|
|
||||||
"smtp_port": settings.SMTP_PORT,
|
|
||||||
"sender_email": settings.SMTP_SENDER_EMAIL,
|
|
||||||
"sender_password": settings.SMTP_SENDER_PASSWORD,
|
|
||||||
"use_ssl": settings.SMTP_USE_SSL,
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def send_email(
|
def send_email(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import AdminTemplatesView from '@/views/admin/AdminTemplatesView.vue'
|
|||||||
import AdminRecordsView from '@/views/admin/AdminRecordsView.vue'
|
import AdminRecordsView from '@/views/admin/AdminRecordsView.vue'
|
||||||
import AdminLogsView from '@/views/admin/AdminLogsView.vue'
|
import AdminLogsView from '@/views/admin/AdminLogsView.vue'
|
||||||
import AdminStatsView from '@/views/admin/AdminStatsView.vue'
|
import AdminStatsView from '@/views/admin/AdminStatsView.vue'
|
||||||
|
import AdminEmailSettingsView from '@/views/admin/AdminEmailSettingsView.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuth()
|
const auth = useAuth()
|
||||||
@@ -46,6 +47,8 @@ const view = computed(() => {
|
|||||||
return AdminLogsView
|
return AdminLogsView
|
||||||
case 'admin-stats':
|
case 'admin-stats':
|
||||||
return AdminStatsView
|
return AdminStatsView
|
||||||
|
case 'admin-email-settings':
|
||||||
|
return AdminEmailSettingsView
|
||||||
default:
|
default:
|
||||||
return NotFoundView
|
return NotFoundView
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import type {
|
|||||||
CheckInStartResponse,
|
CheckInStartResponse,
|
||||||
CreateTaskFromTemplatePayload,
|
CreateTaskFromTemplatePayload,
|
||||||
CronValidation,
|
CronValidation,
|
||||||
|
EmailNotificationSettings,
|
||||||
|
EmailNotificationSettingsUpdate,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
LogsResponse,
|
LogsResponse,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
@@ -118,6 +120,9 @@ export const adminApi = {
|
|||||||
),
|
),
|
||||||
batchCheckIn: (task_ids: number[]) =>
|
batchCheckIn: (task_ids: number[]) =>
|
||||||
apiClient.post<unknown>('/api/admin/batch_check_in', { task_ids }),
|
apiClient.post<unknown>('/api/admin/batch_check_in', { task_ids }),
|
||||||
|
emailSettings: () => apiClient.get<EmailNotificationSettings>('/api/admin/email_settings'),
|
||||||
|
updateEmailSettings: (payload: EmailNotificationSettingsUpdate) =>
|
||||||
|
apiClient.put<EmailNotificationSettings>('/api/admin/email_settings', payload),
|
||||||
}
|
}
|
||||||
|
|
||||||
export type * from './types'
|
export type * from './types'
|
||||||
|
|||||||
@@ -202,6 +202,30 @@ export interface LogsResponse {
|
|||||||
logs: string
|
logs: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EmailNotificationSettings {
|
||||||
|
id: number
|
||||||
|
smtp_server: string
|
||||||
|
smtp_port: number
|
||||||
|
smtp_sender_email: string
|
||||||
|
smtp_use_ssl: boolean
|
||||||
|
notify_token_expiring: boolean
|
||||||
|
notify_check_in_success: boolean
|
||||||
|
has_smtp_sender_password: boolean
|
||||||
|
created_at?: string | null
|
||||||
|
updated_at?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailNotificationSettingsUpdate {
|
||||||
|
smtp_server: string
|
||||||
|
smtp_port: number
|
||||||
|
smtp_sender_email: string
|
||||||
|
smtp_use_ssl: boolean
|
||||||
|
notify_token_expiring: boolean
|
||||||
|
notify_check_in_success: boolean
|
||||||
|
smtp_sender_password?: string
|
||||||
|
clear_smtp_sender_password?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface CronValidation {
|
export interface CronValidation {
|
||||||
valid: boolean
|
valid: boolean
|
||||||
message: string
|
message: string
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export type RouteKey =
|
|||||||
| 'admin-records'
|
| 'admin-records'
|
||||||
| 'admin-logs'
|
| 'admin-logs'
|
||||||
| 'admin-stats'
|
| 'admin-stats'
|
||||||
|
| 'admin-email-settings'
|
||||||
| 'not-found'
|
| 'not-found'
|
||||||
|
|
||||||
export interface AppRoute {
|
export interface AppRoute {
|
||||||
@@ -67,6 +68,13 @@ export const routes: AppRoute[] = [
|
|||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
requiresAdmin: true,
|
requiresAdmin: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'admin-email-settings',
|
||||||
|
path: '/admin/email-settings',
|
||||||
|
title: '邮件设置',
|
||||||
|
requiresAuth: true,
|
||||||
|
requiresAdmin: true,
|
||||||
|
},
|
||||||
{ key: 'not-found', path: '/:pathMatch(.*)*', title: '页面未找到' },
|
{ key: 'not-found', path: '/:pathMatch(.*)*', title: '页面未找到' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
Mail,
|
||||||
Menu,
|
Menu,
|
||||||
Monitor,
|
Monitor,
|
||||||
MoonStar,
|
MoonStar,
|
||||||
@@ -43,6 +44,7 @@ const adminLinks = [
|
|||||||
{ path: '/admin/records', label: '全量记录', icon: ScrollText },
|
{ path: '/admin/records', label: '全量记录', icon: ScrollText },
|
||||||
{ path: '/admin/logs', label: '日志', icon: Shield },
|
{ path: '/admin/logs', label: '日志', icon: Shield },
|
||||||
{ path: '/admin/stats', label: '统计', icon: BarChart3 },
|
{ path: '/admin/stats', label: '统计', icon: BarChart3 },
|
||||||
|
{ path: '/admin/email-settings', label: '邮件', icon: Mail },
|
||||||
]
|
]
|
||||||
|
|
||||||
const title = computed(() => router.current.value.title)
|
const title = computed(() => router.current.value.title)
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Mail, RefreshCw, Save } from 'lucide-vue-next'
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { adminApi, type EmailNotificationSettings } from '@/api'
|
||||||
|
import StateBlock from '@/components/StateBlock.vue'
|
||||||
|
import {
|
||||||
|
alertClass,
|
||||||
|
cardClass,
|
||||||
|
inputClass,
|
||||||
|
labelClass,
|
||||||
|
sectionHeaderClass,
|
||||||
|
toneClass,
|
||||||
|
} from '@/components/ui'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { extractErrorMessage, formatFullDateTime } from '@/utils/format'
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const savedMessage = ref('')
|
||||||
|
const settings = ref<EmailNotificationSettings | null>(null)
|
||||||
|
const form = reactive({
|
||||||
|
smtp_server: '',
|
||||||
|
smtp_port: 465,
|
||||||
|
smtp_sender_email: '',
|
||||||
|
smtp_sender_password: '',
|
||||||
|
clear_smtp_sender_password: false,
|
||||||
|
smtp_use_ssl: true,
|
||||||
|
notify_token_expiring: true,
|
||||||
|
notify_check_in_success: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const passwordState = computed(() => {
|
||||||
|
if (form.clear_smtp_sender_password) return '保存后清空'
|
||||||
|
if (form.smtp_sender_password) return '保存后替换'
|
||||||
|
if (settings.value?.has_smtp_sender_password) return '已保存'
|
||||||
|
return '未设置'
|
||||||
|
})
|
||||||
|
|
||||||
|
function hydrate(next: EmailNotificationSettings) {
|
||||||
|
settings.value = next
|
||||||
|
form.smtp_server = next.smtp_server
|
||||||
|
form.smtp_port = next.smtp_port
|
||||||
|
form.smtp_sender_email = next.smtp_sender_email
|
||||||
|
form.smtp_sender_password = ''
|
||||||
|
form.clear_smtp_sender_password = false
|
||||||
|
form.smtp_use_ssl = next.smtp_use_ssl
|
||||||
|
form.notify_token_expiring = next.notify_token_expiring
|
||||||
|
form.notify_check_in_success = next.notify_check_in_success
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
savedMessage.value = ''
|
||||||
|
try {
|
||||||
|
hydrate(await adminApi.emailSettings())
|
||||||
|
} catch (err) {
|
||||||
|
error.value = extractErrorMessage(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving.value = true
|
||||||
|
error.value = ''
|
||||||
|
savedMessage.value = ''
|
||||||
|
try {
|
||||||
|
const next = await adminApi.updateEmailSettings({
|
||||||
|
smtp_server: form.smtp_server,
|
||||||
|
smtp_port: form.smtp_port,
|
||||||
|
smtp_sender_email: form.smtp_sender_email,
|
||||||
|
smtp_use_ssl: form.smtp_use_ssl,
|
||||||
|
notify_token_expiring: form.notify_token_expiring,
|
||||||
|
notify_check_in_success: form.notify_check_in_success,
|
||||||
|
smtp_sender_password: form.smtp_sender_password || undefined,
|
||||||
|
clear_smtp_sender_password: form.clear_smtp_sender_password,
|
||||||
|
})
|
||||||
|
hydrate(next)
|
||||||
|
savedMessage.value = '邮件设置已保存'
|
||||||
|
} catch (err) {
|
||||||
|
error.value = extractErrorMessage(err)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<StateBlock v-if="loading" title="正在加载邮件设置" type="loading" />
|
||||||
|
<StateBlock
|
||||||
|
v-else-if="error && !settings"
|
||||||
|
title="邮件设置加载失败"
|
||||||
|
:description="error"
|
||||||
|
type="error"
|
||||||
|
action-label="重试"
|
||||||
|
@action="load"
|
||||||
|
/>
|
||||||
|
<form v-else class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]" @submit.prevent="save">
|
||||||
|
<section :class="[cardClass, 'min-w-0 overflow-hidden']">
|
||||||
|
<div :class="sectionHeaderClass">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-semibold">SMTP 配置</h2>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span :class="toneClass(settings?.has_smtp_sender_password ? 'success' : 'warning')">
|
||||||
|
密码{{ passwordState }}
|
||||||
|
</span>
|
||||||
|
<Button variant="outline" type="button" :disabled="saving" @click="load">
|
||||||
|
<RefreshCw class="size-4" />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" :disabled="saving">
|
||||||
|
<Save class="size-4" />
|
||||||
|
{{ saving ? '保存中' : '保存' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 p-4 md:grid-cols-2">
|
||||||
|
<label class="grid gap-2 md:col-span-2">
|
||||||
|
<span :class="labelClass">SMTP SERVER</span>
|
||||||
|
<input
|
||||||
|
v-model.trim="form.smtp_server"
|
||||||
|
:class="inputClass"
|
||||||
|
maxlength="255"
|
||||||
|
placeholder="smtp.example.com"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="grid gap-2">
|
||||||
|
<span :class="labelClass">SMTP PORT</span>
|
||||||
|
<input
|
||||||
|
v-model.number="form.smtp_port"
|
||||||
|
:class="inputClass"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="grid gap-2">
|
||||||
|
<span :class="labelClass">发件邮箱</span>
|
||||||
|
<input
|
||||||
|
v-model.trim="form.smtp_sender_email"
|
||||||
|
:class="inputClass"
|
||||||
|
type="email"
|
||||||
|
maxlength="255"
|
||||||
|
placeholder="mailer@example.com"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="grid gap-2 md:col-span-2">
|
||||||
|
<span :class="labelClass">SMTP 密码</span>
|
||||||
|
<input
|
||||||
|
v-model="form.smtp_sender_password"
|
||||||
|
:class="inputClass"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
maxlength="500"
|
||||||
|
placeholder="留空则保持现有密码"
|
||||||
|
:disabled="form.clear_smtp_sender_password"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="grid gap-3 rounded-lg border border-border bg-background p-3 md:col-span-2 sm:grid-cols-2"
|
||||||
|
>
|
||||||
|
<label class="flex items-center gap-3 text-sm font-medium">
|
||||||
|
<input v-model="form.smtp_use_ssl" type="checkbox" class="size-4 accent-primary" />
|
||||||
|
使用 SSL 连接 SMTP
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-3 text-sm font-medium">
|
||||||
|
<input
|
||||||
|
v-model="form.clear_smtp_sender_password"
|
||||||
|
type="checkbox"
|
||||||
|
class="size-4 accent-primary"
|
||||||
|
:disabled="Boolean(form.smtp_sender_password)"
|
||||||
|
/>
|
||||||
|
保存时清空 SMTP 密码
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside :class="[cardClass, 'grid h-fit gap-4 overflow-hidden xl:sticky xl:top-20']">
|
||||||
|
<div class="border-b border-border bg-muted/55 px-4 py-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Mail class="size-4 text-muted-foreground" />
|
||||||
|
<h2 class="font-semibold">通知策略</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 p-4">
|
||||||
|
<label class="grid gap-2 rounded-lg border border-border bg-background p-3">
|
||||||
|
<span class="flex items-center justify-between gap-3 text-sm font-medium">
|
||||||
|
Token 即将过期提醒
|
||||||
|
<input
|
||||||
|
v-model="form.notify_token_expiring"
|
||||||
|
type="checkbox"
|
||||||
|
class="size-4 accent-primary"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-muted-foreground">只影响过期前提醒,不影响已过期通知。</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="grid gap-2 rounded-lg border border-border bg-background p-3">
|
||||||
|
<span class="flex items-center justify-between gap-3 text-sm font-medium">
|
||||||
|
打卡成功通知
|
||||||
|
<input
|
||||||
|
v-model="form.notify_check_in_success"
|
||||||
|
type="checkbox"
|
||||||
|
class="size-4 accent-primary"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-muted-foreground"
|
||||||
|
>关闭后仅跳过成功邮件,失败邮件仍会发送。</span
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div v-if="settings?.updated_at" class="text-sm text-muted-foreground">
|
||||||
|
上次保存:{{ formatFullDateTime(settings.updated_at) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="savedMessage" :class="alertClass.success">{{ savedMessage }}</div>
|
||||||
|
<div v-if="error" :class="alertClass.danger">{{ error }}</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
@@ -31,6 +31,7 @@ redis = [
|
|||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
"httpx>=0.28.1",
|
||||||
"pre-commit>=4.0.0",
|
"pre-commit>=4.0.0",
|
||||||
"pytest>=8.0.0",
|
"pytest>=8.0.0",
|
||||||
"pytest-asyncio>=0.24.0",
|
"pytest-asyncio>=0.24.0",
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ from backend.migrations import (
|
|||||||
run_pending_migrations,
|
run_pending_migrations,
|
||||||
)
|
)
|
||||||
from backend.migration_steps.account_lockout import apply as apply_account_lockout
|
from backend.migration_steps.account_lockout import apply as apply_account_lockout
|
||||||
|
from backend.migration_steps.email_notification_settings import (
|
||||||
|
apply as apply_email_notification_settings,
|
||||||
|
)
|
||||||
from backend.migration_steps.task_thread_id import apply as apply_task_thread_id
|
from backend.migration_steps.task_thread_id import apply as apply_task_thread_id
|
||||||
|
|
||||||
|
|
||||||
@@ -75,13 +78,36 @@ def test_existing_migrations_are_registered_in_order() -> None:
|
|||||||
assert [migration.id for migration in MIGRATIONS] == [
|
assert [migration.id for migration in MIGRATIONS] == [
|
||||||
"2026050401_add_account_lockout",
|
"2026050401_add_account_lockout",
|
||||||
"2026050402_add_task_thread_id",
|
"2026050402_add_task_thread_id",
|
||||||
|
"2026050501_add_email_notification_settings",
|
||||||
]
|
]
|
||||||
assert [migration.apply.__module__ for migration in MIGRATIONS] == [
|
assert [migration.apply.__module__ for migration in MIGRATIONS] == [
|
||||||
"backend.migration_steps.account_lockout",
|
"backend.migration_steps.account_lockout",
|
||||||
"backend.migration_steps.task_thread_id",
|
"backend.migration_steps.task_thread_id",
|
||||||
|
"backend.migration_steps.email_notification_settings",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_email_notification_settings_migration_creates_settings_table() -> None:
|
||||||
|
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
||||||
|
|
||||||
|
with engine.connect() as conn:
|
||||||
|
apply_email_notification_settings(conn)
|
||||||
|
|
||||||
|
columns = {
|
||||||
|
row[1] for row in conn.execute(text("PRAGMA table_info(email_notification_settings)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {
|
||||||
|
"smtp_server",
|
||||||
|
"smtp_port",
|
||||||
|
"smtp_sender_email",
|
||||||
|
"smtp_sender_password",
|
||||||
|
"smtp_use_ssl",
|
||||||
|
"notify_token_expiring",
|
||||||
|
"notify_check_in_success",
|
||||||
|
} <= columns
|
||||||
|
|
||||||
|
|
||||||
def test_account_lockout_migration_adds_missing_user_fields() -> None:
|
def test_account_lockout_migration_adds_missing_user_fields() -> None:
|
||||||
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from pydantic import ValidationError
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from backend.api import admin as admin_api
|
||||||
|
from backend.dependencies import get_current_user, get_db
|
||||||
|
from backend.models import Base, User
|
||||||
|
from backend.schemas.email_settings import EmailNotificationSettingsUpdate
|
||||||
|
from backend.services import email_settings_service
|
||||||
|
from backend.services.email_service import EmailService
|
||||||
|
from backend.services.email_settings_service import EmailSettingsService
|
||||||
|
|
||||||
|
|
||||||
|
def make_session():
|
||||||
|
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
session_factory = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
session = session_factory()
|
||||||
|
return engine, session_factory, session
|
||||||
|
|
||||||
|
|
||||||
|
def test_email_settings_default_row_uses_environment_defaults_and_masks_password(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
engine, _, session = make_session()
|
||||||
|
monkeypatch.setattr(email_settings_service.settings, "SMTP_SERVER", "smtp.example.test")
|
||||||
|
monkeypatch.setattr(email_settings_service.settings, "SMTP_PORT", 2525)
|
||||||
|
monkeypatch.setattr(email_settings_service.settings, "SMTP_SENDER_EMAIL", "mailer@example.com")
|
||||||
|
monkeypatch.setattr(email_settings_service.settings, "SMTP_SENDER_PASSWORD", "secret")
|
||||||
|
monkeypatch.setattr(email_settings_service.settings, "SMTP_USE_SSL", False)
|
||||||
|
|
||||||
|
snapshot = EmailSettingsService.get_snapshot(session)
|
||||||
|
|
||||||
|
assert snapshot.smtp_server == "smtp.example.test"
|
||||||
|
assert snapshot.smtp_port == 2525
|
||||||
|
assert snapshot.smtp_sender_email == "mailer@example.com"
|
||||||
|
assert snapshot.smtp_sender_password == "secret"
|
||||||
|
assert snapshot.has_smtp_sender_password is True
|
||||||
|
session.close()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_email_settings_update_preserves_and_clears_password(monkeypatch) -> None:
|
||||||
|
engine, _, session = make_session()
|
||||||
|
monkeypatch.setattr(email_settings_service.settings, "SMTP_SERVER", "smtp.example.test")
|
||||||
|
monkeypatch.setattr(email_settings_service.settings, "SMTP_PORT", 2525)
|
||||||
|
monkeypatch.setattr(email_settings_service.settings, "SMTP_SENDER_EMAIL", "mailer@example.com")
|
||||||
|
monkeypatch.setattr(email_settings_service.settings, "SMTP_SENDER_PASSWORD", "")
|
||||||
|
monkeypatch.setattr(email_settings_service.settings, "SMTP_USE_SSL", False)
|
||||||
|
|
||||||
|
EmailSettingsService.get_snapshot(session)
|
||||||
|
EmailSettingsService.update_settings(
|
||||||
|
session,
|
||||||
|
EmailNotificationSettingsUpdate(
|
||||||
|
smtp_server="smtp.changed.test",
|
||||||
|
smtp_port=587,
|
||||||
|
smtp_sender_email="ops@example.com",
|
||||||
|
smtp_use_ssl=True,
|
||||||
|
notify_token_expiring=False,
|
||||||
|
notify_check_in_success=False,
|
||||||
|
smtp_sender_password="new-secret",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = EmailSettingsService.get_snapshot(session)
|
||||||
|
assert updated.smtp_server == "smtp.changed.test"
|
||||||
|
assert updated.smtp_port == 587
|
||||||
|
assert updated.smtp_sender_email == "ops@example.com"
|
||||||
|
assert updated.smtp_sender_password == "new-secret"
|
||||||
|
assert updated.notify_token_expiring is False
|
||||||
|
assert updated.notify_check_in_success is False
|
||||||
|
|
||||||
|
EmailSettingsService.update_settings(
|
||||||
|
session,
|
||||||
|
EmailNotificationSettingsUpdate(
|
||||||
|
smtp_server="smtp.changed.test",
|
||||||
|
smtp_port=587,
|
||||||
|
smtp_sender_email="ops@example.com",
|
||||||
|
smtp_use_ssl=True,
|
||||||
|
notify_token_expiring=False,
|
||||||
|
notify_check_in_success=False,
|
||||||
|
clear_smtp_sender_password=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
cleared = EmailSettingsService.get_snapshot(session)
|
||||||
|
assert cleared.smtp_sender_password in ("", None)
|
||||||
|
assert cleared.has_smtp_sender_password is False
|
||||||
|
session.close()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_email_settings_route_rejects_non_admin() -> None:
|
||||||
|
engine, session_factory, session = make_session()
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(admin_api.router, prefix="/api/admin")
|
||||||
|
|
||||||
|
def override_get_db():
|
||||||
|
yield session
|
||||||
|
|
||||||
|
async def override_get_current_user() -> User:
|
||||||
|
return User(id=1, alias="user", role="user", is_approved=True)
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
app.dependency_overrides[get_current_user] = override_get_current_user
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/api/admin/email_settings")
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
session.close()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_expiring_notification_can_be_disabled(monkeypatch) -> None:
|
||||||
|
engine, session_factory, session = make_session()
|
||||||
|
monkeypatch.setattr(email_settings_service, "SessionLocal", session_factory)
|
||||||
|
EmailSettingsService.update_settings(
|
||||||
|
session,
|
||||||
|
EmailNotificationSettingsUpdate(
|
||||||
|
smtp_server="smtp.example.test",
|
||||||
|
smtp_port=465,
|
||||||
|
smtp_sender_email="mailer@example.com",
|
||||||
|
smtp_use_ssl=True,
|
||||||
|
notify_token_expiring=False,
|
||||||
|
notify_check_in_success=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
sent: dict[str, object] = {}
|
||||||
|
|
||||||
|
def fake_send(*args, **kwargs) -> bool:
|
||||||
|
sent["called"] = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(EmailService, "send_email", fake_send)
|
||||||
|
|
||||||
|
user = User(alias="Alice", email="alice@example.com")
|
||||||
|
assert EmailService.notify_token_expiring(user, "1000") is False
|
||||||
|
assert sent == {}
|
||||||
|
session.close()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_success_check_in_notification_can_be_disabled(monkeypatch) -> None:
|
||||||
|
engine, session_factory, session = make_session()
|
||||||
|
monkeypatch.setattr(email_settings_service, "SessionLocal", session_factory)
|
||||||
|
EmailSettingsService.update_settings(
|
||||||
|
session,
|
||||||
|
EmailNotificationSettingsUpdate(
|
||||||
|
smtp_server="smtp.example.test",
|
||||||
|
smtp_port=465,
|
||||||
|
smtp_sender_email="mailer@example.com",
|
||||||
|
smtp_use_ssl=True,
|
||||||
|
notify_token_expiring=True,
|
||||||
|
notify_check_in_success=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
sent: dict[str, object] = {}
|
||||||
|
|
||||||
|
def fake_send(*args, **kwargs) -> bool:
|
||||||
|
sent["called"] = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(EmailService, "send_email", fake_send)
|
||||||
|
|
||||||
|
user = User(alias="Alice", email="alice@example.com")
|
||||||
|
assert (
|
||||||
|
EmailService.notify_check_in_result(
|
||||||
|
user,
|
||||||
|
{"thread_id": "thread-1"},
|
||||||
|
True,
|
||||||
|
"打卡成功",
|
||||||
|
)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
assert sent == {}
|
||||||
|
session.close()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_email_settings_update_validates_sender_email() -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
EmailNotificationSettingsUpdate(
|
||||||
|
smtp_server="smtp.example.test",
|
||||||
|
smtp_port=465,
|
||||||
|
smtp_sender_email="not-an-email",
|
||||||
|
smtp_use_ssl=True,
|
||||||
|
notify_token_expiring=True,
|
||||||
|
notify_check_in_success=True,
|
||||||
|
)
|
||||||
@@ -29,6 +29,7 @@ def test_frontend_has_business_app_structure() -> None:
|
|||||||
"views/admin/AdminRecordsView.vue",
|
"views/admin/AdminRecordsView.vue",
|
||||||
"views/admin/AdminLogsView.vue",
|
"views/admin/AdminLogsView.vue",
|
||||||
"views/admin/AdminStatsView.vue",
|
"views/admin/AdminStatsView.vue",
|
||||||
|
"views/admin/AdminEmailSettingsView.vue",
|
||||||
"utils/format.ts",
|
"utils/format.ts",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -53,10 +54,17 @@ def test_frontend_routes_cover_user_and_admin_workflows() -> None:
|
|||||||
"/admin/records",
|
"/admin/records",
|
||||||
"/admin/logs",
|
"/admin/logs",
|
||||||
"/admin/stats",
|
"/admin/stats",
|
||||||
|
"/admin/email-settings",
|
||||||
]:
|
]:
|
||||||
assert path in router
|
assert path in router
|
||||||
|
|
||||||
|
|
||||||
|
def test_frontend_admin_api_covers_email_settings() -> None:
|
||||||
|
api = (SRC_ROOT / "api" / "index.ts").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "/api/admin/email_settings" in api
|
||||||
|
|
||||||
|
|
||||||
def test_frontend_replaces_starter_component() -> None:
|
def test_frontend_replaces_starter_component() -> None:
|
||||||
app = (SRC_ROOT / "App.vue").read_text(encoding="utf-8")
|
app = (SRC_ROOT / "App.vue").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ redis = [
|
|||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "httpx" },
|
||||||
{ name = "pre-commit" },
|
{ name = "pre-commit" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-asyncio" },
|
{ name = "pytest-asyncio" },
|
||||||
@@ -262,6 +263,7 @@ provides-extras = ["production", "redis"]
|
|||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "httpx", specifier = ">=0.28.1" },
|
||||||
{ name = "pre-commit", specifier = ">=4.0.0" },
|
{ name = "pre-commit", specifier = ">=4.0.0" },
|
||||||
{ name = "pytest", specifier = ">=8.0.0" },
|
{ name = "pytest", specifier = ">=8.0.0" },
|
||||||
{ name = "pytest-asyncio", specifier = ">=0.24.0" },
|
{ name = "pytest-asyncio", specifier = ">=0.24.0" },
|
||||||
@@ -437,6 +439,19 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpcore"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httptools"
|
name = "httptools"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
@@ -466,6 +481,21 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
|
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpx"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "httpcore" },
|
||||||
|
{ name = "idna" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "identify"
|
name = "identify"
|
||||||
version = "2.6.19"
|
version = "2.6.19"
|
||||||
|
|||||||
Reference in New Issue
Block a user