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
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"))
|
|
|