feat: add system settings, audit log, device detail, dark mode, i18n, email notifications

System Management:
- System Settings page with 8 configurable parameters (admin only)
- Audit Log page with filterable table (user, action, resource, date range)
- Audit logging wired into auth, devices, users, alarms, reports API handlers
- SystemSetting model + migration (002)

Device Detail:
- Dedicated /devices/:id page with 4 tabs (realtime, historical trends, alarm history, device info)
- ECharts historical charts with granularity/time range selectors
- Device name clickable in Devices and Monitoring tables → navigates to detail

Email & Scheduling:
- Email service with SMTP support (STARTTLS/SSL/plain)
- Alarm email notification with professional HTML template
- Report scheduler using APScheduler for cron-based auto-generation
- Scheduled report task seeded (daily at 8am)

UI Enhancements:
- Dark mode toggle (persisted to localStorage, Ant Design darkAlgorithm)
- Data comparison view in Analysis page (dual date range, side-by-side metrics)
- i18n framework (i18next) with zh/en translations for menu and common UI
- Language switcher in header (中文/English)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Du Wenbo
2026-04-02 19:42:22 +08:00
parent 895af4caf9
commit ef9b5d055f
36 changed files with 2196 additions and 113 deletions

View File

@@ -0,0 +1,41 @@
"""Add system_settings table
Revision ID: 002_system_settings
Revises: 001_initial
Create Date: 2026-04-02
"""
from alembic import op
import sqlalchemy as sa
revision = "002_system_settings"
down_revision = "001_initial"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"system_settings",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("key", sa.String(100), unique=True, nullable=False, index=True),
sa.Column("value", sa.Text, nullable=False, server_default=""),
sa.Column("description", sa.String(255)),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
# Seed default settings
op.execute("""
INSERT INTO system_settings (key, value, description) VALUES
('platform_name', '天普零碳园区智慧能源管理平台', '平台名称'),
('data_retention_days', '365', '数据保留天数'),
('alarm_auto_resolve_minutes', '30', '告警自动解除时间(分钟)'),
('simulator_interval_seconds', '15', '模拟器采集间隔(秒)'),
('notification_email_enabled', 'false', '是否启用邮件通知'),
('notification_email_smtp', '', 'SMTP服务器地址'),
('report_auto_schedule_enabled', 'false', '是否启用自动报表'),
('timezone', 'Asia/Shanghai', '系统时区')
""")
def downgrade() -> None:
op.drop_table("system_settings")

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter
from app.api.v1 import auth, users, devices, energy, monitoring, alarms, reports, carbon, dashboard, collectors, websocket
from app.api.v1 import auth, users, devices, energy, monitoring, alarms, reports, carbon, dashboard, collectors, websocket, audit, settings
api_router = APIRouter(prefix="/api/v1")
@@ -14,3 +14,5 @@ api_router.include_router(carbon.router)
api_router.include_router(dashboard.router)
api_router.include_router(collectors.router)
api_router.include_router(websocket.router)
api_router.include_router(audit.router)
api_router.include_router(settings.router)

View File

@@ -7,6 +7,7 @@ from app.core.database import get_db
from app.core.deps import get_current_user, require_roles
from app.models.alarm import AlarmRule, AlarmEvent
from app.models.user import User
from app.services.audit import log_audit
router = APIRouter(prefix="/alarms", tags=["告警管理"])
@@ -107,6 +108,7 @@ async def acknowledge_event(event_id: int, db: AsyncSession = Depends(get_db), u
event.status = "acknowledged"
event.acknowledged_by = user.id
event.acknowledged_at = datetime.now(timezone.utc)
await log_audit(db, user.id, "acknowledge", "alarm", detail=f"确认告警 #{event_id}")
return {"message": "已确认"}
@@ -119,6 +121,7 @@ async def resolve_event(event_id: int, note: str = "", db: AsyncSession = Depend
event.status = "resolved"
event.resolved_at = datetime.now(timezone.utc)
event.resolve_note = note
await log_audit(db, user.id, "resolve", "alarm", detail=f"解决告警 #{event_id}")
return {"message": "已解决"}

View File

@@ -0,0 +1,76 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from datetime import datetime
from app.core.database import get_db
from app.core.deps import get_current_user, require_roles
from app.models.user import User, AuditLog
router = APIRouter(prefix="/audit", tags=["审计日志"])
@router.get("/logs")
async def list_audit_logs(
user_id: int | None = None,
action: str | None = None,
resource: str | None = None,
start_time: str | None = None,
end_time: str | None = None,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_roles("admin", "energy_manager")),
):
"""Return paginated audit logs with optional filters."""
query = select(
AuditLog.id,
AuditLog.user_id,
User.username,
AuditLog.action,
AuditLog.resource,
AuditLog.detail,
AuditLog.ip_address,
AuditLog.created_at,
).outerjoin(User, AuditLog.user_id == User.id)
if user_id is not None:
query = query.where(AuditLog.user_id == user_id)
if action:
query = query.where(AuditLog.action == action)
if resource:
query = query.where(AuditLog.resource == resource)
if start_time:
try:
st = datetime.fromisoformat(start_time)
query = query.where(AuditLog.created_at >= st)
except ValueError:
pass
if end_time:
try:
et = datetime.fromisoformat(end_time)
query = query.where(AuditLog.created_at <= et)
except ValueError:
pass
# Count
count_q = select(func.count()).select_from(query.subquery())
total = (await db.execute(count_q)).scalar()
# Paginate
query = query.order_by(AuditLog.created_at.desc()).offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
items = []
for row in result.all():
items.append({
"id": row.id,
"user_id": row.user_id,
"username": row.username or "-",
"action": row.action,
"resource": row.resource,
"detail": row.detail,
"ip_address": row.ip_address,
"created_at": str(row.created_at) if row.created_at else None,
})
return {"total": total, "items": items}

View File

@@ -1,5 +1,5 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
@@ -8,6 +8,7 @@ from app.core.database import get_db
from app.core.security import verify_password, create_access_token, hash_password
from app.core.deps import get_current_user
from app.models.user import User
from app.services.audit import log_audit
router = APIRouter(prefix="/auth", tags=["认证"])
@@ -27,7 +28,7 @@ class RegisterRequest(BaseModel):
@router.post("/login", response_model=Token)
async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
async def login(request: Request, form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.username == form.username))
user = result.scalar_one_or_none()
if not user or not verify_password(form.password, user.hashed_password):
@@ -36,6 +37,8 @@ async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession =
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已禁用")
user.last_login = datetime.now(timezone.utc)
token = create_access_token({"sub": str(user.id), "role": user.role})
client_ip = request.client.host if request.client else None
await log_audit(db, user.id, "login", "auth", detail=f"用户 {user.username} 登录", ip_address=client_ip)
return Token(
access_token=token,
user={"id": user.id, "username": user.username, "full_name": user.full_name, "role": user.role}

View File

@@ -6,6 +6,7 @@ from app.core.database import get_db
from app.core.deps import get_current_user, require_roles
from app.models.device import Device, DeviceType, DeviceGroup
from app.models.user import User
from app.services.audit import log_audit
router = APIRouter(prefix="/devices", tags=["设备管理"])
@@ -98,6 +99,7 @@ async def create_device(data: DeviceCreate, db: AsyncSession = Depends(get_db),
device = Device(**data.model_dump())
db.add(device)
await db.flush()
await log_audit(db, user.id, "create", "device", detail=f"创建设备 {data.name} ({data.code})")
return _device_to_dict(device)
@@ -107,8 +109,10 @@ async def update_device(device_id: int, data: DeviceUpdate, db: AsyncSession = D
device = result.scalar_one_or_none()
if not device:
raise HTTPException(status_code=404, detail="设备不存在")
for k, v in data.model_dump(exclude_unset=True).items():
updates = data.model_dump(exclude_unset=True)
for k, v in updates.items():
setattr(device, k, v)
await log_audit(db, user.id, "update", "device", detail=f"更新设备 {device.name}: {', '.join(updates.keys())}")
return _device_to_dict(device)

View File

@@ -13,6 +13,7 @@ from app.core.deps import get_current_user
from app.models.report import ReportTemplate, ReportTask
from app.models.user import User
from app.services.report_generator import ReportGenerator, REPORTS_DIR
from app.services.audit import log_audit
logger = logging.getLogger(__name__)
@@ -184,6 +185,7 @@ async def run_task(
task.file_path = filepath
task.last_run = datetime.now()
await db.flush()
await log_audit(db, user.id, "export", "report", detail=f"运行报表任务 #{task_id}")
logger.info(f"Report task {task_id} completed: {filepath}")
return {"message": "报表生成完成", "task_id": task.id, "mode": "sync", "status": "completed"}
except Exception as e:
@@ -286,6 +288,7 @@ async def generate_quick_report(
raise HTTPException(status_code=500, detail=f"报表生成失败: {str(e)}")
filename = Path(filepath).name
await log_audit(db, user.id, "export", "report", detail=f"生成报表: {req.report_type} ({req.export_format})")
return {
"message": "报表生成成功",
"filename": filename,

View File

@@ -0,0 +1,84 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from app.core.database import get_db
from app.core.deps import get_current_user, require_roles
from app.models.user import User
from app.models.setting import SystemSetting
from app.services.audit import log_audit
router = APIRouter(prefix="/settings", tags=["系统设置"])
# Default settings — used when keys are missing from DB
DEFAULTS: dict[str, str] = {
"platform_name": "天普零碳园区智慧能源管理平台",
"data_retention_days": "365",
"alarm_auto_resolve_minutes": "30",
"simulator_interval_seconds": "15",
"notification_email_enabled": "false",
"notification_email_smtp": "",
"report_auto_schedule_enabled": "false",
"timezone": "Asia/Shanghai",
}
class SettingsUpdate(BaseModel):
platform_name: str | None = None
data_retention_days: int | None = None
alarm_auto_resolve_minutes: int | None = None
simulator_interval_seconds: int | None = None
notification_email_enabled: bool | None = None
notification_email_smtp: str | None = None
report_auto_schedule_enabled: bool | None = None
timezone: str | None = None
@router.get("")
async def get_settings(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Return all platform settings as a flat dict."""
result = await db.execute(select(SystemSetting))
db_settings = {s.key: s.value for s in result.scalars().all()}
# Merge defaults with DB values
merged = {**DEFAULTS, **db_settings}
# Cast types for frontend
return {
"platform_name": merged["platform_name"],
"data_retention_days": int(merged["data_retention_days"]),
"alarm_auto_resolve_minutes": int(merged["alarm_auto_resolve_minutes"]),
"simulator_interval_seconds": int(merged["simulator_interval_seconds"]),
"notification_email_enabled": merged["notification_email_enabled"] == "true",
"notification_email_smtp": merged["notification_email_smtp"],
"report_auto_schedule_enabled": merged["report_auto_schedule_enabled"] == "true",
"timezone": merged["timezone"],
}
@router.put("")
async def update_settings(
data: SettingsUpdate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_roles("admin")),
):
"""Update platform settings (admin only)."""
updates = data.model_dump(exclude_unset=True)
changed_keys = []
for key, value in updates.items():
str_value = str(value).lower() if isinstance(value, bool) else str(value)
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
if setting:
setting.value = str_value
else:
db.add(SystemSetting(key=key, value=str_value))
changed_keys.append(key)
await log_audit(
db, user.id, "update", "system",
detail=f"更新系统设置: {', '.join(changed_keys)}",
)
return {"message": "设置已更新"}

View File

@@ -6,6 +6,7 @@ from app.core.database import get_db
from app.core.deps import get_current_user, require_roles
from app.core.security import hash_password
from app.models.user import User, Role
from app.services.audit import log_audit
router = APIRouter(prefix="/users", tags=["用户管理"])
@@ -58,6 +59,7 @@ async def create_user(data: UserCreate, db: AsyncSession = Depends(get_db), user
)
db.add(new_user)
await db.flush()
await log_audit(db, user.id, "create", "user", detail=f"创建用户 {data.username}")
return {"id": new_user.id, "username": new_user.username}
@@ -67,8 +69,10 @@ async def update_user(user_id: int, data: UserUpdate, db: AsyncSession = Depends
target = result.scalar_one_or_none()
if not target:
raise HTTPException(status_code=404, detail="用户不存在")
for k, v in data.model_dump(exclude_unset=True).items():
updates = data.model_dump(exclude_unset=True)
for k, v in updates.items():
setattr(target, k, v)
await log_audit(db, admin.id, "update", "user", detail=f"更新用户 {target.username}: {', '.join(updates.keys())}")
return {"message": "已更新"}

View File

@@ -23,6 +23,17 @@ class Settings(BaseSettings):
CELERY_ENABLED: bool = False # Set True when Celery worker is running
USE_SIMULATOR: bool = True # True=simulator mode, False=real IoT collectors
# SMTP Email settings
SMTP_HOST: str = ""
SMTP_PORT: int = 587
SMTP_USER: str = ""
SMTP_PASSWORD: str = ""
SMTP_FROM: str = "noreply@tianpu-ems.com"
SMTP_ENABLED: bool = False
# Platform URL for links in emails
PLATFORM_URL: str = "http://localhost:3000"
@property
def DATABASE_URL_SYNC(self) -> str:
"""Derive synchronous URL from async DATABASE_URL for Alembic."""

View File

@@ -8,6 +8,7 @@ from app.api.router import api_router
from app.api.v1.websocket import start_broadcast_task, stop_broadcast_task
from app.core.config import get_settings
from app.services.simulator import DataSimulator
from app.services.report_scheduler import start_scheduler, stop_scheduler
from app.collectors.manager import CollectorManager
settings = get_settings()
@@ -28,7 +29,9 @@ async def lifespan(app: FastAPI):
collector_manager = CollectorManager()
await collector_manager.start()
start_broadcast_task()
await start_scheduler()
yield
await stop_scheduler()
stop_broadcast_task()
if settings.USE_SIMULATOR:
await simulator.stop()

View File

@@ -4,6 +4,7 @@ from app.models.energy import EnergyData, EnergyDailySummary
from app.models.alarm import AlarmRule, AlarmEvent
from app.models.carbon import CarbonEmission, EmissionFactor
from app.models.report import ReportTemplate, ReportTask
from app.models.setting import SystemSetting
__all__ = [
"User", "Role", "AuditLog",
@@ -12,4 +13,5 @@ __all__ = [
"AlarmRule", "AlarmEvent",
"CarbonEmission", "EmissionFactor",
"ReportTemplate", "ReportTask",
"SystemSetting",
]

View File

@@ -0,0 +1,13 @@
from sqlalchemy import Column, Integer, String, Text, DateTime
from sqlalchemy.sql import func
from app.core.database import Base
class SystemSetting(Base):
__tablename__ = "system_settings"
id = Column(Integer, primary_key=True, autoincrement=True)
key = Column(String(100), unique=True, nullable=False, index=True)
value = Column(Text, nullable=False, default="")
description = Column(String(255))
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

View File

@@ -1,13 +1,111 @@
"""告警检测服务 - 根据告警规则检查最新数据,生成/自动恢复告警事件"""
import asyncio
import logging
from datetime import datetime, timezone, timedelta
from pathlib import Path
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.alarm import AlarmRule, AlarmEvent
from app.models.energy import EnergyData
from app.models.device import Device
logger = logging.getLogger("alarm_checker")
# Alarm email template path
_ALARM_TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "templates" / "alarm_email.html"
# Severity display config
_SEVERITY_CONFIG = {
"critical": {
"label": "紧急告警",
"badge_color": "#d32f2f",
"bg_color": "#ffebee",
"text_color": "#c62828",
},
"major": {
"label": "重要告警",
"badge_color": "#e65100",
"bg_color": "#fff3e0",
"text_color": "#e65100",
},
"warning": {
"label": "一般告警",
"badge_color": "#f9a825",
"bg_color": "#fffde7",
"text_color": "#f57f17",
},
}
async def _send_alarm_email(
rule: AlarmRule, event: AlarmEvent, device_id: int, session: AsyncSession
):
"""Send alarm notification email if configured."""
from app.services.email_service import send_email
from app.core.config import get_settings
# Check if email is in notify_channels
channels = rule.notify_channels or []
if "email" not in channels:
return
# Get email targets from notify_targets
targets = rule.notify_targets or {}
emails = targets.get("emails", []) if isinstance(targets, dict) else []
# If notify_targets is a list of strings (emails directly)
if isinstance(targets, list):
emails = [t for t in targets if isinstance(t, str) and "@" in t]
if not emails:
logger.debug(f"No email recipients for alarm rule '{rule.name}', skipping.")
return
# Fetch device info
dev_result = await session.execute(select(Device).where(Device.id == device_id))
device = dev_result.scalar_one_or_none()
device_name = device.name if device else f"设备#{device_id}"
device_code = device.code if device else "N/A"
settings = get_settings()
severity_cfg = _SEVERITY_CONFIG.get(rule.severity, _SEVERITY_CONFIG["warning"])
# Build threshold string
if rule.condition == "range_out":
threshold_str = f"[{rule.threshold_low}, {rule.threshold_high}]"
else:
threshold_str = str(rule.threshold)
# Format triggered time in Beijing timezone
triggered_time = event.triggered_at or datetime.now(timezone.utc)
triggered_beijing = triggered_time + timedelta(hours=8)
triggered_str = triggered_beijing.strftime("%Y-%m-%d %H:%M:%S")
# Load and render template
try:
template_html = _ALARM_TEMPLATE_PATH.read_text(encoding="utf-8")
except FileNotFoundError:
logger.error("Alarm email template not found, skipping email.")
return
body_html = template_html.format(
severity_label=severity_cfg["label"],
severity_badge_color=severity_cfg["badge_color"],
severity_bg_color=severity_cfg["bg_color"],
severity_text_color=severity_cfg["text_color"],
title=event.title,
device_name=device_name,
device_code=device_code,
data_type=rule.data_type,
current_value=str(event.value),
threshold_str=threshold_str,
triggered_at=triggered_str,
description=event.description or "",
platform_url=settings.PLATFORM_URL,
)
subject = f"[{severity_cfg['label']}] {event.title} - 天普EMS告警通知"
asyncio.create_task(send_email(to=emails, subject=subject, body_html=body_html))
# Rate limit: don't create duplicate events for the same rule+device within this window
RATE_LIMIT_MINUTES = 5
@@ -140,6 +238,9 @@ async def check_alarms(session: AsyncSession):
f"value={dp.value} threshold={threshold_str}"
)
# Send email notification (non-blocking)
await _send_alarm_email(rule, event, device_id, session)
elif not triggered and active_event:
# Auto-resolve
active_event.status = "resolved"

View File

@@ -0,0 +1,32 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import AuditLog
async def log_audit(
db: AsyncSession,
user_id: int | None,
action: str,
resource: str,
detail: str = "",
ip_address: str | None = None,
):
"""Write an audit log entry.
Args:
db: async database session (must be flushed/committed by caller)
user_id: ID of the acting user (None for system actions)
action: one of login, create, update, delete, export, view,
acknowledge, resolve
resource: one of user, device, alarm, report, system, auth
detail: human-readable description
ip_address: client IP if available
"""
entry = AuditLog(
user_id=user_id,
action=action,
resource=resource,
detail=detail,
ip_address=ip_address,
)
db.add(entry)
# Don't flush here — let the caller's transaction handle it

View File

@@ -0,0 +1,105 @@
"""邮件发送服务 - SMTP email sending for alarm notifications and report delivery."""
import logging
import smtplib
import ssl
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.base import MIMEBase
from email import encoders
from pathlib import Path
from typing import Optional
from app.core.config import get_settings
logger = logging.getLogger("email_service")
async def send_email(
to: list[str],
subject: str,
body_html: str,
attachments: Optional[list[str]] = None,
) -> bool:
"""
Send an email via SMTP.
Args:
to: List of recipient email addresses.
subject: Email subject line.
body_html: HTML body content.
attachments: Optional list of file paths to attach.
Returns:
True if sent successfully, False otherwise.
"""
settings = get_settings()
if not settings.SMTP_ENABLED:
logger.warning("SMTP is not enabled (SMTP_ENABLED=False). Skipping email send.")
return False
if not settings.SMTP_HOST:
logger.warning("SMTP_HOST is not configured. Skipping email send.")
return False
if not to:
logger.warning("No recipients specified. Skipping email send.")
return False
try:
msg = MIMEMultipart("mixed")
msg["From"] = settings.SMTP_FROM
msg["To"] = ", ".join(to)
msg["Subject"] = subject
# HTML body
html_part = MIMEText(body_html, "html", "utf-8")
msg.attach(html_part)
# Attachments
if attachments:
for filepath in attachments:
path = Path(filepath)
if not path.exists():
logger.warning(f"Attachment not found, skipping: {filepath}")
continue
with open(path, "rb") as f:
part = MIMEBase("application", "octet-stream")
part.set_payload(f.read())
encoders.encode_base64(part)
part.add_header(
"Content-Disposition",
f'attachment; filename="{path.name}"',
)
msg.attach(part)
# Send via SMTP
context = ssl.create_default_context()
if settings.SMTP_PORT == 465:
# SSL connection
with smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT, context=context) as server:
if settings.SMTP_USER and settings.SMTP_PASSWORD:
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
server.sendmail(settings.SMTP_FROM, to, msg.as_string())
else:
# STARTTLS connection (port 587 or 25)
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as server:
server.ehlo()
if settings.SMTP_PORT == 587:
server.starttls(context=context)
server.ehlo()
if settings.SMTP_USER and settings.SMTP_PASSWORD:
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
server.sendmail(settings.SMTP_FROM, to, msg.as_string())
logger.info(f"Email sent successfully to {to}, subject: {subject}")
return True
except smtplib.SMTPException as e:
logger.error(f"SMTP error sending email to {to}: {e}")
return False
except Exception as e:
logger.error(f"Unexpected error sending email to {to}: {e}")
return False

View File

@@ -0,0 +1,192 @@
"""报表定时调度服务 - Schedule report tasks via APScheduler and send results by email."""
import logging
from datetime import date, timedelta, datetime, timezone
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from sqlalchemy import select
from app.core.database import async_session
from app.models.report import ReportTask, ReportTemplate
from app.services.report_generator import ReportGenerator
from app.services.email_service import send_email
logger = logging.getLogger("report_scheduler")
_scheduler: AsyncIOScheduler | None = None
def _parse_cron(cron_expr: str) -> dict:
"""Parse a 5-field cron expression into APScheduler CronTrigger kwargs."""
parts = cron_expr.strip().split()
if len(parts) != 5:
raise ValueError(f"Invalid cron expression (need 5 fields): {cron_expr}")
return {
"minute": parts[0],
"hour": parts[1],
"day": parts[2],
"month": parts[3],
"day_of_week": parts[4],
}
async def _run_report_task(task_id: int):
"""Execute a single report task: generate the report and email it to recipients."""
logger.info(f"Running scheduled report task id={task_id}")
async with async_session() as session:
# Load task
task_result = await session.execute(
select(ReportTask).where(ReportTask.id == task_id)
)
task = task_result.scalar_one_or_none()
if not task:
logger.warning(f"Report task id={task_id} not found, skipping.")
return
if not task.is_active:
logger.info(f"Report task id={task_id} is inactive, skipping.")
return
# Update status
task.status = "running"
task.last_run = datetime.now(timezone.utc)
await session.flush()
# Load template to determine report type
tmpl_result = await session.execute(
select(ReportTemplate).where(ReportTemplate.id == task.template_id)
)
template = tmpl_result.scalar_one_or_none()
if not template:
logger.error(f"Template id={task.template_id} not found for task id={task_id}")
task.status = "failed"
await session.commit()
return
try:
generator = ReportGenerator(session)
today = date.today()
export_format = task.export_format or "xlsx"
# Choose generation method based on template report_type
if template.report_type == "daily":
yesterday = today - timedelta(days=1)
filepath = await generator.generate_energy_daily_report(
start_date=yesterday, end_date=yesterday, export_format=export_format
)
elif template.report_type == "monthly":
# Generate for previous month
first_of_month = today.replace(day=1)
last_month_end = first_of_month - timedelta(days=1)
last_month_start = last_month_end.replace(day=1)
filepath = await generator.generate_monthly_summary(
month=last_month_start.month,
year=last_month_start.year,
export_format=export_format,
)
elif template.report_type == "custom" and "device" in template.name.lower():
filepath = await generator.generate_device_status_report(
export_format=export_format
)
else:
# Default: daily report for yesterday
yesterday = today - timedelta(days=1)
filepath = await generator.generate_energy_daily_report(
start_date=yesterday, end_date=yesterday, export_format=export_format
)
task.file_path = filepath
task.status = "completed"
logger.info(f"Report task id={task_id} completed: {filepath}")
# Send email with attachment if recipients configured
recipients = task.recipients or []
if isinstance(recipients, list) and recipients:
report_name = task.name or template.name
subject = f"{report_name} - 天普EMS自动报表"
body_html = f"""
<div style="font-family: 'Microsoft YaHei', sans-serif; padding: 20px;">
<h2 style="color: #1a73e8;">天普零碳园区智慧能源管理平台</h2>
<p>您好,</p>
<p>系统已自动生成 <strong>{report_name}</strong>,请查收附件。</p>
<p style="color: #666; font-size: 13px;">
生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}<br>
报表类型: {template.report_type}<br>
格式: {export_format.upper()}
</p>
<hr style="border: none; border-top: 1px solid #e8e8e8; margin: 20px 0;">
<p style="font-size: 12px; color: #999;">此为系统自动发送,请勿回复。</p>
</div>
"""
await send_email(
to=recipients,
subject=subject,
body_html=body_html,
attachments=[filepath],
)
except Exception as e:
logger.error(f"Report task id={task_id} failed: {e}", exc_info=True)
task.status = "failed"
await session.commit()
async def _load_and_schedule_tasks():
"""Load all active report tasks with schedules and register them with APScheduler."""
global _scheduler
if not _scheduler:
return
async with async_session() as session:
result = await session.execute(
select(ReportTask).where(
ReportTask.is_active == True,
ReportTask.schedule != None,
ReportTask.schedule != "",
)
)
tasks = result.scalars().all()
for task in tasks:
try:
cron_kwargs = _parse_cron(task.schedule)
_scheduler.add_job(
_run_report_task,
CronTrigger(**cron_kwargs),
args=[task.id],
id=f"report_task_{task.id}",
replace_existing=True,
misfire_grace_time=3600,
)
logger.info(
f"Scheduled report task id={task.id} name='{task.name}' "
f"cron='{task.schedule}'"
)
except Exception as e:
logger.error(f"Failed to schedule report task id={task.id}: {e}")
logger.info(f"Report scheduler loaded {len(tasks)} task(s).")
async def start_scheduler():
"""Start the APScheduler-based report scheduler."""
global _scheduler
if _scheduler and _scheduler.running:
logger.warning("Report scheduler is already running.")
return
_scheduler = AsyncIOScheduler(timezone="Asia/Shanghai")
_scheduler.start()
logger.info("Report scheduler started.")
await _load_and_schedule_tasks()
async def stop_scheduler():
"""Stop the report scheduler gracefully."""
global _scheduler
if _scheduler and _scheduler.running:
_scheduler.shutdown(wait=False)
logger.info("Report scheduler stopped.")
_scheduler = None

View File

@@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin:0; padding:0; background-color:#f4f5f7; font-family: 'Microsoft YaHei', 'PingFang SC', 'Helvetica Neue', Arial, sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f5f7; padding:20px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 8px rgba(0,0,0,0.08);">
<!-- Header -->
<tr>
<td style="background: linear-gradient(135deg, #1a73e8, #0d47a1); padding:24px 32px; text-align:center;">
<div style="font-size:12px; color:rgba(255,255,255,0.8); margin-bottom:4px;">TIANPU EMS</div>
<div style="font-size:20px; font-weight:bold; color:#ffffff; letter-spacing:1px;">天普零碳园区智慧能源管理平台</div>
</td>
</tr>
<!-- Alert Banner -->
<tr>
<td style="padding:0;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td style="background-color:{severity_bg_color}; padding:16px 32px; text-align:center;">
<span style="display:inline-block; background-color:{severity_badge_color}; color:#ffffff; font-size:13px; font-weight:bold; padding:4px 16px; border-radius:12px; letter-spacing:1px;">{severity_label}</span>
<div style="color:{severity_text_color}; font-size:16px; font-weight:bold; margin-top:8px;">{title}</div>
</td>
</tr>
</table>
</td>
</tr>
<!-- Alarm Details -->
<tr>
<td style="padding:24px 32px;">
<table width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e8e8e8; border-radius:6px; overflow:hidden;">
<tr>
<td style="background-color:#fafafa; padding:10px 16px; font-size:13px; color:#666; width:120px; border-bottom:1px solid #e8e8e8;">设备名称</td>
<td style="padding:10px 16px; font-size:14px; color:#333; border-bottom:1px solid #e8e8e8;">{device_name}</td>
</tr>
<tr>
<td style="background-color:#fafafa; padding:10px 16px; font-size:13px; color:#666; border-bottom:1px solid #e8e8e8;">设备编号</td>
<td style="padding:10px 16px; font-size:14px; color:#333; border-bottom:1px solid #e8e8e8;">{device_code}</td>
</tr>
<tr>
<td style="background-color:#fafafa; padding:10px 16px; font-size:13px; color:#666; border-bottom:1px solid #e8e8e8;">监控指标</td>
<td style="padding:10px 16px; font-size:14px; color:#333; border-bottom:1px solid #e8e8e8;">{data_type}</td>
</tr>
<tr>
<td style="background-color:#fafafa; padding:10px 16px; font-size:13px; color:#666; border-bottom:1px solid #e8e8e8;">当前值</td>
<td style="padding:10px 16px; font-size:14px; color:{severity_badge_color}; font-weight:bold; border-bottom:1px solid #e8e8e8;">{current_value}</td>
</tr>
<tr>
<td style="background-color:#fafafa; padding:10px 16px; font-size:13px; color:#666; border-bottom:1px solid #e8e8e8;">告警阈值</td>
<td style="padding:10px 16px; font-size:14px; color:#333; border-bottom:1px solid #e8e8e8;">{threshold_str}</td>
</tr>
<tr>
<td style="background-color:#fafafa; padding:10px 16px; font-size:13px; color:#666;">触发时间</td>
<td style="padding:10px 16px; font-size:14px; color:#333;">{triggered_at}</td>
</tr>
</table>
</td>
</tr>
<!-- Description -->
<tr>
<td style="padding:0 32px 24px;">
<div style="background-color:#fff8e1; border-left:4px solid #ffa000; padding:12px 16px; border-radius:0 4px 4px 0; font-size:13px; color:#795548;">
{description}
</div>
</td>
</tr>
<!-- Action Button -->
<tr>
<td style="padding:0 32px 24px; text-align:center;">
<a href="{platform_url}/alarms" style="display:inline-block; background-color:#1a73e8; color:#ffffff; text-decoration:none; padding:12px 32px; border-radius:6px; font-size:14px; font-weight:bold;">查看告警详情</a>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color:#f8f9fa; padding:16px 32px; border-top:1px solid #e8e8e8;">
<div style="font-size:12px; color:#999; text-align:center; line-height:1.6;">
此为系统自动发送,请勿回复。<br>
天普零碳园区智慧能源管理平台 &copy; 2026
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>