记录原始数据变更操作日志
This commit is contained in:
+100
-21
@@ -524,7 +524,7 @@ def create_teacher(path: Path, teacher: Teacher) -> dict:
|
||||
prune_data_backups(backup_dir.parent)
|
||||
except OSError:
|
||||
pass
|
||||
return {"teacher": teacher_to_dict(teacher), "backup_id": backup_dir.name}
|
||||
return {"teacher": teacher_to_dict(teacher), "backup_id": backup_dir.name, "operation": "新增老师档案"}
|
||||
|
||||
|
||||
def update_teacher(path: Path, old_teacher_id: str, teacher: Teacher) -> dict:
|
||||
@@ -542,7 +542,7 @@ def update_teacher(path: Path, old_teacher_id: str, teacher: Teacher) -> dict:
|
||||
prune_data_backups(backup_dir.parent)
|
||||
except OSError:
|
||||
pass
|
||||
return {"teacher": teacher_to_dict(teacher), "backup_id": backup_dir.name}
|
||||
return {"teacher": teacher_to_dict(teacher), "backup_id": backup_dir.name, "operation": "修改老师档案"}
|
||||
|
||||
|
||||
def teacher_to_dict(teacher: Teacher) -> dict:
|
||||
@@ -590,7 +590,7 @@ def create_account(path: Path, account: Account) -> dict:
|
||||
prune_data_backups(backup_dir.parent)
|
||||
except OSError:
|
||||
pass
|
||||
return {"account": account_to_dict(account), "backup_id": backup_dir.name}
|
||||
return {"account": account_to_dict(account), "backup_id": backup_dir.name, "operation": "新增课时账户"}
|
||||
|
||||
|
||||
def update_account(path: Path, old_student_id: str, account: Account) -> dict:
|
||||
@@ -608,7 +608,7 @@ def update_account(path: Path, old_student_id: str, account: Account) -> dict:
|
||||
prune_data_backups(backup_dir.parent)
|
||||
except OSError:
|
||||
pass
|
||||
return {"account": account_to_dict(account), "backup_id": backup_dir.name}
|
||||
return {"account": account_to_dict(account), "backup_id": backup_dir.name, "operation": "修改课时账户"}
|
||||
|
||||
|
||||
def render_accounts_text(path: Path, accounts: list[Account]) -> str:
|
||||
@@ -691,7 +691,7 @@ def register_class_record_lines(
|
||||
prune_data_backups(backup_dir.parent)
|
||||
except OSError:
|
||||
pass
|
||||
return {"registered": len(record_lines), "lines": record_lines, "backup_id": backup_dir.name}
|
||||
return {"registered": len(record_lines), "lines": record_lines, "backup_id": backup_dir.name, "operation": "登记上课记录"}
|
||||
|
||||
|
||||
def register_payment_lines(
|
||||
@@ -726,7 +726,7 @@ def register_payment_lines(
|
||||
prune_data_backups(backup_dir.parent)
|
||||
except OSError:
|
||||
pass
|
||||
return {"registered": len(registered), "lines": registered, "backup_id": backup_dir.name}
|
||||
return {"registered": len(registered), "lines": registered, "backup_id": backup_dir.name, "operation": "登记缴费记录"}
|
||||
|
||||
|
||||
def default_admin_tasks() -> dict:
|
||||
@@ -1358,18 +1358,96 @@ def append_operation_log(path: Path, operation: str, status: str, **fields: obje
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
now = datetime.now().isoformat(timespec="seconds")
|
||||
log_id = f"{datetime.now().strftime('%Y%m%d%H%M%S')}-{sha1_text(json.dumps(fields, ensure_ascii=False, sort_keys=True), 8)}"
|
||||
row = {
|
||||
row = localize_operation_log_item({
|
||||
"id": log_id,
|
||||
"created_at": now,
|
||||
"operation": operation,
|
||||
"status": status,
|
||||
**fields,
|
||||
}
|
||||
})
|
||||
with path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")
|
||||
return log_id
|
||||
|
||||
|
||||
OPERATION_LABELS = {
|
||||
"history_course_summary_import": "历史课程小结导入",
|
||||
"course_summary_manual_register": "课程小结登记",
|
||||
"course_summary_ingest": "课程小结接收",
|
||||
"admin_task_approve": "审核批准",
|
||||
"admin_task_reject": "审核驳回",
|
||||
"admin_course_summary_update_time": "课程小结补齐时间",
|
||||
"admin_course_summary_delete": "课程小结删除",
|
||||
"register-class-records": "登记上课记录",
|
||||
"register-payments": "登记缴费记录",
|
||||
"admin-create-account": "新增课时账户",
|
||||
"admin-update-account": "修改课时账户",
|
||||
"admin-create-teacher": "新增老师档案",
|
||||
"admin-update-teacher": "修改老师档案",
|
||||
"admin-approve-correction": "审核批准上课记录纠错",
|
||||
"admin-approve-deletion": "审核批准上课记录删除",
|
||||
"admin-update-course-summary-time": "课程小结补齐时间",
|
||||
"admin-delete-course-summary": "课程小结删除",
|
||||
}
|
||||
|
||||
STATUS_LABELS = {
|
||||
"completed": "完成",
|
||||
"approved": "已批准",
|
||||
"rejected": "已驳回",
|
||||
"updated": "已更新",
|
||||
"deleted": "已删除",
|
||||
"duplicate": "重复",
|
||||
"auto_registered": "自动入账",
|
||||
"review": "待审核",
|
||||
"conflict": "冲突",
|
||||
"pending": "待处理",
|
||||
}
|
||||
|
||||
TYPE_LABELS = {
|
||||
"class_record_correction": "上课记录纠错",
|
||||
"class_record_deletion": "上课记录删除",
|
||||
"course_summary_review": "课程小结审核",
|
||||
}
|
||||
|
||||
|
||||
def localize_operation_log_item(item: dict) -> dict:
|
||||
row = dict(item)
|
||||
operation = str(row.get("operation") or "")
|
||||
status = str(row.get("status") or "")
|
||||
task_type = str(row.get("task_type") or "")
|
||||
row["operation"] = OPERATION_LABELS.get(operation, operation)
|
||||
row["status"] = STATUS_LABELS.get(status, status)
|
||||
if task_type:
|
||||
row["task_type"] = TYPE_LABELS.get(task_type, task_type)
|
||||
if row.get("message"):
|
||||
row["message"] = str(row["message"])
|
||||
return row
|
||||
|
||||
|
||||
def migrate_operation_log_labels(path: Path) -> dict:
|
||||
if not path.exists():
|
||||
return {"updated": 0, "path": str(path)}
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
updated_lines: list[str] = []
|
||||
changed = 0
|
||||
for raw_line in lines:
|
||||
if not raw_line.strip():
|
||||
updated_lines.append(raw_line)
|
||||
continue
|
||||
try:
|
||||
item = json.loads(raw_line)
|
||||
except json.JSONDecodeError:
|
||||
updated_lines.append(raw_line)
|
||||
continue
|
||||
localized = localize_operation_log_item(item)
|
||||
if localized != item:
|
||||
changed += 1
|
||||
updated_lines.append(json.dumps(localized, ensure_ascii=False, sort_keys=True))
|
||||
if changed:
|
||||
atomic_write_text(path, "\n".join(updated_lines) + "\n")
|
||||
return {"updated": changed, "path": str(path)}
|
||||
|
||||
|
||||
def list_operation_logs(path: Path, limit: int = 100, operation: str = "", status_filter: str = "", student: str = "") -> dict:
|
||||
rows: list[dict] = []
|
||||
if path.exists():
|
||||
@@ -1380,6 +1458,7 @@ def list_operation_logs(path: Path, limit: int = 100, operation: str = "", statu
|
||||
item = json.loads(raw_line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
item = localize_operation_log_item(item)
|
||||
if operation and item.get("operation") != operation:
|
||||
continue
|
||||
if status_filter and item.get("status") != status_filter:
|
||||
@@ -1948,8 +2027,8 @@ def register_course_summary_texts(
|
||||
result["duplicates"] += 1
|
||||
log_id = append_operation_log(
|
||||
operation_logs_path,
|
||||
"course_summary_manual_register",
|
||||
"duplicate",
|
||||
"课程小结登记",
|
||||
"重复",
|
||||
source_id=source_id,
|
||||
student=normalized["student"],
|
||||
)
|
||||
@@ -1987,8 +2066,8 @@ def register_course_summary_texts(
|
||||
write_course_summary_state(state_path, state)
|
||||
log_id = append_operation_log(
|
||||
operation_logs_path,
|
||||
"course_summary_manual_register",
|
||||
"auto_registered",
|
||||
"课程小结登记",
|
||||
"自动入账",
|
||||
source_id=source_id,
|
||||
student=normalized["student"],
|
||||
teacher=normalized.get("teacher", ""),
|
||||
@@ -2012,8 +2091,8 @@ def register_course_summary_texts(
|
||||
student = str((normalized or raw or {}).get("student") or "")
|
||||
log_id = append_operation_log(
|
||||
operation_logs_path,
|
||||
"course_summary_manual_register",
|
||||
"rejected",
|
||||
"课程小结登记",
|
||||
"已驳回",
|
||||
source_id=source_id,
|
||||
student=student,
|
||||
error=str(exc),
|
||||
@@ -2067,8 +2146,8 @@ def ingest_course_summaries(
|
||||
result["duplicates"] += 1
|
||||
log_id = append_operation_log(
|
||||
operation_logs_path,
|
||||
"course_summary_ingest",
|
||||
"duplicate",
|
||||
"课程小结接收",
|
||||
"重复",
|
||||
batch_id=batch_id,
|
||||
source_id=source_id,
|
||||
student=normalized["student"],
|
||||
@@ -2090,12 +2169,12 @@ def ingest_course_summaries(
|
||||
saved_path=str(saved.get("path") or ""),
|
||||
)
|
||||
result["review_pending"] += 1
|
||||
status_value = "review"
|
||||
status_value = "待审核"
|
||||
task_id = task.get("id")
|
||||
else:
|
||||
register_result = register_class_record_lines(classnotes_path, accounts_path, line=proposed_line)
|
||||
result["auto_registered"] += 1
|
||||
status_value = "auto_registered"
|
||||
status_value = "自动入账"
|
||||
task_id = None
|
||||
backup_id = str(register_result.get("backup_id") or "")
|
||||
|
||||
@@ -2103,7 +2182,7 @@ def ingest_course_summaries(
|
||||
seen_semantic_keys.add(semantic_key)
|
||||
log_id = append_operation_log(
|
||||
operation_logs_path,
|
||||
"course_summary_ingest",
|
||||
"课程小结接收",
|
||||
status_value,
|
||||
batch_id=batch_id,
|
||||
source_id=source_id,
|
||||
@@ -2131,8 +2210,8 @@ def ingest_course_summaries(
|
||||
source_id = str((normalized or raw).get("source_id") or "")
|
||||
log_id = append_operation_log(
|
||||
operation_logs_path,
|
||||
"course_summary_ingest",
|
||||
"rejected",
|
||||
"课程小结接收",
|
||||
"已驳回",
|
||||
batch_id=batch_id,
|
||||
source_id=source_id,
|
||||
student=str((normalized or raw).get("student") or ""),
|
||||
|
||||
@@ -20,9 +20,11 @@ from ..data import (
|
||||
DuplicateRecordError,
|
||||
account_summary,
|
||||
account_to_dict,
|
||||
append_operation_log,
|
||||
create_account,
|
||||
create_teacher,
|
||||
filter_accounts,
|
||||
parse_class_record_line,
|
||||
register_class_record_lines,
|
||||
register_course_summary_texts,
|
||||
register_payment_lines,
|
||||
@@ -47,6 +49,16 @@ async def register_class_records(request: Request, _user: str = Depends(verify_a
|
||||
lines=payload.lines,
|
||||
line=payload.line,
|
||||
)
|
||||
for line in result.get("lines", []):
|
||||
record = parse_class_record_line(str(line))
|
||||
append_operation_log(
|
||||
OPERATION_LOGS_PATH,
|
||||
str(result.get("operation") or "登记上课记录"),
|
||||
"完成",
|
||||
student=record.student,
|
||||
proposed_line=str(line),
|
||||
backup_id=str(result.get("backup_id") or ""),
|
||||
)
|
||||
except DuplicateRecordError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
@@ -60,6 +72,16 @@ async def register_payments(request: Request, _user: str = Depends(verify_admin_
|
||||
payload = await read_register_payload(request)
|
||||
with write_lock:
|
||||
result = register_payment_lines(ACCOUNTS_PATH, lines=payload.lines, line=payload.line)
|
||||
for line in result.get("lines", []):
|
||||
student = str(line).split("-", 1)[0]
|
||||
append_operation_log(
|
||||
OPERATION_LOGS_PATH,
|
||||
str(result.get("operation") or "登记缴费记录"),
|
||||
"完成",
|
||||
student=student,
|
||||
proposed_line=str(line),
|
||||
backup_id=str(result.get("backup_id") or ""),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"ok": True, **result}
|
||||
@@ -144,6 +166,15 @@ def admin_create_teacher(payload: TeacherPayload, _user: str = Depends(verify_ad
|
||||
try:
|
||||
with write_lock:
|
||||
result = create_teacher(TEACHERS_PATH, payload_to_teacher(payload))
|
||||
log_operation = str(result.get("operation") or "新增老师档案")
|
||||
append_operation_log(
|
||||
OPERATION_LOGS_PATH,
|
||||
log_operation,
|
||||
"完成",
|
||||
teacher_id=str(result.get("teacher", {}).get("teacher_id") or ""),
|
||||
teacher=str(result.get("teacher", {}).get("name") or ""),
|
||||
backup_id=str(result.get("backup_id") or ""),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"ok": True, **result}
|
||||
@@ -158,6 +189,15 @@ def admin_update_teacher(teacher_id: str, payload: TeacherPayload, _user: str =
|
||||
teacher_id,
|
||||
payload_to_teacher(payload, teacher_id=payload.teacher_id.strip() or teacher_id),
|
||||
)
|
||||
log_operation = str(result.get("operation") or "修改老师档案")
|
||||
append_operation_log(
|
||||
OPERATION_LOGS_PATH,
|
||||
log_operation,
|
||||
"完成",
|
||||
teacher_id=str(result.get("teacher", {}).get("teacher_id") or ""),
|
||||
teacher=str(result.get("teacher", {}).get("name") or ""),
|
||||
backup_id=str(result.get("backup_id") or ""),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"ok": True, **result}
|
||||
@@ -168,6 +208,15 @@ def admin_create_account(payload: AccountPayload, _user: str = Depends(verify_ad
|
||||
try:
|
||||
with write_lock:
|
||||
result = create_account(ACCOUNTS_PATH, payload_to_account(payload))
|
||||
log_operation = str(result.get("operation") or "新增课时账户")
|
||||
append_operation_log(
|
||||
OPERATION_LOGS_PATH,
|
||||
log_operation,
|
||||
"完成",
|
||||
student_id=str(result.get("account", {}).get("student_id") or ""),
|
||||
student=str(result.get("account", {}).get("student") or ""),
|
||||
backup_id=str(result.get("backup_id") or ""),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"ok": True, **result}
|
||||
@@ -178,6 +227,15 @@ def admin_update_account(student_id: str, payload: AccountPayload, _user: str =
|
||||
try:
|
||||
with write_lock:
|
||||
result = update_account(ACCOUNTS_PATH, student_id, payload_to_account(payload))
|
||||
log_operation = str(result.get("operation") or "修改课时账户")
|
||||
append_operation_log(
|
||||
OPERATION_LOGS_PATH,
|
||||
log_operation,
|
||||
"完成",
|
||||
student_id=str(result.get("account", {}).get("student_id") or ""),
|
||||
student=str(result.get("account", {}).get("student") or ""),
|
||||
backup_id=str(result.get("backup_id") or ""),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"ok": True, **result}
|
||||
|
||||
@@ -17,6 +17,7 @@ from ..data import (
|
||||
delete_course_summary,
|
||||
list_admin_tasks,
|
||||
list_operation_logs,
|
||||
migrate_operation_log_labels,
|
||||
query_course_summaries,
|
||||
reject_admin_task,
|
||||
update_course_summary_time,
|
||||
@@ -46,6 +47,7 @@ def admin_operation_logs(
|
||||
student: str = Query(""),
|
||||
_user: str = Depends(verify_admin_auth),
|
||||
):
|
||||
migrate_operation_log_labels(OPERATION_LOGS_PATH)
|
||||
return list_operation_logs(
|
||||
OPERATION_LOGS_PATH,
|
||||
limit=limit,
|
||||
@@ -91,8 +93,8 @@ def admin_approve_task(task_id: int, _user: str = Depends(verify_admin_auth)):
|
||||
task = result.get("task", {})
|
||||
append_operation_log(
|
||||
OPERATION_LOGS_PATH,
|
||||
"admin_task_approve",
|
||||
"approved",
|
||||
"审核批准",
|
||||
"已批准",
|
||||
task_id=task_id,
|
||||
task_type=str(task.get("type") or ""),
|
||||
student=str(task.get("student") or task.get("corrected", {}).get("student") or ""),
|
||||
@@ -111,8 +113,8 @@ def admin_reject_task(task_id: int, _user: str = Depends(verify_admin_auth)):
|
||||
task = reject_admin_task(ADMIN_TASKS_PATH, task_id)
|
||||
append_operation_log(
|
||||
OPERATION_LOGS_PATH,
|
||||
"admin_task_reject",
|
||||
"rejected",
|
||||
"审核驳回",
|
||||
"已驳回",
|
||||
task_id=task_id,
|
||||
task_type=str(task.get("type") or ""),
|
||||
student=str(task.get("student") or task.get("corrected", {}).get("student") or ""),
|
||||
@@ -130,8 +132,8 @@ def admin_update_course_summary_time(summary_id: str, payload: dict, _user: str
|
||||
result = update_course_summary_time(COURSE_SUMMARIES_ROOT, summary_id, str(payload.get("time_range") or ""))
|
||||
append_operation_log(
|
||||
OPERATION_LOGS_PATH,
|
||||
"admin_course_summary_update_time",
|
||||
"updated",
|
||||
"课程小结补齐时间",
|
||||
"已更新",
|
||||
summary_id=summary_id,
|
||||
time_range=str(payload.get("time_range") or ""),
|
||||
backup_id=str(result.get("backup_id") or ""),
|
||||
@@ -148,8 +150,8 @@ def admin_delete_course_summary(summary_id: str, _user: str = Depends(verify_adm
|
||||
result = delete_course_summary(COURSE_SUMMARIES_ROOT, summary_id)
|
||||
append_operation_log(
|
||||
OPERATION_LOGS_PATH,
|
||||
"admin_course_summary_delete",
|
||||
"deleted",
|
||||
"课程小结删除",
|
||||
"已删除",
|
||||
summary_id=summary_id,
|
||||
backup_id=str(result.get("backup_id") or ""),
|
||||
)
|
||||
|
||||
@@ -330,17 +330,29 @@
|
||||
<div class="quick-actions">
|
||||
<select id="logOperation" aria-label="操作类型筛选">
|
||||
<option value="">全部操作</option>
|
||||
<option value="course_summary_ingest">小结接收</option>
|
||||
<option value="admin_task_approve">审核批准</option>
|
||||
<option value="admin_task_reject">审核驳回</option>
|
||||
<option value="登记上课记录">登记上课记录</option>
|
||||
<option value="登记缴费记录">登记缴费记录</option>
|
||||
<option value="新增课时账户">新增课时账户</option>
|
||||
<option value="修改课时账户">修改课时账户</option>
|
||||
<option value="新增老师档案">新增老师档案</option>
|
||||
<option value="修改老师档案">修改老师档案</option>
|
||||
<option value="课程小结接收">课程小结接收</option>
|
||||
<option value="课程小结登记">课程小结登记</option>
|
||||
<option value="审核批准">审核批准</option>
|
||||
<option value="审核驳回">审核驳回</option>
|
||||
<option value="课程小结补齐时间">课程小结补齐时间</option>
|
||||
<option value="课程小结删除">课程小结删除</option>
|
||||
</select>
|
||||
<select id="logStatus" aria-label="操作结果筛选">
|
||||
<option value="">全部结果</option>
|
||||
<option value="auto_registered">自动入账</option>
|
||||
<option value="review">待审核</option>
|
||||
<option value="duplicate">重复</option>
|
||||
<option value="rejected">失败/驳回</option>
|
||||
<option value="approved">已批准</option>
|
||||
<option value="完成">完成</option>
|
||||
<option value="自动入账">自动入账</option>
|
||||
<option value="待审核">待审核</option>
|
||||
<option value="重复">重复</option>
|
||||
<option value="已驳回">失败/驳回</option>
|
||||
<option value="已批准">已批准</option>
|
||||
<option value="已更新">已更新</option>
|
||||
<option value="已删除">已删除</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -134,9 +134,9 @@ function statusClass(status) {
|
||||
}
|
||||
|
||||
function taskStatusClass(status) {
|
||||
if (status === "approved" || status === "auto_registered") return "normal";
|
||||
if (status === "rejected" || status === "duplicate") return "closed";
|
||||
if (status === "conflict") return "debt";
|
||||
if (["approved", "auto_registered", "已批准", "自动入账", "完成", "已更新", "已删除"].includes(status)) return "normal";
|
||||
if (["rejected", "duplicate", "已驳回", "重复"].includes(status)) return "closed";
|
||||
if (status === "conflict" || status === "冲突") return "debt";
|
||||
return "warning";
|
||||
}
|
||||
|
||||
@@ -695,8 +695,12 @@ async function deleteCourseSummary(summaryId) {
|
||||
|
||||
function renderLogDetail(item) {
|
||||
const details = [];
|
||||
if (item.task_type) details.push(`类型:${item.task_type}`);
|
||||
if (item.source_id) details.push(`来源:${item.source_id}`);
|
||||
if (item.student_id) details.push(`学生ID:${item.student_id}`);
|
||||
if (item.teacher_id) details.push(`教师ID:${item.teacher_id}`);
|
||||
if (item.teacher || item.subject) details.push(`老师/科目:${item.teacher || ""} ${item.subject || ""}`.trim());
|
||||
if (item.time_range) details.push(`时间:${item.time_range}`);
|
||||
if (item.proposed_line) details.push(`记录:${item.proposed_line}`);
|
||||
if (Array.isArray(item.reasons) && item.reasons.length) details.push(`原因:${item.reasons.join(";")}`);
|
||||
if (item.error) details.push(`错误:${item.error}`);
|
||||
|
||||
@@ -538,8 +538,10 @@ textarea:focus {
|
||||
|
||||
.table-wrap {
|
||||
max-height: calc(100vh - 292px);
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
overscroll-behavior-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
@@ -1047,6 +1049,9 @@ td {
|
||||
|
||||
.table-wrap {
|
||||
max-height: none;
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
overscroll-behavior-y: auto;
|
||||
}
|
||||
|
||||
.inline-account-grid {
|
||||
|
||||
Reference in New Issue
Block a user