From 90758c0487a14582d5768e865c10a95d3448cad8 Mon Sep 17 00:00:00 2001 From: Rain Mark Date: Tue, 14 Apr 2026 20:05:12 +0800 Subject: [PATCH] Refine quick entry roles and filters --- app/auth.py | 4 +- app/routes/quick_entry.py | 69 +++++++++++++++---- app/services/accounts.py | 2 +- app/services/demo_seed.py | 7 ++ app/services/households.py | 2 + app/templates/base.html | 2 +- app/templates/quick_entry/_edit_form.html | 14 ++++ app/templates/quick_entry/_edit_modal.html | 2 +- .../quick_entry/_search_results.html | 6 +- app/templates/quick_entry/edit.html | 6 +- app/templates/quick_entry/index.html | 15 +++- app/templates/quick_entry/new.html | 6 +- tests/e2e/conftest.py | 5 ++ tests/e2e/test_auth_flow.py | 23 +++++++ tests/e2e/test_entry_flow.py | 53 +++++++++++++- tests/e2e/test_share_flow.py | 3 +- tests/test_admin_pages.py | 12 ++++ tests/test_auth.py | 43 ++++++++++++ tests/test_cli_seed.py | 7 +- tests/test_household_pages.py | 52 +++++++++++++- 20 files changed, 296 insertions(+), 37 deletions(-) diff --git a/app/auth.py b/app/auth.py index 19dfb32..703150b 100644 --- a/app/auth.py +++ b/app/auth.py @@ -18,7 +18,7 @@ from app.models import Account from app.services import account_snapshot, write_audit_log SESSION_ACCOUNT_ID_KEY = "account_id" -ALLOWED_ACCOUNT_ROLES = {"admin", "editor", "entry_only"} +ALLOWED_ACCOUNT_ROLES = {"admin", "editor", "entry_only", "quick_editor"} ACTIVE_ACCOUNT_STATUS = "active" P = ParamSpec("P") @@ -132,7 +132,7 @@ def role_required(*roles: str) -> Callable[[Callable[P, R]], Callable[P, R | Res def default_redirect_path(account: Account) -> str: - if account.role == "entry_only": + if account.role in {"entry_only", "quick_editor"}: return url_for("quick_entry.index") return url_for("main.index") diff --git a/app/routes/quick_entry.py b/app/routes/quick_entry.py index f69f7f7..3b25e06 100644 --- a/app/routes/quick_entry.py +++ b/app/routes/quick_entry.py @@ -23,6 +23,27 @@ from app.services import ( quick_entry_bp = Blueprint("quick_entry", __name__, url_prefix="/quick-entry") +def _include_bride_side_param(source: object) -> bool: + if not hasattr(source, "get"): + return False + + value = getattr(source, "get")("include_bride_side", "") + if not isinstance(value, str): + return False + + return value.strip() == "1" + + +def _quick_entry_search_url(*, search_term: str = "", include_bride_side: bool = False) -> str: + params: dict[str, str] = {} + normalized_search_term = search_term.strip() + if normalized_search_term: + params["q"] = normalized_search_term + if include_bride_side: + params["include_bride_side"] = "1" + return url_for("quick_entry.index", **params) + + def _option_id_by_code(options: list[object], code: str) -> int | None: for option in options: if getattr(option, "option_code", None) == code: @@ -34,13 +55,17 @@ def _option_id_by_code(options: list[object], code: str) -> int | None: def _quick_entry_index_context() -> dict[str, object]: search_term = request.args.get("q", "").strip() + include_bride_side = _include_bride_side_param(request.args) households = list_households(search_term=search_term, limit=20) if normalize_search_term(search_term) else [] + if not include_bride_side: + households = [household for household in households if household.side != "bride_side"] households = sorted( households, key=lambda household: household.total_gift_amount > Decimal("0.00"), ) return { "search_term": search_term, + "include_bride_side": include_bride_side, "households": households, } @@ -52,7 +77,7 @@ def _is_partial_request(expected_partial: str) -> bool: @quick_entry_bp.get("/") -@role_required("admin", "editor", "entry_only") +@role_required("admin", "editor", "entry_only", "quick_editor") def index() -> str: context = _quick_entry_index_context() if _is_partial_request("search-results"): @@ -61,21 +86,23 @@ def index() -> str: @quick_entry_bp.get("/households/new") -@role_required("admin", "editor", "entry_only") +@role_required("admin", "editor", "entry_only", "quick_editor") def new_household() -> str: search_term = request.args.get("q", "").strip() + include_bride_side = _include_bride_side_param(request.args) gift_method_options = list_enabled_options("gift_method") gift_scene_options = list_enabled_options("gift_scene") return render_template( "quick_entry/new.html", search_term=search_term, + include_bride_side=include_bride_side, default_gift_method_id=_option_id_by_code(gift_method_options, "cash"), default_gift_scene_id=_option_id_by_code(gift_scene_options, "wedding_day"), ) @quick_entry_bp.get("/households//edit") -@role_required("admin", "editor", "entry_only") +@role_required("admin", "editor", "entry_only", "quick_editor") def edit_household(household_id: int): household = get_household_or_none(household_id) if household is None: @@ -87,6 +114,7 @@ def edit_household(household_id: int): context = { "household": household, "search_term": request.args.get("q", "").strip(), + "include_bride_side": _include_bride_side_param(request.args), "gift_method_options": gift_method_options, "gift_scene_options": gift_scene_options, "default_gift_method_id": _option_id_by_code(gift_method_options, "cash"), @@ -98,7 +126,7 @@ def edit_household(household_id: int): @quick_entry_bp.post("/households//edit") -@role_required("admin", "editor", "entry_only") +@role_required("admin", "editor", "entry_only", "quick_editor") def update_household(household_id: int): household = get_household_or_none(household_id) if household is None: @@ -106,11 +134,13 @@ def update_household(household_id: int): return redirect(url_for("quick_entry.index")) search_term = request.form.get("q", "").strip() - redirect_to_search = url_for("quick_entry.index", q=search_term) if search_term else url_for("quick_entry.index") - redirect_to_self = ( - url_for("quick_entry.edit_household", household_id=household_id, q=search_term) - if search_term - else url_for("quick_entry.edit_household", household_id=household_id) + include_bride_side = _include_bride_side_param(request.form) + redirect_to_search = _quick_entry_search_url(search_term=search_term, include_bride_side=include_bride_side) + redirect_to_self = url_for( + "quick_entry.edit_household", + household_id=household_id, + q=search_term, + **({"include_bride_side": "1"} if include_bride_side else {}), ) submitted_version = request.form.get("version", "").strip() @@ -131,12 +161,18 @@ def update_household(household_id: int): valid_method_ids={option.id for option in gift_method_options}, valid_scene_ids={option.id for option in gift_scene_options}, ) + head_name = request.form.get("head_name", "").strip() + if not head_name: + errors.append("户主姓名不能为空。") + elif is_head_name_taken(head_name, excluding_household_id=household_id): + errors.append("该户主姓名已存在,请换一个名字或直接录入现有户。") if errors: for error in errors: flash(error, "error") return redirect(redirect_to_self) before_snapshot = serialize_entry_edit_snapshot(household) + household.head_name = head_name for field_name, value in payload.items(): setattr(household, field_name, value) @@ -167,11 +203,16 @@ def update_household(household_id: int): @quick_entry_bp.post("/households") -@role_required("admin", "editor", "entry_only") +@role_required("admin", "editor", "entry_only", "quick_editor") def create_household(): - search_term = request.form.get("head_name", "").strip() - redirect_to_search = url_for("quick_entry.index", q=search_term) if search_term else url_for("quick_entry.index") - redirect_to_self = url_for("quick_entry.new_household", q=search_term) if search_term else url_for("quick_entry.new_household") + search_term = request.form.get("q", "").strip() or request.form.get("head_name", "").strip() + include_bride_side = _include_bride_side_param(request.form) + redirect_to_search = _quick_entry_search_url(search_term=search_term, include_bride_side=include_bride_side) + redirect_to_self = url_for( + "quick_entry.new_household", + q=search_term, + **({"include_bride_side": "1"} if include_bride_side else {}), + ) gift_method_options = list_enabled_options("gift_method") gift_scene_options = list_enabled_options("gift_scene") @@ -188,7 +229,7 @@ def create_household(): head_name = request.form.get("head_name", "").strip() if not head_name: flash("户主姓名不能为空。", "error") - return redirect(url_for("quick_entry.new_household")) + return redirect(redirect_to_self) if is_head_name_taken(head_name): flash("该户主姓名已存在,请先搜索现有户后直接录入礼金。", "error") diff --git a/app/services/accounts.py b/app/services/accounts.py index 71e8a61..85fda60 100644 --- a/app/services/accounts.py +++ b/app/services/accounts.py @@ -6,7 +6,7 @@ import re from app.extensions import db from app.models import Account -ACCOUNT_ROLE_OPTIONS = ("admin", "editor", "entry_only") +ACCOUNT_ROLE_OPTIONS = ("admin", "editor", "entry_only", "quick_editor") ACCOUNT_STATUS_OPTIONS = ("active", "disabled") USERNAME_PATTERN = re.compile(r"^[A-Za-z0-9._-]{3,64}$") PASSWORD_UPPERCASE_PATTERN = re.compile(r"[A-Z]") diff --git a/app/services/demo_seed.py b/app/services/demo_seed.py index 29a36fa..9898ec2 100644 --- a/app/services/demo_seed.py +++ b/app/services/demo_seed.py @@ -93,6 +93,12 @@ DEMO_ACCOUNT_SPECS: tuple[DemoAccountSpec, ...] = ( password="EntryDemo123!", role="entry_only", ), + DemoAccountSpec( + username="quick-editor-demo", + display_name="快速录入账号", + password="QuickEditor123!", + role="quick_editor", + ), ) @@ -567,6 +573,7 @@ def demo_login_lines() -> tuple[str, ...]: "admin / ChangeMe123!(管理员)", "editor-demo / EditorDemo123!(演示录入员)", "entry-demo / EntryDemo123!(长辈录入演示)", + "quick-editor-demo / QuickEditor123!(快速录入账号)", ) diff --git a/app/services/households.py b/app/services/households.py index bd606a8..c2ca26d 100644 --- a/app/services/households.py +++ b/app/services/households.py @@ -106,6 +106,7 @@ ADMIN_EDITABLE_FIELDS = ( "note", ) ENTRY_EDITABLE_FIELDS = ( + "head_name", "phone", "attendance_status", "actual_attendee_count", @@ -543,6 +544,7 @@ def serialize_admin_edit_snapshot(household: Household) -> dict[str, object]: def serialize_entry_edit_snapshot(household: Household) -> dict[str, object]: return { + "head_name": household.head_name, "phone": household.phone, "attendance_status": household.attendance_status, "actual_attendee_count": household.actual_attendee_count, diff --git a/app/templates/base.html b/app/templates/base.html index 8dea0ae..b23ad10 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -111,7 +111,7 @@ >

{{ current_account.display_name or current_account.username }}

-

{{ current_account.username }} · {{ current_account.role }}

+

{{ current_account.username }} · {{ '快速录入' if current_account.role == 'quick_editor' else current_account.role }}

- 新增 + 新增 重置 @@ -129,6 +139,7 @@ form.querySelectorAll('input').forEach(function (field) { field.addEventListener('input', scheduleRefresh); + field.addEventListener('change', scheduleRefresh); }); document.addEventListener('click', function (event) { diff --git a/app/templates/quick_entry/new.html b/app/templates/quick_entry/new.html index 72b1db3..7ae5bcd 100644 --- a/app/templates/quick_entry/new.html +++ b/app/templates/quick_entry/new.html @@ -11,7 +11,7 @@ 现金 · 当天 - 返回搜索 + 返回搜索
@@ -27,6 +27,8 @@
+ + @@ -58,7 +60,7 @@
- 取消 + 取消
diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index b72bd41..d579d42 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -113,3 +113,8 @@ def admin_credentials() -> dict[str, str]: @pytest.fixture(scope="function") def entry_credentials() -> dict[str, str]: return {"username": "entry-demo", "password": "EntryDemo123!"} + + +@pytest.fixture(scope="function") +def quick_editor_credentials() -> dict[str, str]: + return {"username": "quick-editor-demo", "password": "QuickEditor123!"} diff --git a/tests/e2e/test_auth_flow.py b/tests/e2e/test_auth_flow.py index a8549c5..73dadf4 100644 --- a/tests/e2e/test_auth_flow.py +++ b/tests/e2e/test_auth_flow.py @@ -34,9 +34,32 @@ def test_auth_boundaries_for_login_logout_redirect_and_forbidden_access( expect(page.get_by_role("heading", name="快速录入")).to_be_visible() expect_sidebar_link(page, "快速录入") expect(page.get_by_role("link", name="管理首页")).to_have_count(0) + expect(page.get_by_label("包含女方")).not_to_be_checked() page.goto(f"{base_url}/") expect_path(page, base_url, "/quick-entry/") expect_flash(page, "您没有权限访问该页面。") logout(page, base_url) + + +def test_quick_editor_is_limited_to_quick_entry( + page: Page, + base_url: str, + quick_editor_credentials: dict[str, str], +) -> None: + login( + page, + base_url, + username=quick_editor_credentials["username"], + password=quick_editor_credentials["password"], + expected_path="/quick-entry/", + ) + + expect(page.get_by_role("heading", name="快速录入")).to_be_visible() + expect_sidebar_link(page, "快速录入") + expect(page.get_by_role("link", name="管理首页")).to_have_count(0) + + page.goto(f"{base_url}/") + expect_path(page, base_url, "/quick-entry/") + expect_flash(page, "您没有权限访问该页面。") diff --git a/tests/e2e/test_entry_flow.py b/tests/e2e/test_entry_flow.py index e1dea51..4f3abeb 100644 --- a/tests/e2e/test_entry_flow.py +++ b/tests/e2e/test_entry_flow.py @@ -1,5 +1,8 @@ from __future__ import annotations +import re +from uuid import uuid4 + from playwright.sync_api import Page, expect from tests.e2e.helpers import expect_sidebar_link, login @@ -21,6 +24,9 @@ def test_entry_user_can_search_select_and_save_limited_fields( page.locator("#quick-entry-search").press("Enter") expect(page.get_by_role("heading", name="搜索结果")).to_be_visible() + expect(page.get_by_text("李阿姨")).to_have_count(0) + page.get_by_label("包含女方").check() + expect(page.get_by_text("李阿姨")).to_be_visible() page.get_by_role("link", name="点击编辑").first.click() expect(page.locator("#quick-entry-edit-modal")).to_be_visible() @@ -28,11 +34,54 @@ def test_entry_user_can_search_select_and_save_limited_fields( expect(page.locator("#attendance_status")).to_have_count(0) expect(page.locator("#gift_method_option_id")).to_have_count(0) expect(page.locator("#gift_scene_option_id")).to_have_count(0) + page.locator("#head_name").fill("李阿姨-更新") page.locator("#total_gift_amount").fill("666.66") page.locator("#note").fill("快速录入自动化更新") page.get_by_role("button", name="保存并返回搜索").click() - expect(page.get_by_text("已保存 李阿姨 的礼金信息。")).to_be_visible() - expect(page).to_have_url(f"{base_url}/quick-entry/?q=%E6%9D%8E%E9%98%BF%E5%A7%A8") + expect(page.get_by_text("已保存 李阿姨-更新 的礼金信息。")).to_be_visible() + expect(page).to_have_url(f"{base_url}/quick-entry/?q=%E6%9D%8E%E9%98%BF%E5%A7%A8&include_bride_side=1") expect(page.locator("#quick-entry-search")).to_have_value("李阿姨") + expect(page.get_by_label("包含女方")).to_be_checked() + expect(page.get_by_role("heading", name="李阿姨-更新")).to_be_visible() + + +def test_quick_editor_can_search_create_and_reedit_household_from_quick_entry( + page: Page, + base_url: str, + quick_editor_credentials: dict[str, str], +) -> None: + unique_name = f"专项新增户{uuid4().hex[:6]}" + renamed_name = f"{unique_name}-改" + + login(page, base_url, username=quick_editor_credentials["username"], password=quick_editor_credentials["password"]) + + expect(page).to_have_url(f"{base_url}/quick-entry/") + expect(page.get_by_role("link", name="管理首页")).to_have_count(0) + + page.locator("#quick-entry-search").fill(unique_name) + page.locator("#quick-entry-search").press("Enter") + + expect(page.get_by_text("没有找到匹配的户")).to_be_visible() + page.get_by_role("link", name="新增").first.click() + + expect(page).to_have_url(re.compile(rf".*/quick-entry/households/new\?q=.*{re.escape(unique_name[-6:])}$")) + expect(page.get_by_role("heading", name="新增一户", exact=True)).to_be_visible() + page.locator("#quick-create-head-name").fill(unique_name) + page.locator("#quick-create-total-gift-amount").fill("388.00") + page.locator("#quick-create-note").fill("E2E 快速新增") + page.get_by_role("button", name="保存并返回搜索").click() + + expect(page).to_have_url(re.compile(rf".*/quick-entry/\?q=.*{re.escape(unique_name[-6:])}$")) + expect(page.get_by_text(f"已快速创建 {unique_name} 并记录礼金。")).to_be_visible() + expect(page.get_by_role("heading", name=unique_name)).to_be_visible() + + page.get_by_role("link", name="点击编辑").first.click() + expect(page.locator("#quick-entry-edit-modal")).to_be_visible() + page.locator("#head_name").fill(renamed_name) + page.locator("#note").fill("E2E 二次编辑备注") + page.get_by_role("button", name="保存并返回搜索").click() + + expect(page.get_by_text(f"已保存 {renamed_name} 的礼金信息。")).to_be_visible() + expect(page.get_by_role("heading", name=renamed_name)).to_be_visible() diff --git a/tests/e2e/test_share_flow.py b/tests/e2e/test_share_flow.py index b8453ef..26192c0 100644 --- a/tests/e2e/test_share_flow.py +++ b/tests/e2e/test_share_flow.py @@ -40,6 +40,7 @@ def test_share_link_mobile_flow_supports_search_edit_and_return( expect(page).to_have_url(re.compile(r".*/s/.+/households/\d+/edit\?q=.*")) expect(page.get_by_text("编辑信息")).to_be_visible() expect(page.locator("#phone")).to_be_visible() + current_head_name = page.locator("#head_name").input_value().strip() page.locator("#phone").fill("13922223333") page.locator("#attendance_status").select_option("partial") @@ -48,5 +49,5 @@ def test_share_link_mobile_flow_supports_search_edit_and_return( page.get_by_role("button", name="保存并返回搜索").click() expect(page).to_have_url(re.compile(r".*/s/.+\?q=.*")) - expect_flash(page, "已保存 李阿姨 的信息。") + expect_flash(page, f"已保存 {current_head_name} 的信息。") expect(page.locator("#share-search")).to_have_value("李阿姨") diff --git a/tests/test_admin_pages.py b/tests/test_admin_pages.py index 46ab935..0d0deef 100644 --- a/tests/test_admin_pages.py +++ b/tests/test_admin_pages.py @@ -95,6 +95,18 @@ def test_admin_accounts_page_renders_for_admin(client, app) -> None: assert "新增账号" in page +def test_admin_account_form_includes_quick_editor_role_option(client, app) -> None: + with app.app_context(): + create_account(username="admin", password="AdminPass123!", role="admin", display_name="管理员") + + login(client, username="admin", password="AdminPass123!") + response = client.get("/admin/accounts/new") + page = response.get_data(as_text=True) + + assert response.status_code == 200 + assert '