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//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/") @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//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//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//gift-records/") @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//gift-records//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//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//members/") @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//members//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)