Skip to content

Commit 3d0803c

Browse files
committed
feat: persist course sync field diffs
1 parent a3ea142 commit 3d0803c

3 files changed

Lines changed: 221 additions & 39 deletions

File tree

ferry/database/generate_changelog.py

Lines changed: 167 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from pathlib import Path
33
from time import gmtime, strftime
44
from typing import cast, Any, TypedDict, Callable
5+
import numpy as np
56
import networkx as nx
67

78

@@ -323,6 +324,171 @@ def register_junction_changes(
323324
)
324325

325326

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+
326492
def print_courses_diff(
327493
diff: dict[str, DiffRecord],
328494
tables_old: dict[str, pd.DataFrame],
@@ -396,45 +562,9 @@ def print_courses_diff(
396562

397563
# Process changed courses with indexed lookups
398564
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)
425566
# Note: course_meetings uses UPSERT logic, so junction change tracking is skipped
426567
# 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-
)
438568
prof_info = (
439569
tables["professors"]
440570
.set_index("professor_id")

ferry/database/models.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,12 @@ class Course(BaseModel):
305305
comment="[computed] Whether last enrollment offering is with same professor as current.",
306306
)
307307

308+
last_sync_diff = Column(
309+
JSONB,
310+
comment="Per-field old/new values from the most recent Ferry sync for this course (user-visible changes only). Null when nothing relevant changed in the last run.",
311+
nullable=True,
312+
)
313+
308314

309315
class Listing(BaseModel):
310316
"""

ferry/database/sync_db_courses.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,14 @@
99
from psycopg2.extensions import register_adapter, AsIs
1010

1111
from ferry.database import Database
12-
from .generate_changelog import print_diff, computed_columns, primary_keys, DiffRecord
12+
from .generate_changelog import (
13+
print_diff,
14+
computed_columns,
15+
primary_keys,
16+
DiffRecord,
17+
build_course_id_to_changes,
18+
serialize_course_id_to_changes,
19+
)
1320

1421

1522
register_adapter(np.int64, AsIs)
@@ -47,7 +54,8 @@ def get_tables_from_db(database_connect_string: str) -> dict[str, pd.DataFrame]:
4754

4855
return {
4956
table_name: pd.read_sql_table(table_name, con=conn).drop(
50-
columns=["time_added", "last_updated"], errors="ignore"
57+
columns=["time_added", "last_updated", "last_sync_diff"],
58+
errors="ignore",
5159
)
5260
for table_name in primary_keys.keys()
5361
}
@@ -701,6 +709,12 @@ def sync_db_courses(
701709

702710
diff = generate_diff(tables_old_for_diff, tables_for_diff)
703711

712+
course_sync_diff_by_id = serialize_course_id_to_changes(
713+
build_course_id_to_changes(diff, tables_old, tables),
714+
tables_old,
715+
tables,
716+
)
717+
704718
print_diff(diff, tables_old, tables, data_dir / "change_log")
705719

706720
inspector = inspect(db.Engine)
@@ -726,6 +740,10 @@ def sync_db_courses(
726740
)
727741
)
728742

743+
course_col_names = {c["name"] for c in inspector.get_columns("courses")}
744+
if "last_sync_diff" not in course_col_names:
745+
conn.execute(text("ALTER TABLE courses ADD COLUMN last_sync_diff JSONB"))
746+
729747
# Process tables in dependency order (buildings before locations)
730748
location_mapping = {}
731749
for table_name in tables_order_add:
@@ -812,6 +830,34 @@ def sync_db_courses(
812830
commit_deletions(table_name, deleted_rows, conn)
813831
print("\033[F", end="")
814832

833+
changed_course_ids = list(course_sync_diff_by_id.keys())
834+
if changed_course_ids:
835+
ids_sql = ",".join(str(int(cid)) for cid in changed_course_ids)
836+
conn.execute(
837+
text(
838+
f"UPDATE courses SET last_sync_diff = NULL "
839+
f"WHERE last_sync_diff IS NOT NULL AND course_id NOT IN ({ids_sql})"
840+
)
841+
)
842+
else:
843+
conn.execute(
844+
text(
845+
"UPDATE courses SET last_sync_diff = NULL WHERE last_sync_diff IS NOT NULL"
846+
)
847+
)
848+
849+
upd = text(
850+
"UPDATE courses SET last_sync_diff = CAST(:payload AS jsonb) WHERE course_id = :course_id"
851+
)
852+
for course_id, payload in course_sync_diff_by_id.items():
853+
conn.execute(
854+
upd,
855+
{
856+
"course_id": int(course_id),
857+
"payload": ujson.dumps(payload),
858+
},
859+
)
860+
815861
# Print row counts for each table.
816862
print("\n[Table Statistics]")
817863
with db.Engine.begin() as conn:

0 commit comments

Comments
 (0)