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.
 
 
 
 
 

742 lines
29 KiB

from __future__ import annotations
from datetime import UTC, datetime
from flask import Blueprint, flash, g, redirect, render_template, request, url_for
from app.auth import role_required
from app.extensions import db
from app.models import Account
from app.services import (
ATTENDANCE_STATUS_OPTIONS,
DEFAULT_HOMEPAGE_PER_PAGE,
HOMEPAGE_PER_PAGE_OPTIONS,
INVITE_STATUS_OPTIONS,
SIDE_OPTIONS,
AGE_GROUP_LABELS,
AGE_GROUP_OPTIONS,
GIFT_RECORD_TYPE_LABELS,
GIFT_RECORD_TYPE_OPTIONS,
GENDER_LABELS,
GENDER_OPTIONS,
RELATION_TO_HEAD_OPTIONS,
SORT_FIELD_LABELS,
SORT_FIELD_OPTIONS,
SORT_ORDER_OPTIONS,
build_new_gift_record_draft,
build_new_household_draft,
build_new_member_draft,
ensure_relation_category_option,
ensure_tag_options,
get_gift_record_or_none,
get_filtered_homepage_stats,
get_homepage_stats,
get_household_or_none,
get_member_or_none,
gift_record_type_label,
household_has_active_gift_records,
is_head_name_taken,
is_household_code_taken,
list_enabled_options,
list_gift_records,
list_members,
paginate_households_for_homepage,
parse_admin_form,
parse_new_relation_category_label,
parse_new_tag_labels,
parse_gift_record_form,
parse_member_form,
recalculate_household_gift_summary,
serialize_admin_edit_snapshot,
serialize_gift_record_snapshot,
serialize_member_snapshot,
write_audit_log,
)
main_bp = Blueprint("main", __name__)
def _admin_index_context() -> dict[str, object]:
search_term = request.args.get("q", "").strip()
side = request.args.get("side", "").strip()
invite_status = request.args.get("invite_status", "").strip()
attendance_status = request.args.get("attendance_status", "").strip()
tag_option_id = request.args.get("tag_option_id", type=int)
sort_by = request.args.get("sort_by", "updated_at").strip()
sort_order = request.args.get("sort_order", "desc").strip()
page = request.args.get("page", type=int) or 1
per_page = request.args.get("per_page", type=int) or DEFAULT_HOMEPAGE_PER_PAGE
if sort_by not in SORT_FIELD_OPTIONS:
sort_by = "updated_at"
if sort_order not in SORT_ORDER_OPTIONS:
sort_order = "desc"
if per_page not in HOMEPAGE_PER_PAGE_OPTIONS:
per_page = DEFAULT_HOMEPAGE_PER_PAGE
tag_options = list_enabled_options("tag")
if tag_option_id not in {option.id for option in tag_options}:
tag_option_id = None
paginated_households = paginate_households_for_homepage(
search_term=search_term,
side=side,
invite_status=invite_status,
attendance_status=attendance_status,
tag_option_id=tag_option_id,
sort_by=sort_by,
sort_order=sort_order,
page=page,
per_page=per_page,
)
stats = get_homepage_stats()
filtered_stats = get_filtered_homepage_stats(paginated_households.filtered_households)
return {
"households": paginated_households.items,
"search_term": search_term,
"side": side,
"invite_status": invite_status,
"attendance_status": attendance_status,
"tag_option_id": tag_option_id,
"tag_options": tag_options,
"sort_by": sort_by,
"sort_order": sort_order,
"page": paginated_households.page,
"per_page": paginated_households.per_page,
"per_page_options": HOMEPAGE_PER_PAGE_OPTIONS,
"total_pages": paginated_households.total_pages,
"has_prev": paginated_households.has_prev,
"has_next": paginated_households.has_next,
"page_start": paginated_households.page_start,
"page_end": paginated_households.page_end,
"side_options": SIDE_OPTIONS,
"invite_status_options": INVITE_STATUS_OPTIONS,
"attendance_status_options": ATTENDANCE_STATUS_OPTIONS,
"sort_field_options": SORT_FIELD_OPTIONS,
"sort_field_labels": SORT_FIELD_LABELS,
"stats": stats,
"filtered_stats": filtered_stats,
"filtered_count": paginated_households.filtered_count,
}
def _is_partial_request(expected_partial: str) -> bool:
requested_partial = request.args.get("partial", "").strip()
header_partial = request.headers.get("X-HW-Partial", "").strip()
return requested_partial == expected_partial or header_partial == expected_partial
def _admin_index_redirect_from_values(values) -> str:
params: dict[str, object] = {}
for key in ("q", "side", "invite_status", "attendance_status", "sort_by", "sort_order"):
raw_value = values.get(key, "")
normalized = raw_value.strip() if isinstance(raw_value, str) else raw_value
if normalized:
params[key] = normalized
for key in ("tag_option_id", "page", "per_page"):
raw_value = values.get(key, "")
normalized = raw_value.strip() if isinstance(raw_value, str) else raw_value
if normalized not in ("", None):
params[key] = normalized
return url_for("main.index", **params)
@main_bp.get("/")
@role_required("admin", "editor")
def index() -> str:
context = _admin_index_context()
if _is_partial_request("results"):
return render_template("main/_household_results.html", **context)
return render_template("main/index.html", **context)
@main_bp.get("/households/new")
@role_required("admin", "editor")
def new_household() -> str:
return render_template(
"main/household_edit.html",
household=build_new_household_draft(),
gift_records=[],
relation_category_options=list_enabled_options("relation_category"),
relation_detail_options=list_enabled_options("relation_detail"),
tag_options=list_enabled_options("tag"),
gift_method_options=list_enabled_options("gift_method"),
gift_scene_options=list_enabled_options("gift_scene"),
is_create_mode=True,
form_title="新增一户",
form_intro="只需填写户主姓名即可创建,户编码会自动生成;创建成功后会自动进入编辑页继续补充。",
submit_label="创建户信息",
)
@main_bp.post("/households")
@role_required("admin", "editor")
def create_household():
redirect_target = url_for("main.new_household")
relation_category_options = list_enabled_options("relation_category")
tag_options = list_enabled_options("tag")
gift_method_options = list_enabled_options("gift_method")
gift_scene_options = list_enabled_options("gift_scene")
payload, errors = parse_admin_form(
dict(request.form),
raw_tag_option_ids=request.form.getlist("tag_option_ids_json"),
valid_relation_category_ids={option.id for option in relation_category_options},
relation_detail_parent_map={},
valid_tag_ids={option.id for option in tag_options},
valid_method_ids={option.id for option in gift_method_options},
valid_scene_ids={option.id for option in gift_scene_options},
require_household_code=False,
)
if errors:
for error in errors:
flash(error, "error")
return redirect(redirect_target)
new_relation_category_label, relation_category_errors = parse_new_relation_category_label(
request.form.get("new_relation_category_label", ""),
)
if relation_category_errors:
for error in relation_category_errors:
flash(error, "error")
return redirect(redirect_target)
new_tag_labels, tag_label_errors = parse_new_tag_labels(request.form.get("new_tag_labels", ""))
if tag_label_errors:
for error in tag_label_errors:
flash(error, "error")
return redirect(redirect_target)
head_name = str(payload["head_name"])
if is_head_name_taken(head_name):
flash("户主姓名已存在,请修改后重试。", "error")
return redirect(redirect_target)
household_code = payload.get("household_code")
normalized_household_code = str(household_code) if household_code else None
if normalized_household_code and is_household_code_taken(normalized_household_code):
flash("户编码已存在,请换一个新的户编码。", "error")
return redirect(redirect_target)
current_account = g.current_account
created_relation_category = ensure_relation_category_option(
new_relation_category_label,
actor_id=current_account.id if isinstance(current_account, Account) else None,
)
if created_relation_category is not None:
payload["relation_category_option_id"] = created_relation_category.id
payload["relation_detail_option_id"] = None
created_tag_options = ensure_tag_options(
new_tag_labels,
actor_id=current_account.id if isinstance(current_account, Account) else None,
)
if created_tag_options:
existing_ids = set(payload["tag_option_ids_json"]) if isinstance(payload["tag_option_ids_json"], list) else set()
payload["tag_option_ids_json"] = sorted(existing_ids | {option.id for option in created_tag_options})
household = build_new_household_draft(household_code=normalized_household_code)
for field_name, value in payload.items():
if field_name == "household_code" and value is None:
continue
setattr(household, field_name, value)
if isinstance(current_account, Account):
household.created_by = current_account.id
household.updated_by = current_account.id
db.session.add(household)
db.session.flush()
write_audit_log(
action_type="create_household",
target_type="household",
actor=current_account if isinstance(current_account, Account) else None,
target_id=household.id,
target_display_name=household.head_name,
after_data=serialize_admin_edit_snapshot(household),
commit=False,
)
db.session.commit()
flash(f"已创建户 {household.head_name}{household.household_code})。", "success")
return redirect(url_for("main.edit_household", household_id=household.id))
@main_bp.get("/households/<int:household_id>/edit")
@role_required("admin", "editor")
def edit_household(household_id: int):
household = get_household_or_none(household_id)
if household is None:
flash("未找到要编辑的户信息。", "error")
return redirect(url_for("main.index"))
members = list_members(household_id)
gift_records = list_gift_records(household_id)
new_gift_record = build_new_gift_record_draft(household_id)
return render_template(
"main/household_edit.html",
household=household,
members=members,
gift_records=gift_records,
new_gift_record=new_gift_record,
new_member=build_new_member_draft(household_id),
relation_category_options=list_enabled_options("relation_category"),
relation_detail_options=list_enabled_options("relation_detail"),
tag_options=list_enabled_options("tag"),
gift_method_options=list_enabled_options("gift_method"),
gift_scene_options=list_enabled_options("gift_scene"),
gift_record_type_options=GIFT_RECORD_TYPE_OPTIONS,
gift_record_type_labels=GIFT_RECORD_TYPE_LABELS,
gift_record_type_label=gift_record_type_label,
gender_options=GENDER_OPTIONS,
gender_labels=GENDER_LABELS,
age_group_options=AGE_GROUP_OPTIONS,
age_group_labels=AGE_GROUP_LABELS,
relation_to_head_options=RELATION_TO_HEAD_OPTIONS,
)
@main_bp.post("/households/<int:household_id>")
@role_required("admin", "editor")
def update_household(household_id: int):
household = get_household_or_none(household_id)
if household is None:
flash("未找到要编辑的户信息。", "error")
return redirect(url_for("main.index"))
redirect_target = url_for("main.edit_household", household_id=household_id)
submitted_version = request.form.get("version", "").strip()
try:
version = int(submitted_version)
except ValueError:
flash("表单版本无效,请重新打开该户信息后再试。", "error")
return redirect(redirect_target)
if version != household.version:
flash("该户信息已被其他人更新,请刷新后重新确认再保存。", "warning")
return redirect(redirect_target)
relation_category_options = list_enabled_options("relation_category")
tag_options = list_enabled_options("tag")
gift_method_options = list_enabled_options("gift_method")
gift_scene_options = list_enabled_options("gift_scene")
payload, errors = parse_admin_form(
dict(request.form),
raw_tag_option_ids=request.form.getlist("tag_option_ids_json"),
valid_relation_category_ids={option.id for option in relation_category_options},
relation_detail_parent_map={},
valid_tag_ids={option.id for option in tag_options},
valid_method_ids={option.id for option in gift_method_options},
valid_scene_ids={option.id for option in gift_scene_options},
)
if errors:
for error in errors:
flash(error, "error")
return redirect(redirect_target)
new_relation_category_label, relation_category_errors = parse_new_relation_category_label(
request.form.get("new_relation_category_label", ""),
)
if relation_category_errors:
for error in relation_category_errors:
flash(error, "error")
return redirect(redirect_target)
new_tag_labels, tag_label_errors = parse_new_tag_labels(request.form.get("new_tag_labels", ""))
if tag_label_errors:
for error in tag_label_errors:
flash(error, "error")
return redirect(redirect_target)
if household_has_active_gift_records(household_id):
payload.pop("total_gift_amount", None)
payload.pop("gift_method_option_id", None)
payload.pop("gift_scene_option_id", None)
head_name = str(payload["head_name"])
if is_head_name_taken(head_name, excluding_household_id=household_id):
flash("户主姓名已存在,请修改后重试。", "error")
return redirect(redirect_target)
household_code = payload.get("household_code")
normalized_household_code = str(household_code) if household_code else None
if normalized_household_code and is_household_code_taken(normalized_household_code, excluding_household_id=household_id):
flash("户编码已存在,请换一个新的户编码。", "error")
return redirect(redirect_target)
before_snapshot = serialize_admin_edit_snapshot(household)
current_account = g.current_account
created_relation_category = ensure_relation_category_option(
new_relation_category_label,
actor_id=current_account.id if isinstance(current_account, Account) else None,
)
if created_relation_category is not None:
payload["relation_category_option_id"] = created_relation_category.id
payload["relation_detail_option_id"] = None
created_tag_options = ensure_tag_options(
new_tag_labels,
actor_id=current_account.id if isinstance(current_account, Account) else None,
)
if created_tag_options:
existing_ids = set(payload["tag_option_ids_json"]) if isinstance(payload["tag_option_ids_json"], list) else set()
payload["tag_option_ids_json"] = sorted(existing_ids | {option.id for option in created_tag_options})
for field_name, value in payload.items():
setattr(household, field_name, value)
if isinstance(current_account, Account):
household.updated_by = current_account.id
if household_has_active_gift_records(household_id):
recalculate_household_gift_summary(household)
if serialize_admin_edit_snapshot(household) == before_snapshot:
flash("没有检测到需要保存的变化。", "warning")
db.session.rollback()
return redirect(redirect_target)
household.version += 1
after_snapshot = serialize_admin_edit_snapshot(household)
write_audit_log(
action_type="update_household",
target_type="household",
actor=current_account if isinstance(current_account, Account) else None,
target_id=household.id,
target_display_name=household.head_name,
before_data=before_snapshot,
after_data=after_snapshot,
commit=False,
)
db.session.commit()
flash(f"已保存 {household.head_name} 的户级修改。", "success")
return redirect(redirect_target)
@main_bp.post("/households/<int:household_id>/delete")
@role_required("admin", "editor")
def delete_household(household_id: int):
household = get_household_or_none(household_id)
if household is None:
flash("未找到要删除的户信息。", "error")
return redirect(url_for("main.index"))
redirect_target = _admin_index_redirect_from_values(request.form)
confirm = request.form.get("confirm", "").strip()
if confirm != "yes":
flash("请确认删除户操作。", "warning")
return redirect(redirect_target)
before_snapshot = serialize_admin_edit_snapshot(household)
current_account = g.current_account
if isinstance(current_account, Account):
household.updated_by = current_account.id
household.deleted_at = datetime.now(UTC).replace(tzinfo=None)
household.version += 1
write_audit_log(
action_type="delete_household",
target_type="household",
actor=current_account if isinstance(current_account, Account) else None,
target_id=household.id,
target_display_name=household.head_name,
before_data=before_snapshot,
after_data=serialize_admin_edit_snapshot(household),
commit=False,
)
db.session.commit()
flash(f"已删除户 {household.head_name}", "success")
return redirect(redirect_target)
@main_bp.post("/households/<int:household_id>/gift-records")
@role_required("admin", "editor")
def create_gift_record(household_id: int):
household = get_household_or_none(household_id)
if household is None:
flash("未找到要补录礼金明细的户信息。", "error")
return redirect(url_for("main.index"))
redirect_target = url_for("main.edit_household", household_id=household_id)
gift_method_options = list_enabled_options("gift_method")
gift_scene_options = list_enabled_options("gift_scene")
payload, errors = parse_gift_record_form(
dict(request.form),
household_id=household_id,
valid_method_ids={option.id for option in gift_method_options},
valid_scene_ids={option.id for option in gift_scene_options},
)
if errors:
for error in errors:
flash(error, "error")
return redirect(redirect_target)
record = build_new_gift_record_draft(household_id)
for field_name, value in payload.items():
setattr(record, field_name, value)
current_account = g.current_account
if isinstance(current_account, Account):
record.created_by = current_account.id
record.updated_by = current_account.id
household.updated_by = current_account.id
db.session.add(record)
db.session.flush()
recalculate_household_gift_summary(household)
household.version += 1
write_audit_log(
action_type="create_gift_record",
target_type="gift_record",
actor=current_account if isinstance(current_account, Account) else None,
target_id=record.id,
target_display_name=f"{household.head_name} - {gift_record_type_label(record.record_type)}",
after_data=serialize_gift_record_snapshot(record),
commit=False,
)
db.session.commit()
flash("已新增礼金明细。", "success")
return redirect(redirect_target)
@main_bp.post("/households/<int:household_id>/gift-records/<int:record_id>")
@role_required("admin", "editor")
def update_gift_record(household_id: int, record_id: int):
household = get_household_or_none(household_id)
if household is None:
flash("未找到要编辑礼金明细的户信息。", "error")
return redirect(url_for("main.index"))
record = get_gift_record_or_none(record_id)
if record is None or record.household_id != household_id:
flash("未找到要编辑的礼金明细。", "error")
return redirect(url_for("main.edit_household", household_id=household_id))
redirect_target = url_for("main.edit_household", household_id=household_id)
gift_method_options = list_enabled_options("gift_method")
gift_scene_options = list_enabled_options("gift_scene")
payload, errors = parse_gift_record_form(
dict(request.form),
household_id=household_id,
valid_method_ids={option.id for option in gift_method_options},
valid_scene_ids={option.id for option in gift_scene_options},
)
if errors:
for error in errors:
flash(error, "error")
return redirect(redirect_target)
before_snapshot = serialize_gift_record_snapshot(record)
for field_name, value in payload.items():
setattr(record, field_name, value)
current_account = g.current_account
if isinstance(current_account, Account):
record.updated_by = current_account.id
household.updated_by = current_account.id
after_snapshot = serialize_gift_record_snapshot(record)
if after_snapshot == before_snapshot:
flash("没有检测到需要保存的礼金明细变更。", "warning")
db.session.rollback()
return redirect(redirect_target)
recalculate_household_gift_summary(household)
household.version += 1
write_audit_log(
action_type="update_gift_record",
target_type="gift_record",
actor=current_account if isinstance(current_account, Account) else None,
target_id=record.id,
target_display_name=f"{household.head_name} - {gift_record_type_label(record.record_type)}",
before_data=before_snapshot,
after_data=after_snapshot,
commit=False,
)
db.session.commit()
flash("已更新礼金明细。", "success")
return redirect(redirect_target)
@main_bp.post("/households/<int:household_id>/gift-records/<int:record_id>/delete")
@role_required("admin", "editor")
def delete_gift_record(household_id: int, record_id: int):
household = get_household_or_none(household_id)
if household is None:
flash("未找到要删除礼金明细的户信息。", "error")
return redirect(url_for("main.index"))
record = get_gift_record_or_none(record_id)
if record is None or record.household_id != household_id:
flash("未找到要删除的礼金明细。", "error")
return redirect(url_for("main.edit_household", household_id=household_id))
redirect_target = url_for("main.edit_household", household_id=household_id)
confirm = request.form.get("confirm", "").strip()
if confirm != "yes":
flash("请确认删除礼金明细操作。", "warning")
return redirect(redirect_target)
before_snapshot = serialize_gift_record_snapshot(record)
current_account = g.current_account
if isinstance(current_account, Account):
record.updated_by = current_account.id
household.updated_by = current_account.id
record.deleted_at = datetime.now(UTC).replace(tzinfo=None)
recalculate_household_gift_summary(household)
household.version += 1
write_audit_log(
action_type="delete_gift_record",
target_type="gift_record",
actor=current_account if isinstance(current_account, Account) else None,
target_id=record.id,
target_display_name=f"{household.head_name} - {gift_record_type_label(record.record_type)}",
before_data=before_snapshot,
after_data=serialize_gift_record_snapshot(record),
commit=False,
)
db.session.commit()
flash("已删除礼金明细。", "success")
return redirect(redirect_target)
@main_bp.post("/households/<int:household_id>/members")
@role_required("admin", "editor")
def create_member(household_id: int):
"""创建新成员。"""
household = get_household_or_none(household_id)
if household is None:
flash("未找到要添加成员的户信息。", "error")
return redirect(url_for("main.index"))
payload, errors = parse_member_form(dict(request.form), household_id=household_id)
if errors:
for error in errors:
flash(error, "error")
return redirect(url_for("main.edit_household", household_id=household_id))
member = build_new_member_draft(household_id)
for field_name, value in payload.items():
setattr(member, field_name, value)
current_account = g.current_account
if isinstance(current_account, Account):
member.created_by = current_account.id
member.updated_by = current_account.id
db.session.add(member)
db.session.flush()
write_audit_log(
action_type="create_household_member",
target_type="household_member",
actor=current_account if isinstance(current_account, Account) else None,
target_id=member.id,
target_display_name=f"{household.head_name} - {member.name}",
after_data=serialize_member_snapshot(member),
commit=False,
)
db.session.commit()
flash(f"已添加成员 {member.name}", "success")
return redirect(url_for("main.edit_household", household_id=household_id))
@main_bp.post("/households/<int:household_id>/members/<int:member_id>")
@role_required("admin", "editor")
def update_member(household_id: int, member_id: int):
"""更新成员信息。"""
household = get_household_or_none(household_id)
if household is None:
flash("未找到要编辑成员的户信息。", "error")
return redirect(url_for("main.index"))
member = get_member_or_none(member_id)
if member is None or member.household_id != household_id:
flash("未找到要编辑的成员信息。", "error")
return redirect(url_for("main.edit_household", household_id=household_id))
redirect_target = url_for("main.edit_household", household_id=household_id)
payload, errors = parse_member_form(dict(request.form), household_id=household_id)
if errors:
for error in errors:
flash(error, "error")
return redirect(redirect_target)
before_snapshot = serialize_member_snapshot(member)
for field_name, value in payload.items():
setattr(member, field_name, value)
current_account = g.current_account
if isinstance(current_account, Account):
member.updated_by = current_account.id
after_snapshot = serialize_member_snapshot(member)
if after_snapshot == before_snapshot:
flash("没有检测到需要保存的成员变化。", "warning")
db.session.rollback()
return redirect(redirect_target)
write_audit_log(
action_type="update_household_member",
target_type="household_member",
actor=current_account if isinstance(current_account, Account) else None,
target_id=member.id,
target_display_name=f"{household.head_name} - {member.name}",
before_data=before_snapshot,
after_data=after_snapshot,
commit=False,
)
db.session.commit()
flash(f"已更新成员 {member.name} 的信息。", "success")
return redirect(redirect_target)
@main_bp.post("/households/<int:household_id>/members/<int:member_id>/delete")
@role_required("admin", "editor")
def delete_member(household_id: int, member_id: int):
"""删除成员。"""
household = get_household_or_none(household_id)
if household is None:
flash("未找到要删除成员的户信息。", "error")
return redirect(url_for("main.index"))
member = get_member_or_none(member_id)
if member is None or member.household_id != household_id:
flash("未找到要删除的成员信息。", "error")
return redirect(url_for("main.edit_household", household_id=household_id))
redirect_target = url_for("main.edit_household", household_id=household_id)
# 确认删除
confirm = request.form.get("confirm", "").strip()
if confirm != "yes":
flash("请确认删除操作。", "warning")
return redirect(redirect_target)
before_snapshot = serialize_member_snapshot(member)
member_name = member.name
current_account = g.current_account
if isinstance(current_account, Account):
member.updated_by = current_account.id
db.session.delete(member)
write_audit_log(
action_type="delete_household_member",
target_type="household_member",
actor=current_account if isinstance(current_account, Account) else None,
target_id=member_id,
target_display_name=f"{household.head_name} - {member_name}",
before_data=before_snapshot,
after_data={"deleted": True},
commit=False,
)
db.session.commit()
flash(f"已删除成员 {member_name}", "success")
return redirect(redirect_target)