You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

399 lines
14 KiB

from __future__ import annotations
from flask import Blueprint, flash, g, redirect, render_template, request, url_for
from flask.typing import ResponseReturnValue
from app.auth import role_required
from app.extensions import db
from app.models import Account
from app.services import (
ACCOUNT_ROLE_OPTIONS,
DEFAULT_SHARE_LINK_EXPIRE_DAYS,
build_share_link_url,
create_share_link,
get_account_or_none,
get_audit_log_or_none,
get_share_link_or_none,
is_username_taken,
list_accounts,
list_action_types,
list_audit_logs,
list_share_links,
parse_account_form,
parse_password_reset_form,
parse_share_link_create_form,
revoke_share_link,
serialize_account_snapshot,
write_audit_log,
)
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
def _share_link_rows() -> list[dict[str, object]]:
rows: list[dict[str, object]] = []
for link in list_share_links():
rows.append(
{
"link": link,
"public_url": build_share_link_url(link.token),
"status_label": "已撤销" if link.revoked_at else ("已过期" if link.is_expired() else "有效"),
}
)
return rows
@admin_bp.get("/accounts")
@role_required("admin")
def accounts() -> ResponseReturnValue:
return render_template(
"admin/accounts.html",
accounts=list_accounts(),
role_options=ACCOUNT_ROLE_OPTIONS,
)
@admin_bp.route("/accounts/new", methods=["GET", "POST"])
@role_required("admin")
def create_account() -> ResponseReturnValue:
form_data = {
"username": "",
"display_name": "",
"role": "entry_only",
"password": "",
"confirm_password": "",
}
if request.method == "POST":
form_data = {
"username": request.form.get("username", "").strip(),
"display_name": request.form.get("display_name", "").strip(),
"role": request.form.get("role", "").strip(),
"password": request.form.get("password", ""),
"confirm_password": request.form.get("confirm_password", ""),
}
payload, errors = parse_account_form(dict(request.form), is_create=True)
if is_username_taken(form_data["username"]):
errors.append("账号名已存在,请换一个新的账号名。")
if errors:
for error in errors:
flash(error, "error")
return render_template(
"admin/account_form.html",
account=None,
form_title="新增账号",
submit_label="创建账号",
cancel_url=url_for("admin.accounts"),
role_options=ACCOUNT_ROLE_OPTIONS,
form_data=form_data,
is_create=True,
), 400
account = Account(
username=str(payload["username"]),
display_name=payload["display_name"] if isinstance(payload["display_name"], str) else None,
role=str(payload["role"]),
status="active",
)
account.set_password(str(payload["password"]))
current_account = g.current_account
if isinstance(current_account, Account):
account.created_by = current_account.id
account.updated_by = current_account.id
db.session.add(account)
db.session.flush()
write_audit_log(
action_type="create_account",
target_type="account",
actor=current_account if isinstance(current_account, Account) else None,
target_id=account.id,
target_display_name=account.display_name or account.username,
after_data=serialize_account_snapshot(account),
commit=False,
)
db.session.commit()
flash(f"已创建账号 {account.username}", "success")
return redirect(url_for("admin.accounts"))
return render_template(
"admin/account_form.html",
account=None,
form_title="新增账号",
submit_label="创建账号",
cancel_url=url_for("admin.accounts"),
role_options=ACCOUNT_ROLE_OPTIONS,
form_data=form_data,
is_create=True,
)
@admin_bp.route("/accounts/<int:account_id>/edit", methods=["GET", "POST"])
@role_required("admin")
def edit_account(account_id: int) -> ResponseReturnValue:
account = get_account_or_none(account_id)
if account is None:
flash("未找到要编辑的账号。", "error")
return redirect(url_for("admin.accounts"))
form_data = {
"username": account.username,
"display_name": account.display_name or "",
"role": account.role,
}
if request.method == "POST":
form_data = {
"username": account.username,
"display_name": request.form.get("display_name", "").strip(),
"role": request.form.get("role", "").strip(),
}
payload, errors = parse_account_form(dict(request.form), is_create=False)
if errors:
for error in errors:
flash(error, "error")
return render_template(
"admin/account_form.html",
account=account,
form_title="编辑账号",
submit_label="保存账号信息",
cancel_url=url_for("admin.accounts"),
role_options=ACCOUNT_ROLE_OPTIONS,
form_data=form_data,
is_create=False,
), 400
before_snapshot = serialize_account_snapshot(account)
account.display_name = payload["display_name"] if isinstance(payload["display_name"], str) else None
account.role = str(payload["role"])
current_account = g.current_account
if isinstance(current_account, Account):
account.updated_by = current_account.id
after_snapshot = serialize_account_snapshot(account)
if after_snapshot == before_snapshot:
flash("没有检测到需要保存的账号变更。", "warning")
db.session.rollback()
return redirect(url_for("admin.edit_account", account_id=account.id))
write_audit_log(
action_type="update_account",
target_type="account",
actor=current_account if isinstance(current_account, Account) else None,
target_id=account.id,
target_display_name=account.display_name or account.username,
before_data=before_snapshot,
after_data=after_snapshot,
commit=False,
)
db.session.commit()
flash(f"已更新账号 {account.username}", "success")
return redirect(url_for("admin.accounts"))
return render_template(
"admin/account_form.html",
account=account,
form_title="编辑账号",
submit_label="保存账号信息",
cancel_url=url_for("admin.accounts"),
role_options=ACCOUNT_ROLE_OPTIONS,
form_data=form_data,
is_create=False,
)
@admin_bp.route("/accounts/<int:account_id>/reset-password", methods=["GET", "POST"])
@role_required("admin")
def reset_account_password(account_id: int) -> ResponseReturnValue:
account = get_account_or_none(account_id)
if account is None:
flash("未找到要重置密码的账号。", "error")
return redirect(url_for("admin.accounts"))
if request.method == "POST":
password, errors = parse_password_reset_form(dict(request.form))
if errors:
for error in errors:
flash(error, "error")
return render_template(
"admin/account_reset_password.html",
account=account,
cancel_url=url_for("admin.accounts"),
), 400
before_snapshot = serialize_account_snapshot(account)
account.set_password(str(password))
current_account = g.current_account
if isinstance(current_account, Account):
account.updated_by = current_account.id
write_audit_log(
action_type="reset_password",
target_type="account",
actor=current_account if isinstance(current_account, Account) else None,
target_id=account.id,
target_display_name=account.display_name or account.username,
before_data=before_snapshot,
after_data=serialize_account_snapshot(account),
commit=False,
)
db.session.commit()
flash(f"已重置账号 {account.username} 的密码。", "success")
return redirect(url_for("admin.accounts"))
return render_template(
"admin/account_reset_password.html",
account=account,
cancel_url=url_for("admin.accounts"),
)
@admin_bp.post("/accounts/<int:account_id>/toggle-status")
@role_required("admin")
def toggle_account_status(account_id: int):
account = get_account_or_none(account_id)
if account is None:
flash("未找到要变更状态的账号。", "error")
return redirect(url_for("admin.accounts"))
current_account = g.current_account
if isinstance(current_account, Account) and current_account.id == account.id and account.status == "active":
flash("不能停用当前正在使用的管理员账号。", "error")
return redirect(url_for("admin.accounts"))
before_snapshot = serialize_account_snapshot(account)
account.status = "disabled" if account.status == "active" else "active"
if isinstance(current_account, Account):
account.updated_by = current_account.id
write_audit_log(
action_type="toggle_account_status",
target_type="account",
actor=current_account if isinstance(current_account, Account) else None,
target_id=account.id,
target_display_name=account.display_name or account.username,
before_data=before_snapshot,
after_data=serialize_account_snapshot(account),
commit=False,
)
db.session.commit()
flash(f"已将账号 {account.username} 设为{'停用' if account.status == 'disabled' else '启用'}", "success")
return redirect(url_for("admin.accounts"))
@admin_bp.get("/audit-logs")
@role_required("admin")
def audit_logs() -> ResponseReturnValue:
actor_user_id = request.args.get("actor_user_id", type=int)
action_type = request.args.get("action_type", "").strip()
from_date = request.args.get("from_date", "").strip()
to_date = request.args.get("to_date", "").strip()
return render_template(
"admin/audit_logs.html",
logs=list_audit_logs(
actor_user_id=actor_user_id,
action_type=action_type,
from_date=from_date,
to_date=to_date,
),
accounts=list_accounts(),
action_types=list_action_types(),
selected_actor_user_id=actor_user_id,
selected_action_type=action_type,
from_date=from_date,
to_date=to_date,
)
@admin_bp.get("/audit-logs/<int:log_id>")
@role_required("admin")
def audit_log_detail(log_id: int) -> ResponseReturnValue:
log = get_audit_log_or_none(log_id)
if log is None:
flash("未找到要查看的审计日志。", "error")
return redirect(url_for("admin.audit_logs"))
return render_template("admin/audit_log_detail.html", log=log)
@admin_bp.route("/share-links", methods=["GET", "POST"])
@role_required("admin")
def share_links() -> ResponseReturnValue:
if request.method == "POST":
payload, errors = parse_share_link_create_form(dict(request.form))
if errors:
for error in errors:
flash(error, "error")
return render_template(
"admin/share_links.html",
link_rows=_share_link_rows(),
default_expire_days=DEFAULT_SHARE_LINK_EXPIRE_DAYS,
form_data={
"label": request.form.get("label", "").strip(),
"expire_days": request.form.get("expire_days", str(DEFAULT_SHARE_LINK_EXPIRE_DAYS)).strip(),
},
), 400
current_account = g.current_account
actor = current_account if isinstance(current_account, Account) else None
label = payload.get("label")
expire_days = payload.get("expire_days")
link = create_share_link(
created_by=actor.id if actor is not None else None,
label=label if isinstance(label, str) else None,
expire_days=expire_days if isinstance(expire_days, int) else DEFAULT_SHARE_LINK_EXPIRE_DAYS,
)
write_audit_log(
action_type="create_share_link",
target_type="share_link",
actor=actor,
target_id=link.id,
target_display_name=build_share_link_url(link.token),
after_data={
"token": link.token,
"label": link.label,
"expires_at": link.expires_at.isoformat(),
},
commit=False,
)
db.session.commit()
flash("已创建分享链接。", "success")
return redirect(url_for("admin.share_links"))
return render_template(
"admin/share_links.html",
link_rows=_share_link_rows(),
default_expire_days=DEFAULT_SHARE_LINK_EXPIRE_DAYS,
form_data={"label": "", "expire_days": str(DEFAULT_SHARE_LINK_EXPIRE_DAYS)},
)
@admin_bp.post("/share-links/<int:link_id>/revoke")
@role_required("admin")
def revoke_share_link_action(link_id: int) -> ResponseReturnValue:
link = get_share_link_or_none(link_id)
if link is None:
flash("未找到要撤销的分享链接。", "error")
return redirect(url_for("admin.share_links"))
updated = revoke_share_link(link)
if not updated:
flash("该分享链接已是撤销状态。", "warning")
return redirect(url_for("admin.share_links"))
current_account = g.current_account
actor = current_account if isinstance(current_account, Account) else None
write_audit_log(
action_type="revoke_share_link",
target_type="share_link",
actor=actor,
target_id=link.id,
target_display_name=build_share_link_url(link.token),
after_data={"revoked_at": link.revoked_at.isoformat() if link.revoked_at else None},
commit=False,
)
db.session.commit()
flash("分享链接已撤销。", "success")
return redirect(url_for("admin.share_links"))