feat: add admin console and review workflow
This commit is contained in:
+272
@@ -44,6 +44,7 @@ PAYMENT_LINE_RE = re.compile(r"^(?P<student>.+?)-(?P<date>\d{4}-\d{2}-\d{2}):(?P
|
||||
TIME_RANGE_RE = re.compile(r"^(?P<sh>\d{1,2}):(?P<sm>\d{2})-(?P<eh>\d{1,2}):(?P<em>\d{2})$")
|
||||
BACKUP_DIR_RE = re.compile(r"^\d{8}-\d{6}-\d{6}-")
|
||||
BACKUP_KEEP_COUNT = 50
|
||||
ACCOUNT_STATUSES = {"正常", "预警", "欠费", "结课", "退费"}
|
||||
WEEKDAYS = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
|
||||
ROLE_WORDS = {
|
||||
"student": ("学生", "学员", "孩子", "同学"),
|
||||
@@ -156,6 +157,33 @@ def parse_payments(text: str) -> list[Payment]:
|
||||
return payments
|
||||
|
||||
|
||||
def validate_payment(payment: Payment) -> Payment:
|
||||
try:
|
||||
datetime.strptime(payment.date, "%Y-%m-%d")
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"缴费日期不存在: {payment.date}") from exc
|
||||
return Payment(date=payment.date, hours=round(float(payment.hours), 2))
|
||||
|
||||
|
||||
def validate_account(account: Account) -> Account:
|
||||
student_id = account.student_id.strip()
|
||||
student = canonical_name(account.student)
|
||||
if not re.fullmatch(r"XS\d{3}", student_id):
|
||||
raise ValueError("学生ID格式应为 XS001 这样的三位编号")
|
||||
if not student:
|
||||
raise ValueError("学生姓名不能为空")
|
||||
if account.account_status not in ACCOUNT_STATUSES:
|
||||
raise ValueError("账户状态必须是 正常、预警、欠费、结课、退费")
|
||||
return Account(
|
||||
student_id=student_id,
|
||||
student=student,
|
||||
payments=[validate_payment(payment) for payment in account.payments],
|
||||
remaining=round(float(account.remaining), 2),
|
||||
account_status=account.account_status,
|
||||
note=account.note.strip(),
|
||||
)
|
||||
|
||||
|
||||
def read_classnotes(path: Path) -> list[ClassRecord]:
|
||||
records: list[ClassRecord] = []
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
@@ -334,6 +362,46 @@ def replace_account_lines(original_text: str, accounts_by_id: dict[str, Account]
|
||||
return "\n".join(output) + trailing_newline
|
||||
|
||||
|
||||
def append_account_line(original_text: str, account: Account) -> str:
|
||||
lines = original_text.splitlines()
|
||||
insert_at = len(lines)
|
||||
for index, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("|") and not stripped.startswith("|---") and "学生ID" not in stripped:
|
||||
insert_at = index + 1
|
||||
lines.insert(insert_at, format_account_row(account))
|
||||
trailing_newline = "\n" if original_text.endswith("\n") else ""
|
||||
return "\n".join(lines) + trailing_newline
|
||||
|
||||
|
||||
def replace_single_account_line(original_text: str, old_student_id: str, account: Account) -> str:
|
||||
lines = original_text.splitlines()
|
||||
replaced = False
|
||||
output: list[str] = []
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("|") and not stripped.startswith("|---") and "学生ID" not in stripped:
|
||||
parts = [part.strip() for part in stripped.strip("|").split("|")]
|
||||
if parts and parts[0] == old_student_id:
|
||||
output.append(format_account_row(account))
|
||||
replaced = True
|
||||
continue
|
||||
output.append(line)
|
||||
if not replaced:
|
||||
raise ValueError(f"未找到学生账户: {old_student_id}")
|
||||
trailing_newline = "\n" if original_text.endswith("\n") else ""
|
||||
return "\n".join(output) + trailing_newline
|
||||
|
||||
|
||||
def next_student_id(accounts: list[Account]) -> str:
|
||||
values = []
|
||||
for account in accounts:
|
||||
match = re.fullmatch(r"XS(\d{3})", account.student_id)
|
||||
if match:
|
||||
values.append(int(match.group(1)))
|
||||
return f"XS{(max(values) if values else 0) + 1:03d}"
|
||||
|
||||
|
||||
def atomic_write_text(path: Path, text: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
original_stat = path.stat() if path.exists() else None
|
||||
@@ -457,6 +525,39 @@ def write_accounts(path: Path, accounts: list[Account]) -> None:
|
||||
atomic_write_text(path, replace_account_lines(original_text, accounts_by_id))
|
||||
|
||||
|
||||
def create_account(path: Path, account: Account) -> dict:
|
||||
accounts = read_accounts(path)
|
||||
account = validate_account(replace(account, student_id=next_student_id(accounts)))
|
||||
if any(item.student_id == account.student_id for item in accounts):
|
||||
raise ValueError(f"学生ID已存在: {account.student_id}")
|
||||
original_accounts = path.read_text(encoding="utf-8")
|
||||
backup_dir = create_data_backup("admin-create-account", {path: original_accounts}, [format_account_row(account)])
|
||||
atomic_write_text(path, append_account_line(original_accounts, account))
|
||||
try:
|
||||
prune_data_backups(backup_dir.parent)
|
||||
except OSError:
|
||||
pass
|
||||
return {"account": account_to_dict(account), "backup_id": backup_dir.name}
|
||||
|
||||
|
||||
def update_account(path: Path, old_student_id: str, account: Account) -> dict:
|
||||
old_student_id = old_student_id.strip()
|
||||
accounts = read_accounts(path)
|
||||
account = validate_account(account)
|
||||
if not any(item.student_id == old_student_id for item in accounts):
|
||||
raise ValueError(f"未找到学生账户: {old_student_id}")
|
||||
if account.student_id != old_student_id and any(item.student_id == account.student_id for item in accounts):
|
||||
raise ValueError(f"学生ID已存在: {account.student_id}")
|
||||
original_accounts = path.read_text(encoding="utf-8")
|
||||
backup_dir = create_data_backup("admin-update-account", {path: original_accounts}, [old_student_id, format_account_row(account)])
|
||||
atomic_write_text(path, replace_single_account_line(original_accounts, old_student_id, account))
|
||||
try:
|
||||
prune_data_backups(backup_dir.parent)
|
||||
except OSError:
|
||||
pass
|
||||
return {"account": account_to_dict(account), "backup_id": backup_dir.name}
|
||||
|
||||
|
||||
def render_accounts_text(path: Path, accounts: list[Account]) -> str:
|
||||
original_text = path.read_text(encoding="utf-8")
|
||||
accounts_by_id = {account.student_id: account for account in accounts}
|
||||
@@ -575,6 +676,177 @@ def register_payment_lines(
|
||||
return {"registered": len(registered), "lines": registered, "backup_id": backup_dir.name}
|
||||
|
||||
|
||||
def default_admin_tasks() -> dict:
|
||||
return {"version": 1, "next_id": 1, "items": []}
|
||||
|
||||
|
||||
def read_admin_tasks(path: Path) -> dict:
|
||||
if not path.exists():
|
||||
return default_admin_tasks()
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"管理任务文件 JSON 格式错误: {path}") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("管理任务文件必须是 JSON 对象")
|
||||
payload.setdefault("version", 1)
|
||||
payload.setdefault("next_id", 1)
|
||||
payload.setdefault("items", [])
|
||||
if not isinstance(payload["items"], list):
|
||||
raise ValueError("管理任务 items 必须是数组")
|
||||
return payload
|
||||
|
||||
|
||||
def write_admin_tasks(path: Path, tasks: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_text(path, json.dumps(tasks, ensure_ascii=False, indent=2) + "\n")
|
||||
|
||||
|
||||
def task_to_dict(task: dict) -> dict:
|
||||
return dict(task)
|
||||
|
||||
|
||||
def find_admin_task(tasks: dict, task_id: int) -> dict:
|
||||
for task in tasks["items"]:
|
||||
if int(task.get("id", 0)) == task_id:
|
||||
return task
|
||||
raise ValueError(f"未找到管理任务: {task_id}")
|
||||
|
||||
|
||||
def submit_correction_tasks(tasks_path: Path, items: list[dict]) -> dict:
|
||||
if not items:
|
||||
raise ValueError("提交审核的纠错记录不能为空")
|
||||
tasks = read_admin_tasks(tasks_path)
|
||||
now = datetime.now().isoformat(timespec="seconds")
|
||||
created: list[dict] = []
|
||||
for item in items:
|
||||
original_line = str(item.get("original_line", "")).strip()
|
||||
corrected_line = str(item.get("corrected_line", "")).strip()
|
||||
if not original_line or not corrected_line:
|
||||
raise ValueError("纠错审核记录缺少原记录或修改后记录")
|
||||
original = parse_class_record_line(original_line)
|
||||
corrected = parse_class_record_line(corrected_line)
|
||||
if original_line == corrected_line:
|
||||
raise ValueError("原记录和修改后记录相同,无需提交审核")
|
||||
task = {
|
||||
"id": int(tasks["next_id"]),
|
||||
"type": "class_record_correction",
|
||||
"status": "pending",
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"original_line": class_record_to_line(original),
|
||||
"corrected_line": class_record_to_line(corrected),
|
||||
"original": record_to_dict(original),
|
||||
"corrected": record_to_dict(corrected),
|
||||
}
|
||||
tasks["next_id"] = int(tasks["next_id"]) + 1
|
||||
tasks["items"].append(task)
|
||||
created.append(task)
|
||||
write_admin_tasks(tasks_path, tasks)
|
||||
return {"submitted": len(created), "items": [task_to_dict(task) for task in created]}
|
||||
|
||||
|
||||
def list_admin_tasks(tasks_path: Path, status_filter: str = "", task_type: str = "") -> dict:
|
||||
tasks = read_admin_tasks(tasks_path)
|
||||
items = tasks["items"]
|
||||
if status_filter:
|
||||
items = [item for item in items if item.get("status") == status_filter]
|
||||
if task_type:
|
||||
items = [item for item in items if item.get("type") == task_type]
|
||||
return {
|
||||
"version": tasks["version"],
|
||||
"next_id": tasks["next_id"],
|
||||
"count": len(items),
|
||||
"items": [task_to_dict(item) for item in sorted(items, key=lambda item: int(item.get("id", 0)), reverse=True)],
|
||||
}
|
||||
|
||||
|
||||
def replace_class_record_line(original_text: str, original_line: str, corrected_line: str) -> str:
|
||||
lines = original_text.splitlines()
|
||||
matched = [index for index, line in enumerate(lines) if line.strip() == original_line]
|
||||
if not matched:
|
||||
raise ValueError("原上课记录在正式文件中不存在,可能已被修改")
|
||||
if original_line != corrected_line and any(line.strip() == corrected_line for line in lines):
|
||||
raise ValueError("修改后的上课记录已存在,不能重复写入")
|
||||
lines[matched[0]] = corrected_line
|
||||
trailing_newline = "\n" if original_text.endswith("\n") else ""
|
||||
return "\n".join(lines) + trailing_newline
|
||||
|
||||
|
||||
def mark_admin_task(tasks_path: Path, task_id: int, status: str, message: str = "") -> dict:
|
||||
tasks = read_admin_tasks(tasks_path)
|
||||
task = find_admin_task(tasks, task_id)
|
||||
if task.get("status") not in {"pending", "conflict"}:
|
||||
raise ValueError("该任务已处理,不能重复操作")
|
||||
now = datetime.now().isoformat(timespec="seconds")
|
||||
task["status"] = status
|
||||
task["updated_at"] = now
|
||||
task["reviewed_at"] = now
|
||||
if message:
|
||||
task["message"] = message
|
||||
write_admin_tasks(tasks_path, tasks)
|
||||
return task_to_dict(task)
|
||||
|
||||
|
||||
def reject_admin_task(tasks_path: Path, task_id: int) -> dict:
|
||||
return mark_admin_task(tasks_path, task_id, "rejected")
|
||||
|
||||
|
||||
def approve_correction_task(tasks_path: Path, classnotes_path: Path, task_id: int) -> dict:
|
||||
tasks = read_admin_tasks(tasks_path)
|
||||
task = find_admin_task(tasks, task_id)
|
||||
if task.get("type") != "class_record_correction":
|
||||
raise ValueError("该任务不是上课记录纠错")
|
||||
if task.get("status") not in {"pending", "conflict"}:
|
||||
raise ValueError("该任务已处理,不能重复批准")
|
||||
|
||||
original_line = str(task.get("original_line", "")).strip()
|
||||
corrected_line = str(task.get("corrected_line", "")).strip()
|
||||
parse_class_record_line(original_line)
|
||||
corrected = parse_class_record_line(corrected_line)
|
||||
corrected_line = class_record_to_line(corrected)
|
||||
|
||||
original_classnotes = classnotes_path.read_text(encoding="utf-8")
|
||||
try:
|
||||
new_classnotes = replace_class_record_line(original_classnotes, original_line, corrected_line)
|
||||
except ValueError as exc:
|
||||
task["status"] = "conflict"
|
||||
task["updated_at"] = datetime.now().isoformat(timespec="seconds")
|
||||
task["message"] = str(exc)
|
||||
write_admin_tasks(tasks_path, tasks)
|
||||
raise
|
||||
|
||||
original_tasks = json.dumps(tasks, ensure_ascii=False, indent=2) + "\n"
|
||||
now = datetime.now().isoformat(timespec="seconds")
|
||||
task["status"] = "approved"
|
||||
task["updated_at"] = now
|
||||
task["reviewed_at"] = now
|
||||
task["corrected_line"] = corrected_line
|
||||
task["corrected"] = record_to_dict(corrected)
|
||||
new_tasks = json.dumps(tasks, ensure_ascii=False, indent=2) + "\n"
|
||||
|
||||
backup_dir = create_data_backup(
|
||||
"admin-approve-correction",
|
||||
{
|
||||
classnotes_path: original_classnotes,
|
||||
tasks_path: original_tasks,
|
||||
},
|
||||
[original_line, corrected_line],
|
||||
)
|
||||
try:
|
||||
atomic_write_text(classnotes_path, new_classnotes)
|
||||
atomic_write_text(tasks_path, new_tasks)
|
||||
except Exception:
|
||||
atomic_write_text(classnotes_path, original_classnotes)
|
||||
atomic_write_text(tasks_path, original_tasks)
|
||||
raise
|
||||
try:
|
||||
prune_data_backups(backup_dir.parent)
|
||||
except OSError:
|
||||
pass
|
||||
return {"task": task_to_dict(task), "backup_id": backup_dir.name}
|
||||
|
||||
|
||||
def parse_record_date(text: str) -> date:
|
||||
return datetime.strptime(text, "%Y.%m.%d").date()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user