Spaces:
Running
Running
File size: 21,319 Bytes
acb3189 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 | """The student's own classes, as dated calendar occurrences.
This is what the Grand Calendar's **My classes** layer draws: the one layer that
isn't collected campus data but the student's own courses, expanded out of the
syllabi they have confirmed β class meetings, office hours, exams and due dates.
`payload()` is a pure function of (syllabi, calendar), with no clock and no
storage, so the expansion is testable at a fixed date against a fixed calendar.
Four rules it inherits from the rest of the syllabus pipeline:
- **`reviewed` is the gate.** Only records the student confirmed are expanded
(`schema.ship_ready`). An unreviewed parse is counted, never drawn β a wrong exam
date a student plans around is worse than no exam date.
- **Never invent a recurrence.** A weekly meeting is only placed inside a term whose
anchors were actually derived from the academic calendar. A term the university
hasn't published class dates for (`spring-2027`, today) yields *no* meetings and an
entry in `unplaced` instead, so the calendar can say why rather than drawing a
plausible, wrong grid.
- **Exams and due dates are absolute.** They carry their own ISO date, so they are
placed as-is even when the term anchors are missing or the date sits outside them.
The student confirmed that date; the review screen already questioned the odd ones.
- **Nothing is dropped in silence.** A meeting with no days, an exam with no date, a
whole term with no anchors β each becomes an `unplaced` row the calendar can
explain, because "my midterm isn't on here" with no reason given is the failure
mode that costs trust.
**Why this is server-side** when `app/calendar.js` otherwise reads `/kb/` straight
from the browser: the recurrence needs the term anchors, and those are derived in
`syllabus/terms.py` from title text (`"Undergraduate examinations and reading days,
Dec. 11-19"`) by rules a JavaScript copy would drift from within one term. One
implementation, in the language the tests are in.
Deliberately separate from `today.py`, which also reads syllabi: that answers "what
is on *today*, what is due *this week*" and carries a dashboard's suppression and
urgency rules; this answers "place every occurrence on a grid". Both gate on
`ship_ready`, and both format times as `HH:MM` for their surface to render.
"""
from __future__ import annotations
import logging
from datetime import date, timedelta
from .syllabus import schema as syllabus_schema
from .syllabus import terms as syllabus_terms
log = logging.getLogger("foresight.schedule")
# The kinds of thing a syllabus puts on a calendar. `class` and `office_hours`
# recur weekly and need term anchors; `exam` and `assignment` are single dated
# points and don't.
CLASS = "class"
OFFICE_HOURS = "office_hours"
EXAM = "exam"
ASSIGNMENT = "assignment"
RECURRING = (CLASS, OFFICE_HOURS)
# Why something couldn't be placed. Codes, not sentences: the wording lives with the
# rest of the calendar's copy in `app/calendar.js`.
NO_TERM_DATES = "no_term_dates" # the university hasn't published the term yet
NO_DAYS = "no_days" # a recurring row with nothing to recur on
NO_DATE = "no_date" # an exam or assignment with no usable date
# Safety bounds, not product limits. Eight syllabi with a couple of meeting rows each
# over a 15-week term lands near 500 occurrences, so hitting either of these means a
# bad anchor (a `classes_end` years out) rather than a busy student β and a runaway
# loop is how a bad date turns into a hung request.
MAX_OCCURRENCES = 3000
MAX_TERM_DAYS = 220
# --- helpers ----------------------------------------------------------------
def _day(value) -> date | None:
try:
return date.fromisoformat(str(value)[:10])
except (TypeError, ValueError):
return None
def _rows(value) -> list:
"""A section's rows, or nothing β stored records come from client-editable JSON."""
return value if isinstance(value, list) else []
def _text(value) -> str | None:
value = value.strip() if isinstance(value, str) else None
return value or None
def _where(row: dict) -> str | None:
"""The most specific location a syllabus row actually carries.
Same rule as `today.py`: the verbatim string a student can read off their
syllabus beats the room we parsed out of it.
"""
return _text(row.get("location_raw")) or _text(row.get("room"))
def _labeled(code: str | None, name: str | None) -> str:
""""BSCI 1510 β Midterm 1", or whichever half exists."""
if code and name:
return f"{code} β {name}"
return code or name or ""
def _meeting_span(bounds: dict) -> tuple[date | None, date | None]:
"""The first and last day a weekly meeting may be placed on, or (None, None).
Classes stop at the last day of classes: the exam period is not class time, and a
final has its own date on the syllabus. When the calendar published no last day of
classes, the day before exams begin is the next best truth and the end of the
semester the last resort β but a missing *start* is never guessed around, because
there is nothing to count a week from.
"""
begin = _day(bounds.get("classes_begin"))
end = _day(bounds.get("classes_end"))
if end is None:
exams = _day(bounds.get("exams_begin"))
end = exams - timedelta(days=1) if exams else _day(bounds.get("term_end"))
if begin is None or end is None or end < begin:
return None, None
if (end - begin).days > MAX_TERM_DAYS:
log.warning("schedule: %s spans %d days (%s β %s) β refusing to expand "
"weekly meetings against anchors that far apart",
bounds.get("term"), (end - begin).days, begin, end)
return None, None
return begin, end
def _break_ranges(bounds: dict) -> list[tuple[date, date]]:
"""Fall break, Thanksgiving, spring break β days classes don't meet."""
out = []
for brk in bounds.get("breaks") or []:
start, end = _day(brk.get("start")), _day(brk.get("end"))
if start and end and end >= start:
out.append((start, end))
return out
def _meeting_days(days, span: tuple[date, date],
breaks: list[tuple[date, date]]) -> list[date]:
"""Every day in the term this weekly slot actually meets."""
wanted = {syllabus_schema.DAYS.index(d) for d in _rows(days)
if d in syllabus_schema.DAYS}
if not wanted:
return []
out, day = [], span[0]
while day <= span[1]:
if day.weekday() in wanted and not any(s <= day <= e for s, e in breaks):
out.append(day)
day += timedelta(days=1)
return out
# --- occurrences ------------------------------------------------------------
def _course(record: dict) -> dict:
"""The fields every occurrence of a course carries, so a popover can name it."""
return {
"syllabus_id": record.get("id"),
"course_code": record.get("course_code"),
"course_title": _text(record.get("course_title")),
"term": record.get("term"),
}
def _occurrence(kind: str, series: str, day: date, record: dict, *, title: str,
start=None, end=None, location=None, building_slug=None,
room=None, weight=None, instructor=None, days=None,
through: date | None = None) -> dict:
"""One dated thing on the grid.
`date`/`start`/`end` stay split β a calendar day and a 24-hour `HH:MM` β rather
than joined into a timestamp, because `app/calendar.js` reads its dates and times
straight out of strings to keep a 9:10 class from drifting into the browser's
timezone. Handing it an offset it would have to strip is how that leaks.
"""
return {
"uid": f"{series}:{day.isoformat()}",
"series": series,
"kind": kind,
"title": title,
"date": day.isoformat(),
"start": start,
"end": end,
"location": location,
"building_slug": building_slug,
"room": room,
"weight": weight,
"instructor": instructor,
# Only on the recurring kinds, so a popover can say "meets Tue, Thu, through
# Dec 9" instead of describing one occurrence as if it were the whole course.
"days": list(days) if days else None,
"through": through.isoformat() if through else None,
**_course(record),
}
def _unplaced(record: dict, reason: str, what: str, label: str | None = None) -> dict:
return {
"course_code": record.get("course_code"),
"term": record.get("term"),
"term_label": label,
"reason": reason,
"what": what,
}
def _recurring(record: dict, span, breaks, term_label) -> tuple[list, list]:
"""Class meetings and office hours, expanded across the term."""
items, unplaced = [], []
instructor = record.get("instructor") if isinstance(record.get("instructor"), dict) else {}
who = _text(instructor.get("name"))
code = record.get("course_code")
# The chip says the course code β it has to fit in a month-view cell, and the
# title, instructor and room are one click away in the popover.
heading = code or _text(record.get("course_title")) or "Class"
slots = [(CLASS, i, row, heading)
for i, row in enumerate(_rows(record.get("meetings")))
if isinstance(row, dict)]
slots += [(OFFICE_HOURS, i, row,
f"{code} office hours" if code else "Office hours")
for i, row in enumerate(_rows(instructor.get("office_hours_slots")))
if isinstance(row, dict)]
for kind, i, row, title in slots:
what = "weekly meetings" if kind == CLASS else "office hours"
if not _rows(row.get("days")):
# Nothing to recur on. A slot the parser read a time but no days from is
# exactly what the review screen's `missing_days` flag is about, so point
# the student back at it rather than dropping the class off the calendar.
unplaced.append(_unplaced(record, NO_DAYS, what, term_label))
continue
if span[0] is None:
unplaced.append(_unplaced(record, NO_TERM_DATES, what, term_label))
continue
# A slot with days but no time is placed as an all-day chip: the student
# confirmed the days, and "Mon Wed Fri, time unknown" is true and useful.
# Office hours fall back to the instructor's office, as `schema.py` does.
location = _where(row) or (
_text(instructor.get("office")) if kind == OFFICE_HOURS else None)
building = row.get("building_slug") or (
instructor.get("office_building_slug") if kind == OFFICE_HOURS else None)
series = f"{kind}:{record.get('id')}:{i}"
for day in _meeting_days(row.get("days"), span, breaks):
items.append(_occurrence(
kind, series, day, record, title=title,
start=_text(row.get("start")), end=_text(row.get("end")),
location=location, building_slug=building,
room=_text(row.get("room")), instructor=who,
days=row.get("days"), through=span[1]))
return items, unplaced
def _coursework(record: dict, term_label) -> tuple[list, list]:
"""Exams and assignment due dates β dated points, placed as written."""
items, unplaced = [], []
code = record.get("course_code")
sections = (
(EXAM, "exams", "date", "Exam"),
(ASSIGNMENT, "assignments", "due", "Assignment"),
)
for kind, section, date_field, fallback in sections:
for i, row in enumerate(_rows(record.get(section))):
if not isinstance(row, dict):
continue
name = _text(row.get("title")) or fallback
day = _day(row.get(date_field))
if day is None:
unplaced.append(_unplaced(record, NO_DATE, name, term_label))
continue
items.append(_occurrence(
kind, f"{kind}:{record.get('id')}:{i}", day, record,
title=_labeled(code, name),
start=_text(row.get("start")), end=_text(row.get("end")),
location=_where(row), building_slug=row.get("building_slug"),
room=_text(row.get("room")), weight=_text(row.get("weight"))))
return items, unplaced
# --- where the classes are -------------------------------------------------
def _place_entry(kind: str, record: dict, row: dict, instructor: dict, room) -> dict:
"""One thing that happens in one building, for the campus map's pin card."""
return {
"kind": kind,
"course_code": record.get("course_code"),
"course_title": _text(record.get("course_title")),
"term": record.get("term"),
"days": [d for d in _rows(row.get("days")) if d in syllabus_schema.DAYS],
"start": _text(row.get("start")),
"end": _text(row.get("end")),
"room": room,
"instructor": _text(instructor.get("name")) if kind == OFFICE_HOURS else None,
}
def _places(ready: list[dict]) -> tuple[list[dict], list[dict]]:
"""Where the student's classes and office hours are, grouped by building.
The campus map's My classes layer: `places` are pins it can show, `unmapped` are
the locations it can't and has to say so about.
Three deliberate choices:
- **Derived from the records, not from `items`.** A room is a fact the syllabus
states; it doesn't depend on whether we could place the term's dates. A course in
a term the university hasn't published still has somewhere it meets.
- **Classes and office hours only.** An exam room is a place too, but this layer
answers "where do I go, week to week" β an exam hall is one date the calendar
already carries, and pinning it would put a room a student visits once beside the
three they visit weekly.
- **A location that didn't resolve is reported, never guessed.** `building_slug` is
null whenever the gazetteer wasn't confident (`schema.resolve_location`), and a
wrong pin is worse than a missing one β so the text goes to `unmapped` for the map
to show as words instead of a pin.
"""
by_slug: dict[str, dict] = {}
unmapped: list[dict] = []
for record in ready:
instructor = record.get("instructor") if isinstance(record.get("instructor"), dict) else {}
office = _text(instructor.get("office"))
rows = [(CLASS, row) for row in _rows(record.get("meetings"))
if isinstance(row, dict)]
rows += [(OFFICE_HOURS, row) for row in _rows(instructor.get("office_hours_slots"))
if isinstance(row, dict)]
for kind, row in rows:
raw, slug, room = _where(row), row.get("building_slug"), _text(row.get("room"))
if kind == OFFICE_HOURS and not raw:
# A slot with no room of its own happens in the office, exactly as the
# calendar places it.
raw, slug, room = (office, instructor.get("office_building_slug"),
_text(instructor.get("office_room")))
if not raw and not slug:
continue # a row with no location says nothing here
entry = _place_entry(kind, record, row, instructor, room)
if not slug:
unmapped.append({"course_code": record.get("course_code"),
"kind": kind, "location": raw,
"term": record.get("term")})
continue
place = by_slug.setdefault(slug, {"building_slug": slug, "location": raw,
"entries": []})
place["entries"].append(entry)
for place in by_slug.values():
place["entries"].sort(key=lambda e: (e["kind"] != CLASS, e["course_code"] or "",
e["start"] or ""))
return ([by_slug[s] for s in sorted(by_slug)], unmapped)
def _term_summary(bounds: dict, span) -> dict:
"""What the calendar may say about a term whose courses it is drawing."""
return {
"term": bounds.get("term"),
"label": bounds.get("label"),
"classes_begin": bounds.get("classes_begin"),
"classes_end": bounds.get("classes_end"),
"exams_begin": bounds.get("exams_begin"),
"exams_end": bounds.get("exams_end"),
"breaks": bounds.get("breaks") or [],
# Whether weekly meetings could be placed at all β the honest version of
# "why is my Spring 2027 class not on here".
"placeable": span[0] is not None,
}
def _course_summary(record: dict, span) -> dict:
"""One row for the calendar's course list, so the layer has a legend.
Times stay `HH:MM`; the frontend formats them with the same `fmtTime` it uses on
every other chip, so one calendar never shows two clock styles.
"""
instructor = record.get("instructor") if isinstance(record.get("instructor"), dict) else {}
meetings = [{"days": [d for d in _rows(row.get("days"))
if d in syllabus_schema.DAYS],
"start": _text(row.get("start")),
"end": _text(row.get("end")),
"location": _where(row)}
for row in _rows(record.get("meetings")) if isinstance(row, dict)]
return {
**_course(record),
"instructor": _text(instructor.get("name")),
"meetings": meetings,
"exams": len([r for r in _rows(record.get("exams")) if isinstance(r, dict)]),
"assignments": len([r for r in _rows(record.get("assignments"))
if isinstance(r, dict)]),
"placeable": span[0] is not None,
}
def payload(syllabi: list | None, calendar: list | None = None) -> dict:
"""Everything the My classes layer draws, from the student's confirmed syllabi.
Returns:
{"items": [occurrence, ...], # every dated thing, sorted
"courses": [{...}], # one row per confirmed course
"terms": [{...}], # the anchors each course was placed against
"places": [{...}], # buildings their classes are in (the map)
"unplaced": [{...}], # what couldn't be placed, and why
"unmapped": [{...}], # locations that resolved to no building
"unreviewed": <int>} # uploaded, saved, awaiting confirmation
Two surfaces read this: the calendar's grid takes `items`, the campus map's My
classes layer takes `places`. One endpoint rather than two because it is one
question β "where does this student have to be, and when" β and a second route
would re-read the same file and re-derive the same anchors.
Everything is returned for every term the student has a syllabus for, not a date
window: a term is a few hundred occurrences, and the calendar navigates freely in
both directions, so a window would mean a request per swipe of the month arrow.
"""
records = syllabi if isinstance(syllabi, list) else []
ready = syllabus_schema.ship_ready(records)
cal = calendar if calendar is not None else syllabus_terms.load_calendar()
anchors: dict[str, dict] = {}
spans: dict[str, tuple] = {}
def bounds_for(term: str) -> tuple[dict, tuple]:
if term not in anchors:
anchors[term] = syllabus_terms.bounds(term, cal)
spans[term] = _meeting_span(anchors[term])
return anchors[term], spans[term]
items: list[dict] = []
courses: list[dict] = []
unplaced: list[dict] = []
for record in ready:
bounds, span = bounds_for(record.get("term") or "")
courses.append(_course_summary(record, span))
rows, missed = _recurring(record, span, _break_ranges(bounds), bounds.get("label"))
items += rows
unplaced += missed
rows, missed = _coursework(record, bounds.get("label"))
items += rows
unplaced += missed
if len(items) > MAX_OCCURRENCES:
# Truncating in silence would read as "your term ends in October", so say it
# here and let the caller's log carry it.
log.warning("schedule: %d occurrences from %d syllabi β truncated to %d",
len(items), len(ready), MAX_OCCURRENCES)
items = items[:MAX_OCCURRENCES]
items.sort(key=lambda i: (i["date"], i["start"] or "", i["title"]))
places, unmapped = _places(ready)
return {
"items": items,
"courses": courses,
"terms": [_term_summary(anchors[t], spans[t]) for t in sorted(anchors) if t],
"places": places,
"unmapped": unmapped,
# Same thing said twice is noise: one line per (course, reason, what).
"unplaced": list({tuple(sorted(u.items())): u for u in unplaced}.values()),
# Saved but not confirmed. The calendar shows nothing from these β and a
# student who uploaded a syllabus and never finished the review deserves to be
# told that, rather than wondering where their classes went.
"unreviewed": len(records) - len(ready),
}
|