Update teacher profiles and record summaries

This commit is contained in:
Codex
2026-06-16 01:09:51 +08:00
parent a7fbe91266
commit 2785f3eb41
18 changed files with 1088 additions and 40 deletions
+539 -5
View File
@@ -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),