Browse Source

Refine quick entry roles and filters

main
Rain Mark 3 days ago
parent
commit
90758c0487
  1. 4
      app/auth.py
  2. 69
      app/routes/quick_entry.py
  3. 2
      app/services/accounts.py
  4. 7
      app/services/demo_seed.py
  5. 2
      app/services/households.py
  6. 2
      app/templates/base.html
  7. 14
      app/templates/quick_entry/_edit_form.html
  8. 2
      app/templates/quick_entry/_edit_modal.html
  9. 6
      app/templates/quick_entry/_search_results.html
  10. 6
      app/templates/quick_entry/edit.html
  11. 15
      app/templates/quick_entry/index.html
  12. 6
      app/templates/quick_entry/new.html
  13. 5
      tests/e2e/conftest.py
  14. 23
      tests/e2e/test_auth_flow.py
  15. 53
      tests/e2e/test_entry_flow.py
  16. 3
      tests/e2e/test_share_flow.py
  17. 12
      tests/test_admin_pages.py
  18. 43
      tests/test_auth.py
  19. 7
      tests/test_cli_seed.py
  20. 52
      tests/test_household_pages.py

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

69
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/<int:household_id>/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/<int:household_id>/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")

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

7
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!(快速录入账号)",
)

2
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,

2
app/templates/base.html

@ -111,7 +111,7 @@
>
<div class="rounded-xl border border-neutral-200 bg-neutral-50 px-3 py-2 dark:border-neutral-700 dark:bg-neutral-800/80">
<p class="truncate text-sm font-medium text-neutral-800 dark:text-neutral-100">{{ current_account.display_name or current_account.username }}</p>
<p class="truncate text-xs text-neutral-500 dark:text-neutral-400">{{ current_account.username }} · {{ current_account.role }}</p>
<p class="truncate text-xs text-neutral-500 dark:text-neutral-400">{{ current_account.username }} · {{ '快速录入' if current_account.role == 'quick_editor' else current_account.role }}</p>
</div>
<button type="button" id="theme-toggle" class="mt-2 flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-sm font-medium text-neutral-700 transition hover:bg-neutral-100 hover:text-neutral-900 focus:outline-none focus:ring-2 focus:ring-neutral-300 dark:text-neutral-200 dark:hover:bg-neutral-800 dark:hover:text-neutral-50 dark:focus:ring-neutral-600" role="menuitem">
<span>切换主题</span>

14
app/templates/quick_entry/_edit_form.html

@ -10,6 +10,7 @@
>
<input type="hidden" name="version" value="{{ household.version }}">
<input type="hidden" name="q" value="{{ search_term }}">
<input type="hidden" name="include_bride_side" value="{{ '1' if include_bride_side else '0' }}">
<input type="hidden" name="phone" value="{{ household.phone or '' }}">
<input type="hidden" name="attendance_status" value="attending">
<input type="hidden" name="actual_attendee_count" value="{{ household.actual_attendee_count }}">
@ -21,6 +22,19 @@
<input type="hidden" name="candy_status" value="{{ household.candy_status }}">
<input type="hidden" name="child_red_packet_status" value="{{ household.child_red_packet_status }}">
<div class="rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm dark:border-neutral-700 dark:bg-neutral-900/70">
<label for="head_name" class="form-label">户主姓名</label>
<input
class="form-input"
id="head_name"
name="head_name"
type="text"
value="{{ household.head_name }}"
maxlength="80"
required
>
</div>
<div class="rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm dark:border-neutral-700 dark:bg-neutral-900/70">
<label for="total_gift_amount" class="form-label">礼金金额</label>
<div class="relative">

2
app/templates/quick_entry/_edit_modal.html

@ -11,7 +11,7 @@
<div class="pr-12">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-2xl font-semibold tracking-tight text-neutral-900 dark:text-neutral-100">快速录入礼金</h3>
<h3 class="text-2xl font-semibold tracking-tight text-neutral-900 dark:text-neutral-100">快速录入</h3>
<span class="rounded-full bg-accent-100 px-3 py-1 text-xs font-medium text-accent-700 dark:bg-accent-900/40 dark:text-accent-300">现金 · 当天</span>
</div>
<p class="mt-2 text-sm text-neutral-500 dark:text-neutral-400">只记录当天礼金和补充备注,保存后返回搜索结果。</p>

6
app/templates/quick_entry/_search_results.html

@ -11,8 +11,8 @@
<div class="card-body flex flex-col gap-3 sm:flex-row sm:items-center">
<a
class="btn btn-accent btn-sm w-full shrink-0 sm:order-first sm:w-auto"
href="{{ url_for('quick_entry.edit_household', household_id=household.id, q=search_term) }}"
data-quick-entry-edit-url="{{ url_for('quick_entry.edit_household', household_id=household.id, q=search_term, partial='edit-modal') }}"
href="{{ url_for('quick_entry.edit_household', household_id=household.id, q=search_term, include_bride_side='1') if include_bride_side else url_for('quick_entry.edit_household', household_id=household.id, q=search_term) }}"
data-quick-entry-edit-url="{{ url_for('quick_entry.edit_household', household_id=household.id, q=search_term, partial='edit-modal', include_bride_side='1') if include_bride_side else url_for('quick_entry.edit_household', household_id=household.id, q=search_term, partial='edit-modal') }}"
>
点击编辑
</a>
@ -40,7 +40,7 @@
<p class="text-sm text-neutral-500 dark:text-neutral-400">可以换个关键词继续搜,或者直接新增一户并记录当天礼金。</p>
</div>
<div class="flex justify-center">
<a class="btn btn-accent btn-sm" href="{{ url_for('quick_entry.new_household', q=search_term) }}">新增</a>
<a class="btn btn-accent btn-sm" href="{{ url_for('quick_entry.new_household', q=search_term, include_bride_side='1') if include_bride_side else url_for('quick_entry.new_household', q=search_term) }}">新增</a>
</div>
</div>
</div>

6
app/templates/quick_entry/edit.html

@ -1,17 +1,17 @@
{% extends "base.html" %}
{% block title %}快速录入礼金{% endblock %}
{% block title %}快速录入{% endblock %}
{% block content %}
<div class="w-full">
<section class="mb-4 flex items-start justify-between gap-3 sm:mb-5">
<div>
<div class="flex items-center gap-2">
<h2 class="page-title">快速录入礼金</h2>
<h2 class="page-title">快速录入</h2>
<span class="rounded-full bg-accent-100 px-3 py-1 text-xs font-medium text-accent-700 dark:bg-accent-900/40 dark:text-accent-300">现金 · 当天</span>
</div>
</div>
<a class="btn btn-secondary shrink-0" href="{{ url_for('quick_entry.index', q=search_term) if search_term else url_for('quick_entry.index') }}">返回搜索</a>
<a class="btn btn-secondary shrink-0" href="{{ url_for('quick_entry.index', q=search_term, include_bride_side='1') if include_bride_side else (url_for('quick_entry.index', q=search_term) if search_term else url_for('quick_entry.index')) }}">返回搜索</a>
</section>
<section class="mb-4 rounded-2xl border border-neutral-200 bg-white px-4 py-3 text-sm text-neutral-700 shadow-soft dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 sm:mb-5">

15
app/templates/quick_entry/index.html

@ -19,9 +19,19 @@
<label for="quick-entry-search" class="form-label">搜索关键词</label>
<div class="flex flex-col gap-3 sm:flex-row">
<input id="quick-entry-search" name="q" type="text" class="form-input flex-1" value="{{ search_term }}" placeholder="户主 / 成员 / 拼音 / 标签 / 备注" autofocus>
<div class="flex gap-2">
<div class="flex flex-wrap items-center justify-end gap-2 sm:flex-nowrap">
<label class="inline-flex shrink-0 items-center gap-2 rounded-full border border-neutral-200 bg-neutral-50 px-3 py-2 text-xs text-neutral-500 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-400">
<input
type="checkbox"
name="include_bride_side"
value="1"
class="h-3.5 w-3.5 rounded border-neutral-300 text-brand-600 focus:ring-brand-500 dark:border-neutral-600 dark:bg-neutral-900"
{% if include_bride_side %}checked{% endif %}
>
<span>包含女方</span>
</label>
<button type="submit" class="btn btn-primary flex-1 sm:flex-none">搜索</button>
<a class="btn btn-accent flex-1 sm:flex-none" href="{{ url_for('quick_entry.new_household', q=search_term) if search_term else url_for('quick_entry.new_household') }}">新增</a>
<a class="btn btn-accent flex-1 sm:flex-none" href="{{ url_for('quick_entry.new_household', q=search_term, include_bride_side='1') if include_bride_side else (url_for('quick_entry.new_household', q=search_term) if search_term else url_for('quick_entry.new_household')) }}">新增</a>
<a class="btn btn-secondary flex-1 sm:flex-none" href="{{ url_for('quick_entry.index') }}">重置</a>
</div>
</div>
@ -129,6 +139,7 @@
form.querySelectorAll('input').forEach(function (field) {
field.addEventListener('input', scheduleRefresh);
field.addEventListener('change', scheduleRefresh);
});
document.addEventListener('click', function (event) {

6
app/templates/quick_entry/new.html

@ -11,7 +11,7 @@
<span class="rounded-full bg-accent-100 px-3 py-1 text-xs font-medium text-accent-700 dark:bg-accent-900/40 dark:text-accent-300">现金 · 当天</span>
</div>
</div>
<a class="btn btn-secondary shrink-0" href="{{ url_for('quick_entry.index', q=search_term) if search_term else url_for('quick_entry.index') }}">返回搜索</a>
<a class="btn btn-secondary shrink-0" href="{{ url_for('quick_entry.index', q=search_term, include_bride_side='1') if include_bride_side else (url_for('quick_entry.index', q=search_term) if search_term else url_for('quick_entry.index')) }}">返回搜索</a>
</section>
<section class="card overflow-hidden">
@ -27,6 +27,8 @@
<div class="card-body sm:px-7">
<form action="{{ url_for('quick_entry.create_household') }}" method="post" class="mx-auto max-w-3xl space-y-5">
<input type="hidden" name="q" value="{{ search_term }}">
<input type="hidden" name="include_bride_side" value="{{ '1' if include_bride_side else '0' }}">
<input type="hidden" name="phone" value="">
<input type="hidden" name="attendance_status" value="attending">
<input type="hidden" name="actual_attendee_count" value="1">
@ -58,7 +60,7 @@
</div>
<div class="flex flex-wrap items-center justify-end gap-3 border-t border-neutral-200 pt-5 dark:border-neutral-800">
<a class="btn btn-secondary order-2 sm:order-1" href="{{ url_for('quick_entry.index', q=search_term) if search_term else url_for('quick_entry.index') }}">取消</a>
<a class="btn btn-secondary order-2 sm:order-1" href="{{ url_for('quick_entry.index', q=search_term, include_bride_side='1') if include_bride_side else (url_for('quick_entry.index', q=search_term) if search_term else url_for('quick_entry.index')) }}">取消</a>
<button type="submit" class="btn btn-primary min-w-36 order-1 sm:order-2">保存并返回搜索</button>
</div>
</form>

5
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!"}

23
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, "您没有权限访问该页面。")

53
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()

3
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("李阿姨")

12
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 '<option value="quick_editor"' in page
def test_admin_accounts_page_rejects_editor(client, app) -> None:
with app.app_context():
create_account(username="editor", password="EditorPass123!", role="editor")

43
tests/test_auth.py

@ -101,6 +101,16 @@ def test_entry_only_login_redirects_to_entry_page(client, app) -> None:
assert response.headers["Location"].endswith("/quick-entry/")
def test_quick_editor_login_redirects_to_entry_page(client, app) -> None:
with app.app_context():
create_account(username="quick-editor", password="QuickEdit123!", role="quick_editor")
response = login(client, username="quick-editor", password="QuickEdit123!")
assert response.status_code == 302
assert response.headers["Location"].endswith("/quick-entry/")
def test_login_rejects_wrong_password(client, app) -> None:
with app.app_context():
create_account(username="editor-user", password="EditorPass123!", role="editor")
@ -205,6 +215,17 @@ def test_entry_page_allows_entry_only_role(client, app) -> None:
assert "快速录入" in response.get_data(as_text=True)
def test_entry_page_allows_quick_editor_role(client, app) -> None:
with app.app_context():
create_account(username="quick-editor", password="QuickEdit123!", role="quick_editor")
login(client, username="quick-editor", password="QuickEdit123!")
response = client.get("/quick-entry/")
assert response.status_code == 200
assert "快速录入" in response.get_data(as_text=True)
def test_logout_clears_session(client, app) -> None:
with app.app_context():
create_account(username="logout-user", password="LogoutPass123!", role="editor")
@ -292,3 +313,25 @@ def test_entry_only_forbidden_from_admin_audit_page(client, app) -> None:
assert response.status_code == 302
assert response.headers["Location"].endswith("/quick-entry/")
def test_quick_editor_is_redirected_away_from_main_page(client, app) -> None:
with app.app_context():
create_account(username="quick-editor", password="QuickEdit123!", role="quick_editor")
login(client, username="quick-editor", password="QuickEdit123!")
response = client.get("/", follow_redirects=False)
assert response.status_code == 302
assert response.headers["Location"].endswith("/quick-entry/")
def test_quick_editor_forbidden_from_admin_audit_page(client, app) -> None:
with app.app_context():
create_account(username="quick-editor", password="QuickEdit123!", role="quick_editor")
login(client, username="quick-editor", password="QuickEdit123!")
response = client.get("/admin/audit-logs", follow_redirects=False)
assert response.status_code == 302
assert response.headers["Location"].endswith("/quick-entry/")

7
tests/test_cli_seed.py

@ -135,20 +135,21 @@ def test_seed_demo_command_creates_demo_accounts_and_households() -> None:
result = runner.invoke(args=["seed-demo"])
assert result.exit_code == 0
assert "Seeded 2 demo accounts." in result.output
assert "Seeded 3 demo accounts." in result.output
assert "Seeded 3 demo households." in result.output
assert "editor-demo / EditorDemo123!" in result.output
assert "quick-editor-demo / QuickEditor123!" in result.output
with app.app_context():
demo_accounts = db.session.execute(
db.select(Account).where(Account.username.in_(["editor-demo", "entry-demo"])),
db.select(Account).where(Account.username.in_(["editor-demo", "entry-demo", "quick-editor-demo"])),
).scalars().all()
demo_households = db.session.execute(
db.select(Household).where(Household.household_code.in_(["D001", "D002", "D003"])),
).scalars().all()
member_count = db.session.execute(db.select(HouseholdMember)).scalars().all()
assert len(demo_accounts) == 2
assert len(demo_accounts) == 3
assert len(demo_households) == 3
assert len(member_count) >= 8

52
tests/test_household_pages.py

@ -1203,9 +1203,10 @@ def test_entry_edit_modal_renders_gift_only_form(client, app) -> None:
assert response.status_code == 200
assert 'id="quick-entry-edit-modal-panel"' in page
assert "快速录入礼金" in page
assert "快速录入" in page
assert 'id="quick-entry-household-edit-form"' in page
assert "保存并返回搜索" in page
assert 'id="head_name"' in page
assert 'name="version"' in page
assert 'name="q" value=""' in page
assert 'id="phone"' not in page
@ -1279,6 +1280,7 @@ def test_entry_update_saves_allowed_fields_and_writes_audit_log(client, app) ->
f"/quick-entry/households/{household_id}/edit",
data={
"q": "",
"head_name": "王大爷-改名",
"version": "1",
"attendance_status": "attending",
"actual_attendee_count": "0",
@ -1302,7 +1304,7 @@ def test_entry_update_saves_allowed_fields_and_writes_audit_log(client, app) ->
with app.app_context():
stored_household = db.session.get(Household, household_id)
assert stored_household is not None
assert stored_household.head_name == "王大爷"
assert stored_household.head_name == "王大爷-改名"
assert stored_household.phone == "13800001111"
assert stored_household.attendance_status == "attending"
assert stored_household.actual_attendee_count == 0
@ -1326,10 +1328,56 @@ def test_entry_update_saves_allowed_fields_and_writes_audit_log(client, app) ->
assert logs[0].actor_user_id == entry_account_id
assert logs[0].before_data_json is not None
assert logs[0].after_data_json is not None
assert logs[0].before_data_json["head_name"] == "王大爷"
assert logs[0].after_data_json["head_name"] == "王大爷-改名"
assert logs[0].before_data_json["phone"] == "13800001111"
assert logs[0].after_data_json["phone"] == "13800001111"
def test_entry_search_hides_bride_side_households_by_default(client, app) -> None:
with app.app_context():
create_account(username="entry-user", password="EntryPass123!", role="entry_only")
create_household(household_code="A001", head_name="李阿姨", side="bride_side")
create_household(household_code="A002", head_name="李伯伯", side="groom_side")
login(client, username="entry-user", password="EntryPass123!")
response = client.get("/quick-entry/?q=李")
page = response.get_data(as_text=True)
assert response.status_code == 200
assert "李伯伯" in page
assert "李阿姨" not in page
assert 'name="include_bride_side"' in page
def test_entry_search_can_include_bride_side_households(client, app) -> None:
with app.app_context():
create_account(username="entry-user", password="EntryPass123!", role="entry_only")
create_household(household_code="A001", head_name="李阿姨", side="bride_side")
create_household(household_code="A002", head_name="李伯伯", side="groom_side")
login(client, username="entry-user", password="EntryPass123!")
response = client.get("/quick-entry/?q=李&include_bride_side=1")
page = response.get_data(as_text=True)
assert response.status_code == 200
assert "李伯伯" in page
assert "李阿姨" in page
def test_quick_editor_cannot_access_admin_edit_page(client, app) -> None:
with app.app_context():
create_account(username="quick-editor", password="QuickEdit123!", role="quick_editor")
household = create_household(household_code="A001", head_name="王大爷")
household_id = household.id
login(client, username="quick-editor", password="QuickEdit123!")
response = client.get(f"/households/{household_id}/edit", follow_redirects=False)
assert response.status_code == 302
assert response.headers["Location"].endswith("/quick-entry/")
def test_entry_can_quick_create_household_when_search_has_no_match(client, app) -> None:
with app.app_context():
entry_account = create_account(username="entry-user", password="EntryPass123!", role="entry_only")

Loading…
Cancel
Save