659 lines
28 KiB
Python
659 lines
28 KiB
Python
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import os
|
||
import re
|
||
import secrets
|
||
from datetime import datetime, timedelta
|
||
|
||
from flask import Blueprint, abort, current_app, flash, jsonify, redirect, render_template, request, send_file, session, url_for
|
||
from flask_login import current_user, login_required, login_user, logout_user
|
||
from sqlalchemy import or_
|
||
from sqlalchemy.orm import joinedload, selectinload
|
||
|
||
from .config import BASE_DIR
|
||
from .email import (
|
||
EmailConfigurationError,
|
||
EmailDeliveryError,
|
||
password_reset_notice_email,
|
||
send_transactional_email,
|
||
verification_code_email,
|
||
)
|
||
from .extensions import db
|
||
from .models import AppSetting, EmailVerificationCode, TrustedDevice, UploadRecord, User
|
||
from .prediction import PredictionError, run_prediction
|
||
from .security import new_captcha
|
||
from .time_utils import format_datetime_for_timezone, utc_now
|
||
|
||
bp = Blueprint("main", __name__)
|
||
REFERENCE_PDF_NAME = "20260630标准文本——供水管道健康状态与剩余寿命评估技术导则.pdf"
|
||
TEMPLATE_EXCEL_NAME = "管道预测数据模板.xlsx"
|
||
REGISTRATION_SETTING_KEY = "allow_registration"
|
||
RECORDS_PER_PAGE = 10
|
||
USERS_PER_PAGE = 20
|
||
EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
|
||
EMAIL_CODE_PURPOSES = {
|
||
"register",
|
||
"login",
|
||
"reset",
|
||
"change_password",
|
||
"change_email_old",
|
||
"change_email_new",
|
||
}
|
||
EMAIL_CODE_LABELS = {
|
||
"register": "完成注册",
|
||
"login": "登录确认",
|
||
"reset": "重置密码",
|
||
"change_password": "修改密码",
|
||
"change_email_old": "确认原邮箱",
|
||
"change_email_new": "确认新邮箱",
|
||
}
|
||
TRUSTED_DEVICE_COOKIE = "trusted_device"
|
||
|
||
|
||
def normal_email(value: str) -> str:
|
||
return value.strip().lower()
|
||
|
||
|
||
def valid_email(value: str) -> bool:
|
||
return len(value) <= 254 and bool(EMAIL_RE.fullmatch(value))
|
||
|
||
|
||
def valid_password(password: str) -> bool:
|
||
return (
|
||
12 <= len(password) <= 128
|
||
and not any(character.isspace() for character in password)
|
||
and any(character.islower() for character in password)
|
||
and any(character.isupper() for character in password)
|
||
and any(character.isdigit() for character in password)
|
||
and any(not character.isalnum() for character in password)
|
||
)
|
||
|
||
|
||
PASSWORD_RULE_MESSAGE = "密码须为12至128位,包含大写字母、小写字母、数字和特殊字符,且不能含空格。"
|
||
|
||
|
||
def grant_fresh_authorization(user: User, purpose: str) -> None:
|
||
"""Grant a short, session-bound authorization after successful email MFA."""
|
||
session[f"fresh_auth_{purpose}"] = {
|
||
"user_id": user.id,
|
||
"auth_version": user.auth_version,
|
||
"expires_at": (
|
||
utc_now() + timedelta(minutes=current_app.config["FRESH_AUTH_MINUTES"])
|
||
).isoformat(),
|
||
}
|
||
|
||
|
||
def has_fresh_authorization(user: User, purpose: str) -> bool:
|
||
authorization = session.get(f"fresh_auth_{purpose}")
|
||
if not isinstance(authorization, dict):
|
||
return False
|
||
try:
|
||
expires_at = datetime.fromisoformat(authorization["expires_at"])
|
||
except (KeyError, TypeError, ValueError):
|
||
return False
|
||
return (
|
||
authorization.get("user_id") == user.id
|
||
and authorization.get("auth_version") == user.auth_version
|
||
and expires_at > utc_now()
|
||
)
|
||
|
||
|
||
def consume_fresh_authorization(purpose: str) -> None:
|
||
session.pop(f"fresh_auth_{purpose}", None)
|
||
|
||
|
||
def render_auth_template(
|
||
mode: str,
|
||
status_code: int = 200,
|
||
captcha: str = "",
|
||
auth_error_field: str | None = None,
|
||
):
|
||
return render_template(
|
||
"login.html",
|
||
mode=mode,
|
||
captcha=captcha,
|
||
auth_error_field=auth_error_field,
|
||
), status_code
|
||
|
||
|
||
def refresh_captcha() -> str:
|
||
session["captcha"] = new_captcha()
|
||
return session["captcha"]
|
||
|
||
|
||
def render_auth_error(
|
||
mode: str,
|
||
message: str,
|
||
status_code: int = 400,
|
||
auth_error_field: str | None = None,
|
||
):
|
||
flash(message, "error")
|
||
return render_auth_template(mode, status_code, refresh_captcha(), auth_error_field)
|
||
|
||
|
||
def captcha_is_valid() -> bool:
|
||
value = request.form.get("captcha", "").strip().upper()
|
||
return bool(value and value == session.get("captcha", ""))
|
||
|
||
|
||
def require_admin() -> None:
|
||
if not current_user.is_admin:
|
||
abort(403)
|
||
|
||
|
||
def registration_allowed() -> bool:
|
||
return AppSetting.get_bool(REGISTRATION_SETTING_KEY, current_app.config["ALLOW_REGISTRATION"])
|
||
|
||
|
||
def requested_page(parameter: str = "page") -> int:
|
||
try:
|
||
return max(int(request.args.get(parameter, 1)), 1)
|
||
except (TypeError, ValueError):
|
||
return 1
|
||
|
||
|
||
def paginated_uploads(query, endpoint: str):
|
||
page = requested_page()
|
||
pagination = query.order_by(UploadRecord.upload_time.desc()).paginate(
|
||
page=page,
|
||
per_page=RECORDS_PER_PAGE,
|
||
error_out=False,
|
||
)
|
||
if pagination.pages and page > pagination.pages:
|
||
return pagination, redirect(url_for(endpoint, page=pagination.pages))
|
||
return pagination, None
|
||
|
||
|
||
def paginated_users():
|
||
search = request.args.get("q", "").strip()
|
||
page = requested_page("user_page")
|
||
query = User.query.filter(User.is_admin.is_(False)).options(
|
||
selectinload(User.trusted_devices)
|
||
)
|
||
if search:
|
||
pattern = f"%{search}%"
|
||
query = query.filter(
|
||
or_(User.username.ilike(pattern), User.email.ilike(pattern))
|
||
)
|
||
pagination = query.order_by(User.created_at.desc(), User.id.desc()).paginate(
|
||
page=page,
|
||
per_page=USERS_PER_PAGE,
|
||
error_out=False,
|
||
)
|
||
if pagination.pages and page > pagination.pages:
|
||
return pagination, search, redirect(
|
||
url_for(
|
||
"main.admin_dashboard",
|
||
page=requested_page(),
|
||
user_page=pagination.pages,
|
||
q=search,
|
||
)
|
||
)
|
||
return pagination, search, None
|
||
|
||
|
||
def admin_dashboard_redirect():
|
||
return redirect(
|
||
url_for(
|
||
"main.admin_dashboard",
|
||
user_page=request.form.get("user_page", 1),
|
||
q=request.form.get("q", ""),
|
||
)
|
||
)
|
||
|
||
|
||
def code_hash(email: str, purpose: str, code: str) -> str:
|
||
return hashlib.sha256(f"{email}:{purpose}:{code}".encode()).hexdigest()
|
||
|
||
|
||
def issue_email_code(email: str, purpose: str) -> str | None:
|
||
"""Issue an email code, returning a failure reason when it cannot be sent."""
|
||
now = utc_now()
|
||
resend_at = now - timedelta(seconds=current_app.config["EMAIL_CODE_RESEND_SECONDS"])
|
||
recent = (
|
||
EmailVerificationCode.query.filter_by(email=email, purpose=purpose)
|
||
.filter(
|
||
EmailVerificationCode.created_at >= resend_at,
|
||
EmailVerificationCode.expires_at > now,
|
||
EmailVerificationCode.used_at.is_(None),
|
||
)
|
||
.first()
|
||
)
|
||
ip = request.remote_addr or ""
|
||
hourly = EmailVerificationCode.query.filter(
|
||
EmailVerificationCode.created_at >= now - timedelta(hours=1),
|
||
EmailVerificationCode.requested_ip == ip,
|
||
).count()
|
||
if recent:
|
||
return "resend_wait"
|
||
if hourly >= 20:
|
||
return "rate_limited"
|
||
EmailVerificationCode.query.filter_by(email=email, purpose=purpose, used_at=None).update({"used_at": now})
|
||
code = f"{secrets.randbelow(1_000_000):06d}"
|
||
record = EmailVerificationCode(
|
||
email=email,
|
||
purpose=purpose,
|
||
code_hash=code_hash(email, purpose, code),
|
||
expires_at=now + timedelta(minutes=current_app.config["EMAIL_CODE_MINUTES"]),
|
||
requested_ip=ip,
|
||
)
|
||
db.session.add(record)
|
||
db.session.commit()
|
||
try:
|
||
send_transactional_email(
|
||
to=email,
|
||
subject=f"【管道健康】{EMAIL_CODE_LABELS.get(purpose, '身份验证')}验证码",
|
||
html=verification_code_email(
|
||
code=code,
|
||
minutes=current_app.config["EMAIL_CODE_MINUTES"],
|
||
purpose=EMAIL_CODE_LABELS.get(purpose, "身份验证"),
|
||
),
|
||
)
|
||
except (EmailConfigurationError, EmailDeliveryError):
|
||
db.session.delete(record)
|
||
db.session.commit()
|
||
return "delivery_failed"
|
||
return None
|
||
|
||
|
||
def email_code_issue_message(reason: str) -> str:
|
||
if reason == "resend_wait":
|
||
return f"验证码已发送,请 {current_app.config['EMAIL_CODE_RESEND_SECONDS']} 秒后再试。"
|
||
if reason == "rate_limited":
|
||
return "请求过于频繁,请稍后再试。"
|
||
return "验证码发送失败,请稍后重试。"
|
||
|
||
|
||
def consume_email_code(email: str, purpose: str, code: str) -> bool:
|
||
record = (
|
||
EmailVerificationCode.query.filter_by(email=email, purpose=purpose, used_at=None)
|
||
.order_by(EmailVerificationCode.created_at.desc())
|
||
.first()
|
||
)
|
||
if (
|
||
record is None
|
||
or record.expires_at <= utc_now()
|
||
or record.attempts >= current_app.config["EMAIL_CODE_MAX_ATTEMPTS"]
|
||
):
|
||
return False
|
||
record.attempts += 1
|
||
if not secrets.compare_digest(record.code_hash, code_hash(email, purpose, code.strip())):
|
||
db.session.commit()
|
||
return False
|
||
record.used_at = utc_now()
|
||
db.session.commit()
|
||
return True
|
||
|
||
|
||
def trusted_device_for(user: User):
|
||
token = request.cookies.get(TRUSTED_DEVICE_COOKIE, "")
|
||
if not token:
|
||
return None
|
||
record = TrustedDevice.query.filter_by(
|
||
token_hash=hashlib.sha256(token.encode()).hexdigest(),
|
||
user_id=user.id,
|
||
).first()
|
||
if record and record.expires_at > utc_now() and record.auth_version == user.auth_version:
|
||
record.last_used_at = utc_now()
|
||
db.session.commit()
|
||
return record
|
||
return None
|
||
|
||
|
||
def login_response(user: User, remember: bool, trust_device: bool = False):
|
||
login_user(user, remember=remember)
|
||
session["auth_version"] = user.auth_version
|
||
response = redirect(url_for("main.home"))
|
||
if trust_device:
|
||
token = secrets.token_urlsafe(32)
|
||
device = TrustedDevice(
|
||
user_id=user.id,
|
||
token_hash=hashlib.sha256(token.encode()).hexdigest(),
|
||
expires_at=utc_now()
|
||
+ timedelta(days=current_app.config["TRUSTED_DEVICE_DAYS"]),
|
||
auth_version=user.auth_version,
|
||
)
|
||
db.session.add(device)
|
||
db.session.commit()
|
||
response.set_cookie(
|
||
TRUSTED_DEVICE_COOKIE,
|
||
token,
|
||
max_age=current_app.config["TRUSTED_DEVICE_DAYS"] * 86400,
|
||
secure=current_app.config["SESSION_COOKIE_SECURE"],
|
||
httponly=True,
|
||
samesite="Lax",
|
||
)
|
||
return response
|
||
|
||
|
||
def verification_page(title: str, purpose: str):
|
||
return render_template(
|
||
"verification.html",
|
||
title=title,
|
||
purpose=purpose,
|
||
email=session.get("pending_email", ""),
|
||
resend_seconds=current_app.config["EMAIL_CODE_RESEND_SECONDS"],
|
||
)
|
||
|
||
|
||
@bp.route("/")
|
||
def index():
|
||
return redirect(url_for("main.home" if current_user.is_authenticated else "main.login"))
|
||
|
||
|
||
@bp.route("/login", methods=["GET", "POST"])
|
||
def login():
|
||
if current_user.is_authenticated:
|
||
return redirect(url_for("main.home"))
|
||
if request.method == "GET":
|
||
return render_auth_template("login", captcha=refresh_captcha())
|
||
identifier, password = request.form.get("identifier", "").strip(), request.form.get("password", "")
|
||
if not captcha_is_valid():
|
||
return render_auth_error("login", "图形验证码错误", auth_error_field="captcha")
|
||
user = User.query.filter(
|
||
or_(User.username == identifier, User.email == normal_email(identifier))
|
||
).first()
|
||
if not user or not user.is_active_account or not user.check_password(password):
|
||
return render_auth_error("login", "用户名、邮箱或密码错误")
|
||
remember = bool(request.form.get("remember"))
|
||
if trusted_device_for(user):
|
||
return login_response(user, remember)
|
||
session.update(pending_email=user.email, pending_user_id=user.id, pending_remember=remember, pending_purpose="login")
|
||
if reason := issue_email_code(user.email, "login"):
|
||
if reason == "resend_wait":
|
||
flash("邮箱验证码已发送,请输入验证码完成二次认证。", "info")
|
||
return redirect(url_for("main.verify_email", purpose="login"))
|
||
return render_auth_error(
|
||
"login", email_code_issue_message(reason),
|
||
503 if reason == "delivery_failed" else 429,
|
||
)
|
||
return redirect(url_for("main.verify_email", purpose="login"))
|
||
|
||
|
||
@bp.route("/register", methods=["GET", "POST"])
|
||
def register():
|
||
if request.method == "GET": return render_auth_template("register", captcha=refresh_captcha())
|
||
username, email, password = request.form.get("username", "").strip(), normal_email(request.form.get("email", "")), request.form.get("password", "")
|
||
if not captcha_is_valid():
|
||
return render_auth_error("register", "图形验证码错误", auth_error_field="captcha")
|
||
if not registration_allowed(): return render_auth_error("register", "当前未开放自助注册,请联系管理员。", 403)
|
||
if not username or len(username) > 100:
|
||
return render_auth_error("register", "显示名不能为空且不能超过100个字符", auth_error_field="username")
|
||
if not valid_email(email):
|
||
return render_auth_error("register", "请输入有效的邮箱地址", auth_error_field="email")
|
||
if not valid_password(password):
|
||
return render_auth_error("register", PASSWORD_RULE_MESSAGE, auth_error_field="password")
|
||
if User.query.filter((User.username == username) | (User.email == email)).first(): return render_auth_error("register", "显示名或邮箱已被使用")
|
||
user = User(username=username, email=email, is_admin=False, is_active_account=False)
|
||
user.set_password(password); db.session.add(user); db.session.commit()
|
||
session.update(pending_email=email, pending_user_id=user.id, pending_purpose="register")
|
||
if reason := issue_email_code(email, "register"):
|
||
return render_auth_error(
|
||
"register", email_code_issue_message(reason),
|
||
503 if reason == "delivery_failed" else 429,
|
||
)
|
||
return redirect(url_for("main.verify_email", purpose="register"))
|
||
|
||
|
||
@bp.route("/verify/<purpose>", methods=["GET", "POST"])
|
||
def verify_email(purpose: str):
|
||
if purpose not in EMAIL_CODE_PURPOSES or session.get("pending_purpose") != purpose:
|
||
abort(400)
|
||
verification_title = "邮箱二次认证" if purpose == "login" else "邮箱验证"
|
||
if request.method == "GET": return verification_page(verification_title, purpose)
|
||
email = session.get("pending_email", "")
|
||
if not consume_email_code(email, purpose, request.form.get("code", "")):
|
||
flash("邮箱验证码无效、过期或尝试次数已用尽。", "error"); return verification_page(verification_title, purpose), 400
|
||
user = db.session.get(User, session.get("pending_user_id"))
|
||
if not user or (purpose != "change_email_new" and user.email != email): abort(400)
|
||
if purpose == "register":
|
||
user.email_verified_at = utc_now()
|
||
user.is_active_account = True
|
||
db.session.commit()
|
||
session.clear()
|
||
flash("邮箱验证成功,欢迎使用系统。", "info")
|
||
return login_response(user, remember=False)
|
||
if purpose == "login":
|
||
remember = bool(session.get("pending_remember"))
|
||
trust_device = bool(request.form.get("trust_device"))
|
||
session.clear()
|
||
return login_response(user, remember, trust_device=trust_device)
|
||
if purpose == "reset":
|
||
grant_fresh_authorization(user, "password_reset")
|
||
return redirect(url_for("main.set_password"))
|
||
if purpose == "change_email_old":
|
||
new_email = session.get("new_email", "")
|
||
session.update(pending_email=new_email, pending_purpose="change_email_new")
|
||
if reason := issue_email_code(new_email, "change_email_new"):
|
||
flash(email_code_issue_message(reason), "error"); return redirect(url_for("main.account_security"))
|
||
return redirect(url_for("main.verify_email", purpose="change_email_new"))
|
||
if purpose == "change_email_new":
|
||
if User.query.filter(User.email == email, User.id != user.id).first():
|
||
flash("该邮箱已被使用。", "error"); return redirect(url_for("main.account_security"))
|
||
user.email = email; user.email_verified_at = utc_now(); user.revoke_authentication(); TrustedDevice.query.filter_by(user_id=user.id).delete(); db.session.commit(); logout_user(); session.clear(); flash("邮箱已更新,请重新登录。", "info"); return redirect(url_for("main.login"))
|
||
grant_fresh_authorization(user, "password_change")
|
||
return redirect(url_for("main.account_security"))
|
||
|
||
|
||
@bp.route("/verify/<purpose>/resend", methods=["POST"])
|
||
def resend_code(purpose: str):
|
||
if purpose not in EMAIL_CODE_PURPOSES or session.get("pending_purpose") != purpose:
|
||
abort(400)
|
||
if reason := issue_email_code(session.get("pending_email", ""), purpose):
|
||
flash(email_code_issue_message(reason), "error")
|
||
else: flash("验证码已发送,请查收邮箱。", "info")
|
||
return redirect(url_for("main.verify_email", purpose=purpose))
|
||
|
||
|
||
@bp.route("/forgot-password", methods=["GET", "POST"])
|
||
def forgot_password():
|
||
if request.method == "GET": return render_template("forgot_password.html")
|
||
email = normal_email(request.form.get("email", "")); user = User.query.filter_by(email=email, is_active_account=True).first()
|
||
if user:
|
||
session.update(pending_email=email, pending_user_id=user.id, pending_purpose="reset")
|
||
reason = issue_email_code(email, "reset")
|
||
if reason in {None, "resend_wait"}:
|
||
if reason == "resend_wait":
|
||
flash("邮箱验证码已发送,请直接输入验证码继续。", "info")
|
||
return redirect(url_for("main.verify_email", purpose="reset"))
|
||
flash(email_code_issue_message(reason), "error")
|
||
return redirect(url_for("main.forgot_password"))
|
||
flash("若该邮箱已注册,验证码将发送至邮箱。", "info")
|
||
return redirect(url_for("main.login"))
|
||
|
||
|
||
@bp.route("/set-password", methods=["GET", "POST"])
|
||
def set_password():
|
||
authorization = session.get("fresh_auth_password_reset")
|
||
user = db.session.get(User, authorization.get("user_id")) if isinstance(authorization, dict) else None
|
||
if not user or not has_fresh_authorization(user, "password_reset"):
|
||
consume_fresh_authorization("password_reset")
|
||
return redirect(url_for("main.forgot_password"))
|
||
if request.method == "GET": return render_template("set_password.html", title="设置新密码", action=url_for("main.set_password"))
|
||
password, confirm = request.form.get("password", ""), request.form.get("password_confirm", "")
|
||
if not valid_password(password) or password != confirm: flash(f"{PASSWORD_RULE_MESSAGE} 两次输入必须一致。", "error"); return render_template("set_password.html", title="设置新密码", action=url_for("main.set_password")), 400
|
||
user.set_password(password); user.revoke_authentication(); TrustedDevice.query.filter_by(user_id=user.id).delete(); db.session.commit(); consume_fresh_authorization("password_reset"); session.clear(); flash("密码已重置,请重新登录。", "info"); return redirect(url_for("main.login"))
|
||
|
||
|
||
@bp.route("/account/security", methods=["GET", "POST"])
|
||
@login_required
|
||
def account_security():
|
||
if request.method == "GET":
|
||
return render_template(
|
||
"account_security.html",
|
||
password_change_authorized=has_fresh_authorization(
|
||
current_user,
|
||
"password_change",
|
||
),
|
||
)
|
||
action = request.form.get("action")
|
||
if action == "revoke_devices":
|
||
if not current_user.check_password(request.form.get("current_password", "")):
|
||
flash("当前密码不正确,未撤销受信设备。", "error")
|
||
return redirect(url_for("main.account_security"))
|
||
current_user.revoke_authentication(); TrustedDevice.query.filter_by(user_id=current_user.id).delete(); db.session.commit(); logout_user(); flash("所有受信设备已撤销,请重新登录。", "info"); return redirect(url_for("main.login"))
|
||
if not current_user.check_password(request.form.get("current_password", "")):
|
||
flash("当前密码不正确。", "error"); return redirect(url_for("main.account_security"))
|
||
if action == "change_email":
|
||
new_email = normal_email(request.form.get("new_email", ""))
|
||
if not valid_email(new_email) or User.query.filter_by(email=new_email).first():
|
||
flash("请输入未被使用的有效邮箱地址。", "error"); return redirect(url_for("main.account_security"))
|
||
session.update(pending_email=current_user.email, pending_user_id=current_user.id, pending_purpose="change_email_old", new_email=new_email)
|
||
if reason := issue_email_code(current_user.email, "change_email_old"):
|
||
flash(email_code_issue_message(reason), "error"); return redirect(url_for("main.account_security"))
|
||
return redirect(url_for("main.verify_email", purpose="change_email_old"))
|
||
session.update(pending_email=current_user.email, pending_user_id=current_user.id, pending_purpose="change_password")
|
||
if reason := issue_email_code(current_user.email, "change_password"):
|
||
flash(email_code_issue_message(reason), "error"); return redirect(url_for("main.account_security"))
|
||
return redirect(url_for("main.verify_email", purpose="change_password"))
|
||
|
||
|
||
@bp.route("/account/change-password", methods=["POST"])
|
||
@login_required
|
||
def change_password():
|
||
if not has_fresh_authorization(current_user, "password_change"):
|
||
consume_fresh_authorization("password_change")
|
||
abort(403)
|
||
password, confirm = request.form.get("password", ""), request.form.get("password_confirm", "")
|
||
if not valid_password(password) or password != confirm or current_user.check_password(password): flash(f"{PASSWORD_RULE_MESSAGE} 两次输入必须一致,且不能与当前密码相同。", "error"); return redirect(url_for("main.account_security"))
|
||
current_user.set_password(password); current_user.revoke_authentication(); TrustedDevice.query.filter_by(user_id=current_user.id).delete(); db.session.commit(); consume_fresh_authorization("password_change"); logout_user(); flash("密码已更新,请重新登录。", "info"); return redirect(url_for("main.login"))
|
||
|
||
|
||
@bp.route("/logout", methods=["POST"])
|
||
@login_required
|
||
def logout(): logout_user(); return redirect(url_for("main.login"))
|
||
|
||
@bp.route("/home")
|
||
@login_required
|
||
def home(): return render_template("home.html")
|
||
|
||
@bp.route("/history")
|
||
@login_required
|
||
def history_page():
|
||
pagination, page_redirect = paginated_uploads(UploadRecord.query.filter_by(user_id=current_user.id), "main.history_page")
|
||
return page_redirect or render_template("history.html", pagination=pagination, records=pagination.items)
|
||
|
||
@bp.route("/admin")
|
||
@login_required
|
||
def admin_dashboard():
|
||
require_admin()
|
||
pagination, page_redirect = paginated_uploads(
|
||
UploadRecord.query.options(joinedload(UploadRecord.user)),
|
||
"main.admin_dashboard",
|
||
)
|
||
user_pagination, user_search, user_page_redirect = paginated_users()
|
||
active_device_counts = {
|
||
user.id: sum(
|
||
device.expires_at > utc_now()
|
||
and device.auth_version == user.auth_version
|
||
for device in user.trusted_devices
|
||
)
|
||
for user in user_pagination.items
|
||
}
|
||
if page_redirect or user_page_redirect:
|
||
return page_redirect or user_page_redirect
|
||
|
||
return render_template(
|
||
"admin.html",
|
||
pagination=pagination,
|
||
records=pagination.items,
|
||
user_pagination=user_pagination,
|
||
user_search=user_search,
|
||
active_device_counts=active_device_counts,
|
||
registration_allowed=registration_allowed(),
|
||
)
|
||
|
||
@bp.route("/admin/registration", methods=["POST"])
|
||
@login_required
|
||
def update_registration_setting():
|
||
require_admin(); enabled = request.form.get("allow_registration") == "on"; AppSetting.set_bool(REGISTRATION_SETTING_KEY, enabled); db.session.commit(); flash("已开放用户自助注册" if enabled else "已关闭用户自助注册", "info"); return redirect(url_for("main.admin_dashboard"))
|
||
|
||
@bp.route("/admin/users/<int:user_id>/password-reset", methods=["POST"])
|
||
@login_required
|
||
def admin_password_reset(user_id: int):
|
||
require_admin()
|
||
user = db.session.get(User, user_id)
|
||
if not user or user.is_admin:
|
||
abort(404)
|
||
try:
|
||
send_transactional_email(
|
||
to=user.email,
|
||
subject="【管道健康】密码重置通知",
|
||
html=password_reset_notice_email(
|
||
username=user.username,
|
||
reset_url=url_for("main.forgot_password", _external=True),
|
||
),
|
||
)
|
||
except (EmailConfigurationError, EmailDeliveryError):
|
||
flash("邮件发送失败,请稍后重试。", "error")
|
||
else:
|
||
flash("密码重置通知已发送至用户邮箱。", "info")
|
||
return admin_dashboard_redirect()
|
||
|
||
|
||
@bp.route("/admin/users/<int:user_id>/trusted-devices/revoke", methods=["POST"])
|
||
@login_required
|
||
def admin_revoke_trusted_devices(user_id: int):
|
||
require_admin()
|
||
user = db.session.get(User, user_id)
|
||
if user is None or user.is_admin:
|
||
abort(404)
|
||
user.revoke_authentication()
|
||
TrustedDevice.query.filter_by(user_id=user.id).delete()
|
||
db.session.commit()
|
||
flash(f"已撤销 {user.username} 的所有受信设备。", "info")
|
||
return admin_dashboard_redirect()
|
||
|
||
|
||
@bp.route("/admin/users/<int:user_id>/account-status", methods=["POST"])
|
||
@login_required
|
||
def admin_update_account_status(user_id: int):
|
||
require_admin()
|
||
user = db.session.get(User, user_id)
|
||
if user is None or user.is_admin:
|
||
abort(404)
|
||
user.is_active_account = request.form.get("is_active") == "true"
|
||
user.revoke_authentication()
|
||
TrustedDevice.query.filter_by(user_id=user.id).delete()
|
||
db.session.commit()
|
||
message = "已启用账号" if user.is_active_account else "已停用账号并撤销所有会话"
|
||
flash(f"{user.username}:{message}。", "info")
|
||
return admin_dashboard_redirect()
|
||
|
||
@bp.route("/download/<int:record_id>/<file_type>")
|
||
@login_required
|
||
def download_file(record_id: int, file_type: str):
|
||
record = db.session.get(UploadRecord, record_id)
|
||
if record is None or not (current_user.is_admin or current_user.id == record.user_id): abort(404 if record is None else 403)
|
||
path = {"original": record.saved_path, "prediction": record.prediction_path}.get(file_type)
|
||
if path is None or not os.path.exists(path): abort(404)
|
||
return send_file(path, as_attachment=True)
|
||
|
||
@bp.route("/download_template")
|
||
def download_template(): return send_file(BASE_DIR / "example.xlsx", as_attachment=True, download_name=TEMPLATE_EXCEL_NAME)
|
||
|
||
@bp.route("/reference_pdf")
|
||
@login_required
|
||
def reference_pdf(): return send_file(BASE_DIR / REFERENCE_PDF_NAME, as_attachment=False, download_name=REFERENCE_PDF_NAME, mimetype="application/pdf")
|
||
|
||
@bp.route("/reference")
|
||
@login_required
|
||
def reference_page(): return render_template("reference.html")
|
||
|
||
@bp.route("/result")
|
||
@login_required
|
||
def result_page(): return render_template("result.html", result=session.get("last_result"))
|
||
|
||
@bp.route("/predict", methods=["POST"])
|
||
@login_required
|
||
def predict():
|
||
model = current_app.config.get("RSF_MODEL"); uploaded = request.files.get("file")
|
||
if model is None: return jsonify({"error": "模型未成功加载,请检查模型文件。"}), 500
|
||
if uploaded is None or uploaded.filename == "": return jsonify({"error": "未选择文件"}), 400
|
||
try: artifacts = run_prediction(uploaded, int(current_user.id), model)
|
||
except PredictionError as exc: return jsonify({"error": exc.message}), exc.status_code
|
||
record = UploadRecord(user_id=current_user.id, original_filename=artifacts.original_filename, saved_path=str(artifacts.saved_path), prediction_path=str(artifacts.excel_path), image_path=str(artifacts.image_path)); db.session.add(record); db.session.commit()
|
||
result = {"original_filename": artifacts.original_filename, "generated_at": format_datetime_for_timezone(record.upload_time, current_app.config["APP_TIMEZONE"]), "image_url": url_for("static", filename=f"images/{artifacts.image_filename}"), "excel_url": url_for("main.download_file", record_id=record.id, file_type="prediction"), "result_url": url_for("main.result_page"), "sample_count": int(artifacts.sample_count), "summary_rows": artifacts.summary_rows[:6], "analysis_text": artifacts.analysis_text, "model_version": artifacts.model_version}; session["last_result"] = result
|
||
return jsonify({"message": "预测成功", "image_url": result["image_url"], "excel_url": result["excel_url"], "result_url": result["result_url"], "sample_count": result["sample_count"], "original_filename": artifacts.original_filename, "model_version": result["model_version"]})
|