diff --git a/app/.env.example b/app/.env.example index 38cc04c..46fa570 100644 --- a/app/.env.example +++ b/app/.env.example @@ -10,6 +10,7 @@ INGEST_AUTH_TOKEN=change-this-ingest-token CLASSNOTES_PATH=/data/classnotes.txt ACCOUNTS_PATH=/data/学生课时账户.md +TEACHERS_PATH=/data/教师档案.md ADMIN_TASKS_PATH=/data/admin_tasks.json COURSE_SUMMARIES_ROOT=/data/course_summaries COURSE_SUMMARY_STATE_PATH=/data/course_summary_state.json diff --git a/app/README.md b/app/README.md index 4dc8e69..d1c097c 100644 --- a/app/README.md +++ b/app/README.md @@ -120,6 +120,7 @@ docker compose exec xsk-education-management python scripts/import_course_summar ```text /root/新时空教务管理系统/data/classnotes.txt /root/新时空教务管理系统/data/学生课时账户.md +/root/新时空教务管理系统/data/教师档案.md /root/新时空教务管理系统/data/course_summaries/ /root/新时空教务管理系统/data/course_summary_state.json /root/新时空教务管理系统/data/operation_logs.jsonl @@ -198,6 +199,9 @@ docker compose up -d - `GET /api/accounts/王鑫鹏`:单个学生账户。 - `POST /api/admin/accounts`:管理后台新增课时账户。 - `PUT /api/admin/accounts/{student_id}`:管理后台修改课时账户。 +- `GET /api/admin/teachers`:管理后台读取老师档案。 +- `POST /api/admin/teachers`:管理后台新增老师档案。 +- `PUT /api/admin/teachers/{teacher_id}`:管理后台修改老师档案。 - `POST /api/corrections`:课程记录页提交纠错审核。 - `GET /api/admin/tasks`:管理后台查看审核任务。 - `POST /api/admin/tasks/{task_id}/approve`:批准纠错并写入正式上课记录。 diff --git a/app/app/api_utils.py b/app/app/api_utils.py index d637b32..daa1c82 100644 --- a/app/app/api_utils.py +++ b/app/app/api_utils.py @@ -6,9 +6,9 @@ from pathlib import Path from fastapi import HTTPException, Request from pydantic import ValidationError -from .config import ACCOUNTS_PATH, CLASSNOTES_PATH -from .data import Account, Payment, read_accounts, read_classnotes -from .schemas import AccountPayload, RegisterLinesPayload +from .config import ACCOUNTS_PATH, CLASSNOTES_PATH, TEACHERS_PATH +from .data import Account, Payment, Teacher, read_accounts, read_classnotes, read_teachers +from .schemas import AccountPayload, RegisterLinesPayload, TeacherPayload async def read_register_payload(request: Request) -> RegisterLinesPayload: @@ -49,6 +49,12 @@ def load_accounts(): return read_accounts(ACCOUNTS_PATH) +def load_teachers(): + if not TEACHERS_PATH.exists(): + return [] + return read_teachers(TEACHERS_PATH) + + def file_meta(path: Path) -> dict: if not path.exists(): return {"exists": False, "path": str(path)} @@ -70,3 +76,14 @@ def payload_to_account(payload: AccountPayload, student_id: str | None = None) - account_status=payload.account_status, note=payload.note, ) + + +def payload_to_teacher(payload: TeacherPayload, teacher_id: str | None = None) -> Teacher: + return Teacher( + teacher_id=teacher_id if teacher_id is not None else payload.teacher_id, + name=payload.name, + alias=payload.alias, + subjects=payload.subjects, + status=payload.status, + note=payload.note, + ) diff --git a/app/app/config.py b/app/app/config.py index 646e099..ddd266f 100644 --- a/app/app/config.py +++ b/app/app/config.py @@ -10,6 +10,7 @@ STATIC_DIR = APP_DIR / "static" CLASSNOTES_PATH = Path(os.getenv("CLASSNOTES_PATH", "/data/classnotes.txt")) ACCOUNTS_PATH = Path(os.getenv("ACCOUNTS_PATH", "/data/学生课时账户.md")) +TEACHERS_PATH = Path(os.getenv("TEACHERS_PATH", "/data/教师档案.md")) ADMIN_TASKS_PATH = Path(os.getenv("ADMIN_TASKS_PATH", "/data/admin_tasks.json")) COURSE_SUMMARIES_ROOT = Path(os.getenv("COURSE_SUMMARIES_ROOT", "/data/course_summaries")) COURSE_SUMMARY_STATE_PATH = Path(os.getenv("COURSE_SUMMARY_STATE_PATH", "/data/course_summary_state.json")) diff --git a/app/app/data.py b/app/app/data.py index ccef0c9..593f92b 100644 --- a/app/app/data.py +++ b/app/app/data.py @@ -22,6 +22,8 @@ from .domain import ( ROLE_WORDS, SUBJECTS, SUBJECT_ALIASES, + TEACHER_STATUSES, + Teacher, UNKNOWN_SUBJECTS, UNKNOWN_TEACHERS, WEEKDAYS, @@ -95,6 +97,11 @@ def format_account_row(account: Account) -> str: ) +def format_teacher_row(teacher: Teacher) -> str: + subjects = "、".join(teacher.subjects) + return f"| {teacher.teacher_id} | {teacher.name} | {teacher.alias} | {subjects} | {teacher.status} | {teacher.note} |" + + def parse_payments(text: str) -> list[Payment]: payments: list[Payment] = [] if not text.strip(): @@ -135,6 +142,34 @@ def validate_account(account: Account) -> Account: ) +def validate_teacher(teacher: Teacher) -> Teacher: + teacher_id = teacher.teacher_id.strip() + name = canonical_name(teacher.name) + alias = teacher.alias.strip() + subjects = [normalize_subject(subject) for subject in teacher.subjects if normalize_subject(subject)] + deduped_subjects = list(dict.fromkeys(subjects)) + status = teacher.status.strip() + note = teacher.note.strip() + if not re.fullmatch(r"T\d{3}", teacher_id): + raise ValueError("教师ID格式应为 T001 这样的三位编号") + if not name: + raise ValueError("教师姓名不能为空") + if "|" in name or "|" in alias or "|" in note: + raise ValueError("教师姓名、别名和备注不能包含 |") + if not deduped_subjects: + raise ValueError("任教学科不能为空") + if status not in TEACHER_STATUSES: + raise ValueError("教师状态必须是 在岗 或 离职") + return Teacher( + teacher_id=teacher_id, + name=name, + alias=alias, + subjects=deduped_subjects, + status=status, + note=note, + ) + + def read_classnotes(path: Path) -> list[ClassRecord]: records: list[ClassRecord] = [] with path.open("r", encoding="utf-8") as handle: @@ -166,7 +201,8 @@ def read_accounts(path: Path) -> list[Account]: with path.open("r", encoding="utf-8") as handle: for line_number, raw_line in enumerate(handle, start=1): line = raw_line.strip() - if not line.startswith("|") or line.startswith("|---") or "学生ID" in line: + compact = line.strip("|").replace(" ", "").replace("|", "") + if not line.startswith("|") or set(compact) <= {"-"} or "学生ID" in line: continue parts = [part.strip() for part in line.strip("|").split("|")] if len(parts) < 5: @@ -188,6 +224,51 @@ def read_accounts(path: Path) -> list[Account]: return accounts +def parse_teacher_subjects(text: str) -> list[str]: + subjects: list[str] = [] + for item in re.split(r"[、,,\s]+", text.strip()): + subject = normalize_subject(item) + if subject and subject not in subjects: + subjects.append(subject) + return subjects + + +def read_teachers(path: Path) -> list[Teacher]: + if not path.exists(): + return [] + teachers: list[Teacher] = [] + with path.open("r", encoding="utf-8") as handle: + for line_number, raw_line in enumerate(handle, start=1): + line = raw_line.strip() + compact = line.strip("|").replace(" ", "").replace("|", "") + if not line.startswith("|") or set(compact) <= {"-"} or "教师ID" in line: + continue + parts = [part.strip() for part in line.strip("|").split("|")] + if len(parts) == 5: + teacher_id, name, subjects, status, note = parts + alias = "" + elif len(parts) >= 6: + teacher_id, name, alias, subjects, status, note = parts[:6] + else: + continue + try: + teachers.append( + validate_teacher( + Teacher( + teacher_id=teacher_id, + name=canonical_name(name), + alias=alias.strip(), + subjects=parse_teacher_subjects(subjects), + status=status, + note=note, + ) + ) + ) + except ValueError as exc: + raise ValueError(f"{path}:{line_number} 教师档案错误: {exc}") from exc + return teachers + + def parse_class_record_line(line: str) -> ClassRecord: raw = line.strip() match = CLASSNOTE_RE.fullmatch(raw) @@ -303,7 +384,8 @@ def replace_account_lines(original_text: str, accounts_by_id: dict[str, Account] output: list[str] = [] for line in lines: stripped = line.strip() - if stripped.startswith("|") and not stripped.startswith("|---") and "学生ID" not in stripped: + compact = stripped.strip("|").replace(" ", "").replace("|", "") + if stripped.startswith("|") and not set(compact) <= {"-"} and "学生ID" not in stripped: parts = [part.strip() for part in stripped.strip("|").split("|")] if parts and parts[0] in accounts_by_id: output.append(format_account_row(accounts_by_id[parts[0]])) @@ -318,7 +400,8 @@ def append_account_line(original_text: str, account: Account) -> str: 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: + compact = stripped.strip("|").replace(" ", "").replace("|", "") + if stripped.startswith("|") and not set(compact) <= {"-"} 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 "" @@ -331,7 +414,8 @@ def replace_single_account_line(original_text: str, old_student_id: str, account output: list[str] = [] for line in lines: stripped = line.strip() - if stripped.startswith("|") and not stripped.startswith("|---") and "学生ID" not in stripped: + compact = stripped.strip("|").replace(" ", "").replace("|", "") + if stripped.startswith("|") and not set(compact) <= {"-"} 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)) @@ -359,6 +443,141 @@ def write_accounts(path: Path, accounts: list[Account]) -> None: atomic_write_text(path, replace_account_lines(original_text, accounts_by_id)) +def next_teacher_id(teachers: list[Teacher]) -> str: + values = [] + for teacher in teachers: + match = re.fullmatch(r"T(\d{3})", teacher.teacher_id) + if match: + values.append(int(match.group(1))) + return f"T{(max(values) if values else 0) + 1:03d}" + + +def replace_teacher_lines(original_text: str, teachers_by_id: dict[str, Teacher]) -> str: + lines = original_text.splitlines() + output: list[str] = [] + for line in lines: + stripped = line.strip() + compact = stripped.strip("|").replace(" ", "").replace("|", "") + if stripped.startswith("|") and not set(compact) <= {"-"} and "教师ID" not in stripped: + parts = [part.strip() for part in stripped.strip("|").split("|")] + if parts and parts[0] in teachers_by_id: + output.append(format_teacher_row(teachers_by_id[parts[0]])) + continue + output.append(line) + trailing_newline = "\n" if original_text.endswith("\n") else "" + return "\n".join(output) + trailing_newline + + +def append_teacher_line(original_text: str, teacher: Teacher) -> str: + if not original_text.strip(): + original_text = ( + "| 教师ID | 教师姓名 | 别名 | 任教学科 | 状态 | 备注 |\n" + "| ------ | -------- | ---- | -------- | ------ | ---- |\n" + ) + lines = original_text.splitlines() + insert_at = len(lines) + for index, line in enumerate(lines): + stripped = line.strip() + compact = stripped.strip("|").replace(" ", "").replace("|", "") + if stripped.startswith("|") and not set(compact) <= {"-"} and "教师ID" not in stripped: + insert_at = index + 1 + lines.insert(insert_at, format_teacher_row(teacher)) + trailing_newline = "\n" if original_text.endswith("\n") else "" + return "\n".join(lines) + trailing_newline + + +def replace_single_teacher_line(original_text: str, old_teacher_id: str, teacher: Teacher) -> str: + lines = original_text.splitlines() + replaced = False + output: list[str] = [] + for line in lines: + stripped = line.strip() + compact = stripped.strip("|").replace(" ", "").replace("|", "") + if stripped.startswith("|") and not set(compact) <= {"-"} and "教师ID" not in stripped: + parts = [part.strip() for part in stripped.strip("|").split("|")] + if parts and parts[0] == old_teacher_id: + output.append(format_teacher_row(teacher)) + replaced = True + continue + output.append(line) + if not replaced: + raise ValueError(f"未找到教师档案: {old_teacher_id}") + trailing_newline = "\n" if original_text.endswith("\n") else "" + return "\n".join(output) + trailing_newline + + +def write_teachers(path: Path, teachers: list[Teacher]) -> None: + original_text = path.read_text(encoding="utf-8") if path.exists() else "" + teachers_by_id = {teacher.teacher_id: teacher for teacher in teachers} + atomic_write_text(path, replace_teacher_lines(original_text, teachers_by_id)) + + +def create_teacher(path: Path, teacher: Teacher) -> dict: + teachers = read_teachers(path) + teacher = validate_teacher(replace(teacher, teacher_id=next_teacher_id(teachers))) + if any(item.teacher_id == teacher.teacher_id for item in teachers): + raise ValueError(f"教师ID已存在: {teacher.teacher_id}") + original_text = path.read_text(encoding="utf-8") if path.exists() else "" + backup_dir = create_data_backup("admin-create-teacher", {path: original_text}, [format_teacher_row(teacher)]) + atomic_write_text(path, append_teacher_line(original_text, teacher)) + try: + prune_data_backups(backup_dir.parent) + except OSError: + pass + return {"teacher": teacher_to_dict(teacher), "backup_id": backup_dir.name} + + +def update_teacher(path: Path, old_teacher_id: str, teacher: Teacher) -> dict: + old_teacher_id = old_teacher_id.strip() + teachers = read_teachers(path) + teacher = validate_teacher(teacher) + if not any(item.teacher_id == old_teacher_id for item in teachers): + raise ValueError(f"未找到教师档案: {old_teacher_id}") + if teacher.teacher_id != old_teacher_id and any(item.teacher_id == teacher.teacher_id for item in teachers): + raise ValueError(f"教师ID已存在: {teacher.teacher_id}") + original_text = path.read_text(encoding="utf-8") if path.exists() else "" + backup_dir = create_data_backup("admin-update-teacher", {path: original_text}, [old_teacher_id, format_teacher_row(teacher)]) + atomic_write_text(path, replace_single_teacher_line(original_text, old_teacher_id, teacher)) + try: + prune_data_backups(backup_dir.parent) + except OSError: + pass + return {"teacher": teacher_to_dict(teacher), "backup_id": backup_dir.name} + + +def teacher_to_dict(teacher: Teacher) -> dict: + return { + "teacher_id": teacher.teacher_id, + "name": teacher.name, + "alias": teacher.alias, + "display_name": teacher.alias or teacher.name, + "subjects": teacher.subjects, + "status": teacher.status, + "note": teacher.note, + } + + +def teacher_alias_map(teachers: list[Teacher]) -> dict[str, str]: + mapping: dict[str, str] = {} + for teacher in teachers: + display = teacher.alias or teacher.name + mapping[teacher.name] = display + if teacher.alias: + mapping[teacher.alias] = display + mapping[teacher.teacher_id] = display + return mapping + + +def teacher_name_map(teachers: list[Teacher]) -> dict[str, Teacher]: + mapping: dict[str, Teacher] = {} + for teacher in teachers: + mapping[teacher.name] = teacher + if teacher.alias: + mapping[teacher.alias] = teacher + mapping[teacher.teacher_id] = teacher + return mapping + + def create_account(path: Path, account: Account) -> dict: accounts = read_accounts(path) account = validate_account(replace(account, student_id=next_student_id(accounts))) @@ -609,6 +828,71 @@ def submit_deletion_tasks(tasks_path: Path, items: list[dict]) -> dict: return {"submitted": len(created), "items": [task_to_dict(task) for task in created]} +def resolve_teacher_input(value: str, teachers: list[Teacher]) -> str: + text = value.strip() + if not text: + raise ValueError("老师不能为空") + matches = [ + teacher + for teacher in teachers + if text in {teacher.teacher_id, teacher.name, teacher.alias} + ] + if len(matches) == 1: + return matches[0].name + if len(matches) > 1: + raise ValueError(f"老师别名不唯一,请在后台修正别名: {text}") + return canonical_name(text) + + +def class_record_from_public_item(original: ClassRecord, item: dict, teachers: list[Teacher]) -> ClassRecord: + date_text = str(item.get("date") or original.date) + time_text = str(item.get("time") or original.time) + student = canonical_name(str(item.get("student") or original.student)) + teacher = resolve_teacher_input(str(item.get("teacher") or original.teacher), teachers) + subject = normalize_subject(str(item.get("subject") or original.subject)) + record_date = parse_record_date(date_text) + weekday = WEEKDAYS[record_date.weekday()] + duration_hours = parse_time_range_hours(time_text) + minutes = int(round(duration_hours * 60)) + duration = duration_text_from_minutes(minutes) + return ClassRecord( + date=record_date.strftime("%Y.%m.%d"), + weekday=weekday, + time=normalize_time_range_text(time_text), + student=student, + duration=duration, + duration_hours=duration_hours, + teacher=teacher, + subject=subject, + ) + + +def submit_public_correction_tasks(tasks_path: Path, records: list[ClassRecord], teachers: list[Teacher], items: list[dict]) -> dict: + if not items: + raise ValueError("提交审核的纠错记录不能为空") + internal_items: list[dict] = [] + for item in items: + original = find_record_by_identity(records, str(item.get("record_id") or "")) + corrected = class_record_from_public_item(original, item, teachers) + internal_items.append( + { + "original_line": class_record_to_line(original), + "corrected_line": class_record_to_line(corrected), + } + ) + return submit_correction_tasks(tasks_path, internal_items) + + +def submit_public_deletion_tasks(tasks_path: Path, records: list[ClassRecord], items: list[dict]) -> dict: + if not items: + raise ValueError("提交审核的删除记录不能为空") + internal_items: list[dict] = [] + for item in items: + original = find_record_by_identity(records, str(item.get("record_id") or "")) + internal_items.append({"original_line": class_record_to_line(original)}) + return submit_deletion_tasks(tasks_path, internal_items) + + def list_admin_tasks(tasks_path: Path, status_filter: str = "", task_type: str = "") -> dict: tasks = read_admin_tasks(tasks_path) items = tasks["items"] @@ -1153,12 +1437,41 @@ def parse_course_summary_title_date(title: str) -> str: return "" +def parse_course_summary_time_text(text: str) -> str: + separators = r"[--–—~到至]" + colon_match = re.search( + rf"(\d{{1,2}})\s*[::..]\s*(\d{{1,2}})\d*\s*{separators}\s*(\d{{1,2}})\s*[::..]\s*(\d{{1,2}})\d*", + text, + ) + if colon_match: + start_hour, start_minute, end_hour, end_minute = (int(value) for value in colon_match.groups()) + candidate = f"{start_hour:02d}:{start_minute:02d}-{end_hour:02d}:{end_minute:02d}" + else: + point_match = re.search( + rf"(\d{{1,2}})\s*点\s*(\d{{1,2}})?(?:分)?\s*{separators}\s*(\d{{1,2}})\s*点\s*(\d{{1,2}})?(?:分)?", + text, + ) + if not point_match: + return "" + start_hour = int(point_match.group(1)) + start_minute = int(point_match.group(2) or 0) + end_hour = int(point_match.group(3)) + end_minute = int(point_match.group(4) or 0) + candidate = f"{start_hour:02d}:{start_minute:02d}-{end_hour:02d}:{end_minute:02d}" + try: + return normalize_time_range_text(candidate) + except ValueError: + return "" + + def iter_course_summary_markdown(root: Path) -> Iterable[dict]: if not root.exists(): return for path in sorted(root.rglob("*.md")): if not path.is_file(): continue + if "backups" in path.relative_to(root).parts: + continue text = path.read_text(encoding="utf-8") matches = list(COURSE_SUMMARY_HEADING_RE.finditer(text)) if not matches: @@ -1181,6 +1494,7 @@ def iter_course_summary_markdown(root: Path) -> Iterable[dict]: "id": item_id, "title": title, "date_iso": parse_course_summary_title_date(title), + "time_range": parse_course_summary_time_text(f"{title}\n{body_text}"), "group": group, "body": body_text, "body_preview": body_text[:260] + ("..." if len(body_text) > 260 else ""), @@ -1223,6 +1537,55 @@ def course_summary_matched_fields(item: dict, q: str) -> list[str]: return [label for key, label in labels.items() if q in str(item.get(key, ""))] +def course_summary_record_key(student: str, teacher: str, subject: str, date_iso: str, time_range: str) -> tuple[str, str, str, str, str]: + return ( + canonical_name(student.strip()), + canonical_name(teacher.strip()), + normalize_subject(subject.strip()), + date_iso.strip(), + normalize_time_range_text(time_range) if time_range else "", + ) + + +def course_summary_to_public(item: dict, teachers: list[Teacher]) -> dict: + display_names = teacher_alias_map(teachers) + teacher = str(item.get("teacher") or "") + body = str(item.get("body") or "") + title = str(item.get("title") or "") + display_teacher = display_names.get(teacher, teacher) + if teacher and display_teacher != teacher: + body = body.replace(teacher, display_teacher) + title = title.replace(teacher, display_teacher) + return { + "id": str(item.get("id") or ""), + "title": title, + "date_iso": str(item.get("date_iso") or ""), + "time_range": str(item.get("time_range") or ""), + "teacher": display_teacher, + "subject": str(item.get("subject") or ""), + "body": body, + } + + +def course_summary_index_for_records(root: Path) -> dict[tuple[str, str, str, str, str], list[dict]]: + index: dict[tuple[str, str, str, str, str], list[dict]] = defaultdict(list) + for item in iter_course_summary_markdown(root): + time_range = str(item.get("time_range") or "") + if not time_range: + continue + key = course_summary_record_key( + str(item.get("student") or ""), + str(item.get("teacher") or ""), + str(item.get("subject") or ""), + str(item.get("date_iso") or ""), + time_range, + ) + index[key].append(item) + for values in index.values(): + values.sort(key=lambda item: (str(item.get("title") or ""), str(item.get("id") or ""))) + return index + + def query_course_summaries( root: Path, q: str = "", @@ -1231,6 +1594,7 @@ def query_course_summaries( subject: str = "", date_from: str = "", date_to: str = "", + missing_time: bool = False, limit: int = 200, ) -> dict: normalized_from = normalize_filter_date(date_from) @@ -1250,6 +1614,7 @@ def query_course_summaries( normalized_from, normalized_to, ) + and (not missing_time or not str(item.get("time_range") or "")) ] for item in matched: item["matched_fields"] = course_summary_matched_fields(item, keyword) @@ -1271,6 +1636,81 @@ def query_course_summaries( } +def replace_course_summary_block(root: Path, path: Path, summary_id: str, new_title: str | None = None, delete: bool = False) -> dict: + text = path.read_text(encoding="utf-8") + matches = list(COURSE_SUMMARY_HEADING_RE.finditer(text)) + identity = parse_course_summary_file_identity(root, path) + for index, match in enumerate(matches): + title = match.group("title").strip() + start = match.start() + body_start = match.end() + end = matches[index + 1].start() if index + 1 < len(matches) else len(text) + body = text[body_start:end].strip() + body_without_meta = re.sub(r"^(?:>\s+.*\n)+\s*", "", body).strip() + body_text = body_without_meta or body + item_id = sha1_text(f"{identity['relative_path']}|{title}|{index}|{body_text[:200]}", 20) + if item_id != summary_id: + continue + if delete: + new_text = text[:start].rstrip() + "\n\n" + text[end:].lstrip() + else: + if not new_title: + raise ValueError("课程小结标题不能为空") + new_text = f"{text[:match.start('title')]}{new_title}{text[match.end('title'):]}" + atomic_write_text(path, new_text.rstrip() + "\n") + return {"id": summary_id, "title": title, "path": str(path), "deleted": delete, "new_title": new_title or ""} + raise ValueError("未找到课程小结") + + +def find_course_summary_item(root: Path, summary_id: str) -> dict: + for item in iter_course_summary_markdown(root): + if str(item.get("id") or "") == summary_id: + return item + raise ValueError("未找到课程小结") + + +def update_course_summary_time(root: Path, summary_id: str, time_range: str) -> dict: + item = find_course_summary_item(root, summary_id) + normalized_time = normalize_time_range_text(time_range) + path = Path(str(item.get("source_path") or "")) + subject = normalize_subject(str(item.get("subject") or "待核对科目")) or "待核对科目" + date_iso = str(item.get("date_iso") or "") + if not date_iso: + raise ValueError("课程小结缺少日期,不能补齐时间") + old_title = str(item.get("title") or "") + date_prefix = date_iso + new_title = re.sub( + r"^\d{4}[.-]\d{1,2}[.-]\d{1,2}(?:\s+\d{1,2}:\d{2}-\d{1,2}:\d{2})?", + f"{date_prefix} {normalized_time}", + old_title, + ) + if new_title == old_title: + new_title = f"{date_prefix} {normalized_time} {subject}课堂小结" + original = path.read_text(encoding="utf-8") + backup_dir = create_data_backup("admin-update-course-summary-time", {path: original}, [summary_id, new_title]) + result = replace_course_summary_block(root, path, summary_id, new_title=new_title) + result["backup_id"] = backup_dir.name + try: + prune_data_backups(backup_dir.parent) + except OSError: + pass + return result + + +def delete_course_summary(root: Path, summary_id: str) -> dict: + item = find_course_summary_item(root, summary_id) + path = Path(str(item.get("source_path") or "")) + original = path.read_text(encoding="utf-8") + backup_dir = create_data_backup("admin-delete-course-summary", {path: original}, [summary_id]) + result = replace_course_summary_block(root, path, summary_id, delete=True) + result["backup_id"] = backup_dir.name + try: + prune_data_backups(backup_dir.parent) + except OSError: + pass + return result + + def course_summary_path(root: Path, summary: dict) -> Path: student = safe_filename_part(summary["student"]) teacher = safe_filename_part(summary["teacher"] or "待核对老师") @@ -1445,6 +1885,7 @@ def approve_course_summary_task( task["status"] = "approved" task["updated_at"] = datetime.now().isoformat(timespec="seconds") task["reviewed_at"] = task["updated_at"] + task["proposed_line"] = proposed_line task["registered_line"] = proposed_line task["backup_id"] = result.get("backup_id", "") write_admin_tasks(tasks_path, tasks) @@ -1651,7 +2092,6 @@ def ingest_course_summaries( result["review_pending"] += 1 status_value = "review" task_id = task.get("id") - backup_id = "" else: register_result = register_class_record_lines(classnotes_path, accounts_path, line=proposed_line) result["auto_registered"] += 1 @@ -1967,6 +2407,11 @@ def build_query_spec(query: str, records: list[ClassRecord], today: date | None ) +def build_public_query_spec(query: str, records: list[ClassRecord], teachers: list[Teacher], today: date | None = None) -> QuerySpec: + spec = build_query_spec(query, records, today=today) + return replace(spec, raw_query=query) + + def filter_records(records: list[ClassRecord], spec: QuerySpec) -> list[ClassRecord]: matched: list[ClassRecord] = [] for record in records: @@ -2014,6 +2459,28 @@ def summarize_records(records: list[ClassRecord]) -> dict: } +def summarize_public_records(records: list[ClassRecord], teachers: list[Teacher]) -> dict: + display_names = teacher_alias_map(teachers) + by_student: defaultdict[str, float] = defaultdict(float) + by_teacher: defaultdict[str, float] = defaultdict(float) + by_subject: defaultdict[str, float] = defaultdict(float) + for record in records: + by_student[record.student] += record.duration_hours + by_teacher[display_names.get(record.teacher, record.teacher)] += record.duration_hours + by_subject[record.subject] += record.duration_hours + return { + "count": len(records), + "total_hours": round(sum(record.duration_hours for record in records), 2), + "students": dict(sorted((key, round(value, 2)) for key, value in by_student.items())), + "teachers": dict(sorted((key, round(value, 2)) for key, value in by_teacher.items())), + "subjects": dict(sorted((key, round(value, 2)) for key, value in by_subject.items())), + } + + +def record_identity(record: ClassRecord) -> str: + return sha1_text(class_record_to_line(record), 20) + + def record_to_dict(record: ClassRecord) -> dict: return { "date": record.date, @@ -2027,6 +2494,38 @@ def record_to_dict(record: ClassRecord) -> dict: } +def public_record_to_dict( + record: ClassRecord, + teachers: list[Teacher], + summary_index: dict[tuple[str, str, str, str, str], list[dict]] | None = None, +) -> dict: + display_names = teacher_alias_map(teachers) + summary_key = course_summary_record_key( + record.student, + record.teacher, + record.subject, + record.date.replace(".", "-"), + record.time, + ) + summaries = [ + course_summary_to_public(item, teachers) + for item in (summary_index or {}).get(summary_key, []) + ] + return { + "record_id": record_identity(record), + "date": record.date, + "weekday": record.weekday, + "time": record.time, + "student": record.student, + "duration": record.duration, + "duration_hours": record.duration_hours, + "teacher": display_names.get(record.teacher, record.teacher), + "subject": record.subject, + "summary_count": len(summaries), + "summaries": summaries, + } + + def account_to_dict(account: Account) -> dict: return { "student_id": account.student_id, @@ -2062,6 +2561,41 @@ def query_records(records: list[ClassRecord], query: str, limit: int = 200) -> d } +def query_public_records( + records: list[ClassRecord], + teachers: list[Teacher], + query: str, + limit: int = 200, + summaries_root: Path | None = None, +) -> dict: + spec = build_public_query_spec(query, records, teachers) + matched = filter_records(records, spec) if has_filter_condition(spec) else [] + shown = matched[:limit] if limit > 0 else matched + display_names = teacher_alias_map(teachers) + summary_index = course_summary_index_for_records(summaries_root) if summaries_root is not None else {} + return { + "query": { + "raw_query": spec.raw_query, + "date_range": format_date_range(spec), + "students": spec.students, + "teachers": [display_names.get(teacher, teacher) for teacher in spec.teachers], + "subjects": spec.subjects, + }, + "summary": summarize_public_records(matched, teachers), + "records": [public_record_to_dict(record, teachers, summary_index) for record in shown], + "total_records": len(matched), + "shown_records": len(shown), + } + + +def find_record_by_identity(records: list[ClassRecord], record_id: str) -> ClassRecord: + target = record_id.strip() + for record in records: + if record_identity(record) == target: + return record + raise ValueError(f"未找到上课记录: {record_id}") + + def account_summary(accounts: list[Account]) -> dict: result = { "total": len(accounts), diff --git a/app/app/domain.py b/app/app/domain.py index e78517c..a8b51cf 100644 --- a/app/app/domain.py +++ b/app/app/domain.py @@ -26,6 +26,7 @@ FALLBACK_ALIASES = { ACCOUNT_STATUSES = {"正常", "预警", "欠费", "结课", "退费"} WEEKDAYS = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"] UNKNOWN_TEACHERS = {"", "待核对老师", "未知老师"} +TEACHER_STATUSES = {"在岗", "离职"} UNKNOWN_SUBJECTS = {"", "待核对科目", "未知科目"} HIGH_CONFIDENCE_VALUES = {"high", "高", "高置信", "true", "1", "yes"} AUTO_RECOGNITION_SOURCES = {"keyword", "rule", "model", "model_high_confidence", "manual_admin", "大模型高置信识别", "关键词"} @@ -63,6 +64,16 @@ class ClassRecord: subject: str +@dataclass(frozen=True) +class Teacher: + teacher_id: str + name: str + alias: str + subjects: list[str] + status: str + note: str + + @dataclass(frozen=True) class QuerySpec: raw_query: str diff --git a/app/app/routers/accounts.py b/app/app/routers/accounts.py index c7ac811..4116e0f 100644 --- a/app/app/routers/accounts.py +++ b/app/app/routers/accounts.py @@ -2,7 +2,7 @@ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Query, Request -from ..api_utils import file_meta, load_accounts, payload_to_account, read_register_payload +from ..api_utils import file_meta, load_accounts, load_teachers, payload_to_account, payload_to_teacher, read_register_payload from ..auth import verify_accounts_auth, verify_admin_auth from ..config import ( ACCOUNTS_PATH, @@ -11,21 +11,26 @@ from ..config import ( COURSE_SUMMARIES_ROOT, COURSE_SUMMARY_STATE_PATH, OPERATION_LOGS_PATH, + TEACHERS_PATH, write_lock, ) from ..data import ( ACCOUNT_STATUSES, + TEACHER_STATUSES, DuplicateRecordError, account_summary, account_to_dict, create_account, + create_teacher, filter_accounts, register_class_record_lines, register_course_summary_texts, register_payment_lines, + teacher_to_dict, update_account, + update_teacher, ) -from ..schemas import AccountPayload +from ..schemas import AccountPayload, TeacherPayload router = APIRouter() @@ -83,10 +88,13 @@ async def register_course_summaries(request: Request, _user: str = Depends(verif @router.get("/api/account-health") def account_health(_user: str = Depends(verify_accounts_auth)): accounts = load_accounts() + teachers = load_teachers() return { "ok": True, "accounts": file_meta(ACCOUNTS_PATH), + "teachers": file_meta(TEACHERS_PATH), "accounts_count": len(accounts), + "teachers_count": len(teachers), "account_summary": account_summary(accounts), } @@ -119,6 +127,42 @@ def admin_statuses(_user: str = Depends(verify_admin_auth)): return {"account_statuses": sorted(ACCOUNT_STATUSES)} +@router.get("/api/admin/teacher-statuses") +def admin_teacher_statuses(_user: str = Depends(verify_admin_auth)): + return {"teacher_statuses": sorted(TEACHER_STATUSES)} + + +@router.get("/api/admin/teachers") +def admin_teachers(_user: str = Depends(verify_admin_auth)): + return { + "teachers": [teacher_to_dict(teacher) for teacher in load_teachers()], + } + + +@router.post("/api/admin/teachers") +def admin_create_teacher(payload: TeacherPayload, _user: str = Depends(verify_admin_auth)): + try: + with write_lock: + result = create_teacher(TEACHERS_PATH, payload_to_teacher(payload)) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"ok": True, **result} + + +@router.put("/api/admin/teachers/{teacher_id}") +def admin_update_teacher(teacher_id: str, payload: TeacherPayload, _user: str = Depends(verify_admin_auth)): + try: + with write_lock: + result = update_teacher( + TEACHERS_PATH, + teacher_id, + payload_to_teacher(payload, teacher_id=payload.teacher_id.strip() or teacher_id), + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"ok": True, **result} + + @router.post("/api/admin/accounts") def admin_create_account(payload: AccountPayload, _user: str = Depends(verify_admin_auth)): try: diff --git a/app/app/routers/admin.py b/app/app/routers/admin.py index fe46c35..d372407 100644 --- a/app/app/routers/admin.py +++ b/app/app/routers/admin.py @@ -14,10 +14,12 @@ from ..config import ( from ..data import ( append_operation_log, approve_admin_task, + delete_course_summary, list_admin_tasks, list_operation_logs, query_course_summaries, reject_admin_task, + update_course_summary_time, ) @@ -61,6 +63,7 @@ def admin_course_summaries( subject: str = Query(""), date_from: str = Query(""), date_to: str = Query(""), + missing_time: bool = Query(False), limit: int = Query(200, ge=1, le=1000), _user: str = Depends(verify_admin_auth), ): @@ -73,6 +76,7 @@ def admin_course_summaries( subject=subject, date_from=date_from, date_to=date_to, + missing_time=missing_time, limit=limit, ) except ValueError as exc: @@ -117,3 +121,38 @@ def admin_reject_task(task_id: int, _user: str = Depends(verify_admin_auth)): except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return {"ok": True, "task": task} + + +@router.post("/api/admin/course-summaries/{summary_id}/time") +def admin_update_course_summary_time(summary_id: str, payload: dict, _user: str = Depends(verify_admin_auth)): + try: + with write_lock: + 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 ""), + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"ok": True, **result} + + +@router.delete("/api/admin/course-summaries/{summary_id}") +def admin_delete_course_summary(summary_id: str, _user: str = Depends(verify_admin_auth)): + try: + with write_lock: + 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 ""), + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"ok": True, **result} diff --git a/app/app/routers/health.py b/app/app/routers/health.py index 8055b4f..6c887dc 100644 --- a/app/app/routers/health.py +++ b/app/app/routers/health.py @@ -2,7 +2,7 @@ from __future__ import annotations from fastapi import APIRouter, Depends -from ..api_utils import file_meta, load_accounts, load_records +from ..api_utils import file_meta, load_accounts, load_records, load_teachers from ..auth import verify_records_auth from ..config import ( ACCOUNTS_PATH, @@ -10,6 +10,7 @@ from ..config import ( COURSE_SUMMARIES_ROOT, COURSE_SUMMARY_STATE_PATH, OPERATION_LOGS_PATH, + TEACHERS_PATH, ) from ..data import account_summary @@ -21,14 +22,17 @@ router = APIRouter() def health(_user: str = Depends(verify_records_auth)): records = load_records() accounts = load_accounts() + teachers = load_teachers() return { "ok": True, "classnotes": file_meta(CLASSNOTES_PATH), "accounts": file_meta(ACCOUNTS_PATH), + "teachers": file_meta(TEACHERS_PATH), "course_summaries": file_meta(COURSE_SUMMARIES_ROOT), "course_summary_state": file_meta(COURSE_SUMMARY_STATE_PATH), "operation_logs": file_meta(OPERATION_LOGS_PATH), "records_count": len(records), "accounts_count": len(accounts), + "teachers_count": len(teachers), "account_summary": account_summary(accounts), } diff --git a/app/app/routers/records.py b/app/app/routers/records.py index dad2c4c..9e46e17 100644 --- a/app/app/routers/records.py +++ b/app/app/routers/records.py @@ -2,10 +2,15 @@ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Query -from ..api_utils import load_accounts, load_records +from ..api_utils import load_accounts, load_records, load_teachers from ..auth import verify_records_auth -from ..config import ADMIN_TASKS_PATH -from ..data import account_to_dict, query_records, submit_correction_tasks, submit_deletion_tasks +from ..config import ADMIN_TASKS_PATH, COURSE_SUMMARIES_ROOT +from ..data import ( + account_to_dict, + query_public_records, + submit_public_correction_tasks, + submit_public_deletion_tasks, +) from ..schemas import CorrectionSubmitPayload, DeletionSubmitPayload @@ -18,7 +23,7 @@ def records( limit: int = Query(200, ge=1, le=1000), _user: str = Depends(verify_records_auth), ): - return query_records(load_records(), q, limit=limit) + return query_public_records(load_records(), load_teachers(), q, limit=limit, summaries_root=COURSE_SUMMARIES_ROOT) @router.get("/api/student-account/{student}") @@ -32,8 +37,10 @@ def record_student_account(student: str, _user: str = Depends(verify_records_aut @router.post("/api/corrections") def submit_corrections(payload: CorrectionSubmitPayload, _user: str = Depends(verify_records_auth)): try: - result = submit_correction_tasks( + result = submit_public_correction_tasks( ADMIN_TASKS_PATH, + load_records(), + load_teachers(), [item.dict() for item in payload.items], ) except ValueError as exc: @@ -44,8 +51,9 @@ def submit_corrections(payload: CorrectionSubmitPayload, _user: str = Depends(ve @router.post("/api/deletions") def submit_deletions(payload: DeletionSubmitPayload, _user: str = Depends(verify_records_auth)): try: - result = submit_deletion_tasks( + result = submit_public_deletion_tasks( ADMIN_TASKS_PATH, + load_records(), [item.dict() for item in payload.items], ) except ValueError as exc: diff --git a/app/app/schemas.py b/app/app/schemas.py index 80ed36d..ef8618f 100644 --- a/app/app/schemas.py +++ b/app/app/schemas.py @@ -22,9 +22,22 @@ class AccountPayload(BaseModel): note: str = "" +class TeacherPayload(BaseModel): + teacher_id: str = "" + name: str + alias: str = "" + subjects: list[str] = Field(default_factory=list) + status: str = "在岗" + note: str = "" + + class CorrectionItemPayload(BaseModel): - original_line: str - corrected_line: str + record_id: str + date: str + time: str + student: str + teacher: str + subject: str class CorrectionSubmitPayload(BaseModel): @@ -32,7 +45,7 @@ class CorrectionSubmitPayload(BaseModel): class DeletionItemPayload(BaseModel): - original_line: str + record_id: str class DeletionSubmitPayload(BaseModel): diff --git a/app/app/static/admin.html b/app/app/static/admin.html index 1fdd797..087fb28 100644 --- a/app/app/static/admin.html +++ b/app/app/static/admin.html @@ -4,7 +4,7 @@