|
2 | 2 | from pathlib import Path |
3 | 3 | from time import gmtime, strftime |
4 | 4 | from typing import cast, Any, TypedDict, Callable |
| 5 | +import numpy as np |
5 | 6 | import networkx as nx |
6 | 7 |
|
7 | 8 |
|
@@ -323,6 +324,171 @@ def register_junction_changes( |
323 | 324 | ) |
324 | 325 |
|
325 | 326 |
|
| 327 | +def build_course_id_to_changes( |
| 328 | + diff: dict[str, DiffRecord], |
| 329 | + tables_old: dict[str, pd.DataFrame], |
| 330 | + tables: dict[str, pd.DataFrame], |
| 331 | +) -> dict[int, dict[str, tuple[Any, Any]]]: |
| 332 | + """ |
| 333 | + Aggregate per-course field/junction changes the same way as the markdown changelog. |
| 334 | + Used to persist structured diffs (e.g. for email alerts). |
| 335 | + """ |
| 336 | + courses_old_indexed = tables_old["courses"].set_index("course_id") |
| 337 | + courses_new_indexed = tables["courses"].set_index("course_id") |
| 338 | + |
| 339 | + course_id_to_changes: dict[int, dict[str, tuple[Any, Any]]] = {} |
| 340 | + for _, course in diff["courses"]["changed_rows"].iterrows(): |
| 341 | + course_id = cast(int, course["course_id"]) |
| 342 | + if course_id not in course_id_to_changes: |
| 343 | + course_id_to_changes[course_id] = {} |
| 344 | + for column in course["columns_changed"]: |
| 345 | + if column in computed_columns["courses"]: |
| 346 | + continue |
| 347 | + old_val = courses_old_indexed.loc[course_id, column] |
| 348 | + new_val = courses_new_indexed.loc[course_id, column] |
| 349 | + course_id_to_changes[course_id][column] = (old_val, new_val) |
| 350 | + register_junction_changes( |
| 351 | + course_id_to_changes, |
| 352 | + diff, |
| 353 | + tables_old, |
| 354 | + tables, |
| 355 | + "course_professors", |
| 356 | + ) |
| 357 | + register_junction_changes( |
| 358 | + course_id_to_changes, |
| 359 | + diff, |
| 360 | + tables_old, |
| 361 | + tables, |
| 362 | + "course_flags", |
| 363 | + ) |
| 364 | + register_junction_changes( |
| 365 | + course_id_to_changes, |
| 366 | + diff, |
| 367 | + tables_old, |
| 368 | + tables, |
| 369 | + "listings", |
| 370 | + ["course_id"], |
| 371 | + ) |
| 372 | + return course_id_to_changes |
| 373 | + |
| 374 | + |
| 375 | +def _json_prepare_scalar(value: Any) -> Any: |
| 376 | + if value is None: |
| 377 | + return None |
| 378 | + if isinstance(value, np.ndarray): |
| 379 | + return _json_prepare_scalar(value.tolist()) |
| 380 | + if isinstance(value, (list, tuple)): |
| 381 | + return [_json_prepare_scalar(v) for v in value] |
| 382 | + if isinstance(value, dict): |
| 383 | + return {str(k): _json_prepare_scalar(v) for k, v in value.items()} |
| 384 | + if isinstance(value, (np.integer, np.int64, np.int32)): |
| 385 | + return int(value) |
| 386 | + if isinstance(value, (np.floating, np.float64, np.float32)): |
| 387 | + if np.isnan(value): |
| 388 | + return None |
| 389 | + return float(value) |
| 390 | + if isinstance(value, np.bool_): |
| 391 | + return bool(value) |
| 392 | + if isinstance(value, pd.Timestamp): |
| 393 | + return value.isoformat() |
| 394 | + if safe_scalar_na(value): |
| 395 | + return None |
| 396 | + if isinstance(value, (bytes, bytearray)): |
| 397 | + return value.decode("utf-8", errors="replace") |
| 398 | + return value |
| 399 | + |
| 400 | + |
| 401 | +def safe_scalar_na(value: Any) -> bool: |
| 402 | + try: |
| 403 | + return bool(pd.isna(value)) |
| 404 | + except (TypeError, ValueError): |
| 405 | + return False |
| 406 | + |
| 407 | + |
| 408 | +def _listing_rows_for_json(df: pd.DataFrame) -> list[dict[str, Any]]: |
| 409 | + if df.empty: |
| 410 | + return [] |
| 411 | + cols = [c for c in ("listing_id", "season_code", "course_code", "section", "crn") if c in df.columns] |
| 412 | + out: list[dict[str, Any]] = [] |
| 413 | + for rec in df[cols].replace({np.nan: None}).to_dict(orient="records"): |
| 414 | + row = {k: _json_prepare_scalar(v) for k, v in rec.items()} |
| 415 | + out.append(row) |
| 416 | + return out |
| 417 | + |
| 418 | + |
| 419 | +def _serialize_change_pair(old: Any, new: Any, prof_info: pd.DataFrame, flag_info: pd.DataFrame) -> dict[str, Any]: |
| 420 | + if isinstance(old, pd.DataFrame) and isinstance(new, pd.DataFrame): |
| 421 | + if "professor_id" in old.columns and "professor_id" in new.columns: |
| 422 | + old_ids = sorted(int(x) for x in old["professor_id"].tolist()) |
| 423 | + new_ids = sorted(int(x) for x in new["professor_id"].tolist()) |
| 424 | + return { |
| 425 | + "old": { |
| 426 | + "professor_ids": old_ids, |
| 427 | + "names": prof_info.reindex(old_ids)["name"].fillna("").tolist(), |
| 428 | + }, |
| 429 | + "new": { |
| 430 | + "professor_ids": new_ids, |
| 431 | + "names": prof_info.reindex(new_ids)["name"].fillna("").tolist(), |
| 432 | + }, |
| 433 | + } |
| 434 | + if "flag_id" in old.columns and "flag_id" in new.columns: |
| 435 | + old_ids = sorted(int(x) for x in old["flag_id"].tolist()) |
| 436 | + new_ids = sorted(int(x) for x in new["flag_id"].tolist()) |
| 437 | + return { |
| 438 | + "old": { |
| 439 | + "flag_ids": old_ids, |
| 440 | + "texts": flag_info.reindex(old_ids)["flag_text"].fillna("").tolist(), |
| 441 | + }, |
| 442 | + "new": { |
| 443 | + "flag_ids": new_ids, |
| 444 | + "texts": flag_info.reindex(new_ids)["flag_text"].fillna("").tolist(), |
| 445 | + }, |
| 446 | + } |
| 447 | + if "listing_id" in old.columns and "listing_id" in new.columns: |
| 448 | + old_rows = sorted( |
| 449 | + _listing_rows_for_json(old), key=lambda r: r.get("listing_id") or 0 |
| 450 | + ) |
| 451 | + new_rows = sorted( |
| 452 | + _listing_rows_for_json(new), key=lambda r: r.get("listing_id") or 0 |
| 453 | + ) |
| 454 | + return {"old": old_rows, "new": new_rows} |
| 455 | + return {"old": _json_prepare_scalar(old), "new": _json_prepare_scalar(new)} |
| 456 | + |
| 457 | + |
| 458 | +def serialize_course_id_to_changes( |
| 459 | + course_id_to_changes: dict[int, dict[str, tuple[Any, Any]]], |
| 460 | + tables_old: dict[str, pd.DataFrame], |
| 461 | + tables: dict[str, pd.DataFrame], |
| 462 | +) -> dict[int, dict[str, Any]]: |
| 463 | + """ |
| 464 | + Convert build_course_id_to_changes output to JSON-serializable dicts per course_id. |
| 465 | + Omits courses with no remaining keys after serialization (e.g. empty junction pairs). |
| 466 | + """ |
| 467 | + prof_info = ( |
| 468 | + tables["professors"] |
| 469 | + .set_index("professor_id") |
| 470 | + .combine_first(tables_old["professors"].set_index("professor_id")) |
| 471 | + ) |
| 472 | + flag_info = ( |
| 473 | + tables["flags"] |
| 474 | + .set_index("flag_id") |
| 475 | + .combine_first(tables_old["flags"].set_index("flag_id")) |
| 476 | + ) |
| 477 | + result: dict[int, dict[str, Any]] = {} |
| 478 | + for course_id, changes in course_id_to_changes.items(): |
| 479 | + if not changes: |
| 480 | + continue |
| 481 | + serialized_fields: dict[str, Any] = {} |
| 482 | + for field, (old, new) in changes.items(): |
| 483 | + pair = _serialize_change_pair(old, new, prof_info, flag_info) |
| 484 | + if pair["old"] == pair["new"]: |
| 485 | + continue |
| 486 | + serialized_fields[field] = pair |
| 487 | + if serialized_fields: |
| 488 | + result[course_id] = serialized_fields |
| 489 | + return result |
| 490 | + |
| 491 | + |
326 | 492 | def print_courses_diff( |
327 | 493 | diff: dict[str, DiffRecord], |
328 | 494 | tables_old: dict[str, pd.DataFrame], |
@@ -396,45 +562,9 @@ def print_courses_diff( |
396 | 562 |
|
397 | 563 | # Process changed courses with indexed lookups |
398 | 564 | course_updates = "" |
399 | | - course_id_to_changes: dict[int, dict[str, tuple[Any, Any]]] = {} |
400 | | - for _, course in diff["courses"]["changed_rows"].iterrows(): |
401 | | - course_id = cast(int, course["course_id"]) |
402 | | - if course_id not in course_id_to_changes: |
403 | | - course_id_to_changes[course_id] = {} |
404 | | - for column in course["columns_changed"]: |
405 | | - if column in computed_columns["courses"]: |
406 | | - continue |
407 | | - # Use indexed lookup instead of filtering |
408 | | - old_val = courses_old_indexed.loc[course_id, column] |
409 | | - new_val = courses_new_indexed.loc[course_id, column] |
410 | | - course_id_to_changes[course_id][column] = (old_val, new_val) |
411 | | - register_junction_changes( |
412 | | - course_id_to_changes, |
413 | | - diff, |
414 | | - tables_old, |
415 | | - tables, |
416 | | - "course_professors", |
417 | | - ) |
418 | | - register_junction_changes( |
419 | | - course_id_to_changes, |
420 | | - diff, |
421 | | - tables_old, |
422 | | - tables, |
423 | | - "course_flags", |
424 | | - ) |
| 565 | + course_id_to_changes = build_course_id_to_changes(diff, tables_old, tables) |
425 | 566 | # Note: course_meetings uses UPSERT logic, so junction change tracking is skipped |
426 | 567 | # Meeting changes (if any) will not be reflected accurately in the changelog |
427 | | - |
428 | | - register_junction_changes( |
429 | | - course_id_to_changes, |
430 | | - diff, |
431 | | - tables_old, |
432 | | - tables, |
433 | | - "listings", |
434 | | - # Only include changes to the course_id column, because changes to |
435 | | - # other columns are reported by the listings section |
436 | | - ["course_id"], |
437 | | - ) |
438 | 568 | prof_info = ( |
439 | 569 | tables["professors"] |
440 | 570 | .set_index("professor_id") |
|
0 commit comments