commit 3814229f493a9c9bd5fecfc9dcdfb340bfa0bbf6 Author: Benjamin Mako Hill Date: Sat Jun 6 18:57:18 2026 -0700 Initial commit: CSSS 590 attendance tracker Publish review_attendance.py, the email and failed-message templates, and the example config used to track CSSS 590 seminar attendance and send warning DMs through the Canvas Conversations API. README walks through the weekly workflow, the Roll Call CSV quirks worth knowing about, and what must stay out of git. Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78eb95a --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Real configuration (contains Canvas API token + course-specific IDs). +config.toml + +# Student data — never commit any of this. +attendance_reports_*.csv +students_contacted.tsv +attendance_summary.csv + +# Common archival material your working dir may collect. +screenshot-*.png +*.pdf +*.svg + +# Python bytecode. +__pycache__/ +*.pyc + +# md2html preview output. +*.html diff --git a/README.md b/README.md new file mode 100644 index 0000000..9ef4415 --- /dev/null +++ b/README.md @@ -0,0 +1,236 @@ +# csss590-attendance + +A small Python tool for instructors of +[CSSS 590](https://myplan.uw.edu/course/#/courses/CSSS590) +(or any similar attend-N-of-M seminar that uses Canvas Roll Call) to +keep track of attendance week to week, to warn students at risk of +falling below the required count, and to print a final pass/fail +summary at the end of the quarter. + +CSSS 590 is the course that provides credit to folks attending the +weekly [CSSS seminar series](https://csss.uw.edu/seminars) at the +[University of Washington](https://www.uw.edu/). Students must attend +a set number of those seminars to get credit. + +The script reads the weekly attendance export that the [Canvas Roll +Call](FIXME) tool emails you, cross-references it against the live Canvas +enrollment, and sends warning DMs through the Canvas Conversations API. +A small Python script writes a short note to each student—scoped to +the course—in the same language you would have written yourself. + +This started as a one-quarter, one-instructor tool. It is published +in case it is useful to others teaching CSSS 590 or a similar course. + +## What it does + +The single entry point is `review_attendance.py`. It has three modes: + +- **Review (default)**—print a students × sessions attendance grid + and a sorted X/N totals list. No Canvas writes; no email; just a + picture of where the class stands. Useful for the weekly look. + +- **`--send-warning`**—same review output, plus a preview of which + students are at or past the warning threshold (derived as + `sessions_expected − sessions_required`) and the rendered text of the + DM each one would receive. Still no Canvas writes. This is the + inspect-before-you-commit step. + +- **`--send-warning -f`**—actually POSTs each warning to the Canvas + Conversations API as a 1:1 DM scoped to the course, and appends each + recipient to `students_contacted.tsv` so subsequent runs skip them. + The `-f` is intentional friction: nothing here can be un-sent. + +A separate `--final-report` mode (mutually exclusive with +`--send-warning`) prints a PASS/FAIL breakdown at the end of the +quarter, annotated with the warning dates from the contact log. + +## Requirements + +- Python 3.11 or newer (the script uses `tomllib` from the standard + library, which appeared in 3.11). +- The `requests` library (`pip install requests` or `apt install + python3-requests`). Everything else is in the stdlib. +- A Canvas instance with the Roll Call (Attendance) LTI tool and + personal API access enabled for you as the instructor. + +## Setup + +1. Clone this repository: + + ``` + git clone https://gitea.communitydata.science/mako/csss590-attendance.git + cd csss590-attendance + ``` + +2. Install the one Python dependency: + + ``` + pip install requests + ``` + +3. Get a Canvas API access token. In Canvas: **Account → Settings → + Approved Integrations → "+ New Access Token"**. Give it a label so + you can find it later. Copy the token immediately—Canvas only + shows it once. + +4. Copy the example config and fill it in: + + ``` + cp config.toml.example config.toml + ``` + + Open `config.toml` in your editor and set `canvas_id` (the number + in your course URL), `base_url` if you are not at `canvas.uw.edu`, + and the `token` field. The token can also come from `token_command` + (a shell command that prints the token—handy if you keep it in a + password manager) or from the `CANVAS_TOKEN` environment variable; + see comments in `config.toml.example` for details. + +5. Drop the Roll Call CSV in this directory whenever a new one + arrives. The script picks the latest matching `attendance_reports_*.csv` + automatically. You can also pass `--csv FILE` to point at a specific + file. + +## Weekly workflow + +The script is one piece of a wider weekly rhythm. The full process: + +1. **Collect attendance on paper during class.** A printed sign-in + sheet beats fumbling with a laptop and is what students see you + doing. + +2. **Transcribe to Roll Call** in Canvas after class. Open the Roll + Call (Attendance) tool and mark each student present or absent for + that day's session. + +3. **Request the attendance report** in Canvas. Roll Call's "Settings" + gear has an "Attendance Report" link; leave all the options at + their defaults. Canvas will email the CSV to you, usually within a + few minutes. + +4. **Save the emailed CSV** into this directory. The script picks the + lexicographically last `attendance_reports_*.csv` it finds, so + renaming the file to start with a date (e.g. + `attendance_reports_20260520-.csv`) ensures the newest week's + file is the one the script reads. If you have a + reason to use a different file for a given run, pass `--csv FILE`. + +5. **Review the week**: + + ``` + python3 review_attendance.py + ``` + + Look at the grid and the X/N totals. Confirm the new column matches + your records and that nobody's count is surprising. If a student is + at the warning threshold, preview what the warning DM would say: + + ``` + python3 review_attendance.py --send-warning + ``` + + This still does not write to Canvas—it renders the message each + at-risk student would receive so you can read them before they + leave your machine. + +6. **Send the warnings** when the preview looks right: + + ``` + python3 review_attendance.py --send-warning -f + ``` + + Each warning becomes a 1:1 Canvas conversation tagged with the + course, and the student is appended to `students_contacted.tsv` so + subsequent runs skip them. + +## End of quarter + +``` +python3 review_attendance.py --final-report +``` + +Prints PASS/FAIL groupings against `sessions_required`. Students who +were warned earlier in the quarter still carry their warning date in +the output so you can see the full story at a glance. + +For students who finished below the line, `failed_message.txt` is a +hand-substituted template you can use to write each of them a note +explaining their options. It is not wired into the script; see +"Templates" below. + +## Templates + +`email_template.txt` is rendered as a Python f-string for each warned +student. The template author has the following names in scope: + +| name | meaning | +| --- | --- | +| `name` | the student's full name from Canvas | +| `first_name` | first whitespace-separated token of the name | +| `attended` | how many sessions the student has attended | +| `sessions_held` | how many sessions have been held so far | +| `sessions_remaining` | sessions still to come this quarter | +| `sessions_required` | from config | +| `sessions_expected` | from config | + +Because the template is evaluated as an f-string, any expression that +references these names is valid. The shipped template uses one +conditional to render either "all" or a number depending on how many +of the remaining sessions a student still needs to attend. + +`failed_message.txt` is a separate template intended for hand-use at +the end of the quarter when a student has missed too many sessions to +pass. It uses `{{double-brace}}` markers (not f-string syntax) and is +not wired into the script—substitute by hand and send through the +Canvas inbox or by re-using the small POST snippet at the bottom of +`review_attendance.py`. + +## Roll Call quirks + +A few things worth knowing if you start poking at the data yourself: + +- The Roll Call CSV has a trailing empty field on every data row but + not on the header—15 columns of data versus a 14-column header. + Naive `csv.DictReader` (or R's `read.csv`) gets confused. The script + supplies explicit field names with an extra "Extra" column to work + around this. + +- Sessions where a student wasn't marked at all have no row in the + CSV—they are not silently recorded as "absent". The script derives + absences as `sessions_held − attended`, which counts an unmarked + session the same as an explicit absence. This is the right behavior + for grading purposes but can surprise you if you trust the row count. + +- Per-session attendance is not reachable through the Canvas API + using a personal access token. Roll Call's own backend (at + `rollcall.instructure.com`) requires an LTI launch JWT, not the + Canvas token. The Canvas Submissions API can give you the cumulative + attendance percentage for the Roll Call assignment, but not the + per-day breakdown. The emailed CSV remains the only easy source of + per-session detail. + +## Source and contributing + +The repository lives at +[gitea.communitydata.science/mako/csss590-attendance](https://gitea.communitydata.science/mako/csss590-attendance). +Issues and pull requests are welcome there—a tweak to a template, a +better config field, a wrinkle in a different Canvas instance. If you +are adapting this for a different course, a PR that generalizes a +class-specific assumption (instead of forking quietly) helps everyone +who comes after you. + +**Please don't publish any student records in git!** To help prevent +this, `.gitignore` keeps `config.toml`, the Roll Call CSVs, the +contact log, the final summary CSV, and incidental archival material +(screenshots, PDFs, SVGs) out of git. The intent is that nothing you +commit could identify a student. Before pushing, run `git status` to +confirm only the script, templates, and example config are tracked. + +## Credit + +Written by [Benjamin Mako Hill](https://mako.cc/academic/) for CSSS +590 at the University of Washington in spring 2026, with substantial +help from Claude. The Roll Call API exploration was constrained by +what a personal Canvas access token can reach. If Instructure ever +opens the Roll Call backend up to instructor tokens, the workflow could +be tightened considerably. diff --git a/config.toml.example b/config.toml.example new file mode 100644 index 0000000..c25bf36 --- /dev/null +++ b/config.toml.example @@ -0,0 +1,55 @@ +# Copy this file to `config.toml` and fill in your course's values. +# `config.toml` is gitignored — it contains course-specific IDs (and +# possibly your Canvas API token) that only matter to your section. + +[course] +# Canvas course ID. Find it in the course URL: +# https://canvas.example.edu/courses//... +canvas_id = 0 + +# Roll Call Attendance assignment ID. Open the Roll Call (Attendance) +# tool in your course; the assignment URL ends in /assignments/. +# Not used by the script today, but kept for reference. +roll_call_assignment_id = 0 + +# Short label used in places like the message subject line. +short_name = "CSSS 590" + +[attendance] +# How many sessions students must attend to pass. +sessions_required = 7 + +# How many sessions you expect to hold in the quarter. The warning +# threshold is derived as sessions_expected - sessions_required, i.e., +# the number of absences after which one more absence would put the +# student below the required count. +sessions_expected = 9 + +[contact] +# Subject line on all warning DMs sent through the Canvas Conversations API. +subject = "CSSS 590 attendance" + +# Path (relative to this config) of the template rendered as a Python +# f-string for each at-risk student. See README for available variables. +template_file = "email_template.txt" + +# Tab-separated log of students who have been warned. The script reads +# it to skip already-warned students and appends new rows after each +# successful send. +contacted_log = "students_contacted.tsv" + +[canvas] +# Base URL of your Canvas instance (no trailing slash). +base_url = "https://canvas.uw.edu" + +# Your Canvas API token. Generate one at: +# Canvas → Account → Settings → "+ New Access Token" +# Treat this like a password — `config.toml` is gitignored so it stays +# off any git remote you push. +token = "PASTE_YOUR_TOKEN_HERE" + +# Alternative to `token` above: a shell command (argv list) that prints +# the token to stdout. Useful if your token lives in a password manager. +# `token` takes precedence over `token_command` if both are set, and the +# CANVAS_TOKEN environment variable takes precedence over both. +# token_command = ["pass", "Canvas/api-token"] diff --git a/email_template.txt b/email_template.txt new file mode 100644 index 0000000..9a26fbf --- /dev/null +++ b/email_template.txt @@ -0,0 +1,9 @@ +Greetings {first_name}! + +As you probably know, I'm the instructor for CSSS 590 (the CSSS Seminar class), which you are registered for this quarter. As you also probably know, you must attend 7 seminars during the quarter to get credit. My records suggest you've missed {sessions_held - attended} seminars. This is just a heads up that there are {sessions_remaining} seminars remaining this quarter which means you must attend {"all" if sessions_required - attended >= sessions_remaining else sessions_required - attended} of them. + +Regards, +Mako (via a little script!) + +[Note: This is a semi-automated warning message from a script I've set up so there's no need to respond. That said, if you reply I will see the message in Canvas and I'm happy to talk more.] + diff --git a/failed_message.txt b/failed_message.txt new file mode 100644 index 0000000..b2318a6 --- /dev/null +++ b/failed_message.txt @@ -0,0 +1,25 @@ +Greetings {{student}}! + +According to my records, you have only taken {{n}} seminars, and not 7 as +required. Did I miss one? These are the days I have you present/absent +for: + +{{list}} + +If my count is incorrect, please let me know which day I'm +off. Assuming that my count is correct, I can see two ways of handling +this: + +1. I can submit a grade of no credit (NC), which I think is not a huge + deal. But maybe you should check with your advisor first? + +2. I can submit a grade of I (incomplete) and you can clear it during + the second week of the fall quarter by attending the seminar then. + +If you go with path 2, you *must* clear the I by the fall quarter +(which means remembering and reminding me). If you don't, it becomes a +big, big mess and very likely just turns into NC or a failed grade. + +Regards, +Mako + diff --git a/review_attendance.py b/review_attendance.py new file mode 100755 index 0000000..ad93255 --- /dev/null +++ b/review_attendance.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +"""Review CSSS 590 attendance and optionally send warnings to at-risk students. + +Reads the most recent Roll Call CSV in this directory (matching +``attendance_reports_*.csv``), pulls the active enrollment list from Canvas, +and prints an attendance grid plus an X/N totals list. With +``--send-warning`` it additionally shows which students at or past the +``absences_threshold`` would be warned, and the rendered message for each. +With ``--send-warning -f`` it actually POSTs the warnings via the Canvas +Conversations API and appends each one to ``students_contacted.tsv``. + +Usage: + review_attendance.py # grid + totals only + review_attendance.py --send-warning # also preview warning messages + review_attendance.py --send-warning -f # actually send the warnings + review_attendance.py --csv FILE # override which CSV to read + +The template is evaluated as a Python f-string, so any expression that +references the variables below is valid. Example for "must attend N (or +all) of the remaining sessions": + + {"all" if sessions_required - attended >= sessions_remaining + else sessions_required - attended} + +Template variables: + {name} full student name + {first_name} first whitespace-separated token of the name + {attended} number of sessions attended so far + {sessions_held} total sessions held to date + {sessions_remaining} sessions still to come this quarter + {sessions_required} sessions required per quarter (from config) + {sessions_expected} sessions expected per quarter (from config) +""" + +import argparse +import csv +import os +import subprocess +import sys +import tomllib +from datetime import date +from pathlib import Path + +import requests + +HERE = Path(__file__).parent +CONFIG_PATH = HERE / "config.toml" + +# Canvas Roll Call CSVs have a trailing empty field on data rows but not the +# header, so DictReader gets confused. Read with these explicit field names. +CSV_FIELDS = [ + "Course ID", "SIS Course ID", "Course Code", "Course Name", + "Section Name", "Section ID", "SIS Section ID", "Teacher ID", + "Teacher Name", "Student ID", "Student Name", "Class Date", + "Attendance", "Timestamp", "Extra", +] + + +def load_config(): + with open(CONFIG_PATH, "rb") as f: + return tomllib.load(f) + + +def newest_attendance_csv(): + files = sorted(HERE.glob("attendance_reports_*.csv")) + if not files: + sys.exit("No attendance_reports_*.csv found in this directory.") + return files[-1] + + +def parse_attendance(path): + """Return students, sessions, attended, present_set parsed from the CSV. + + students: dict[student_id -> name] + sessions: sorted list of class-date strings + attended: dict[student_id -> int] + present_set: set of (student_id, class_date) for present entries + + Absences are derived as ``len(sessions) - attended[sid]``; the CSV + omits rows entirely for sessions where Roll Call wasn't marked for a + student, so counting explicit "absent" rows undercounts. + """ + students = {} + sessions = set() + attended = {} + present_set = set() + with open(path, newline="") as f: + reader = csv.reader(f) + next(reader) # skip header — we use our own field list + for row in reader: + r = dict(zip(CSV_FIELDS, row)) + sid = r["Student ID"].strip() + name = r["Student Name"].strip() + students[sid] = name + sessions.add(r["Class Date"]) + if r["Attendance"] == "present": + attended[sid] = attended.get(sid, 0) + 1 + present_set.add((sid, r["Class Date"])) + return students, sorted(sessions), attended, present_set + + +def read_contacted(tsv_path): + """Return dict[student_id -> emailed_date] for previously-warned students.""" + contacted = {} + if not tsv_path.exists(): + return contacted + with open(tsv_path, newline="") as f: + reader = csv.DictReader(f, delimiter="\t") + for row in reader: + sid = (row.get("Student ID") or "").strip() + if sid: + contacted[sid] = (row.get("Emailed Date") or "").strip() + return contacted + + +def append_contacted(tsv_path, student_id, name, when): + new_file = not tsv_path.exists() or tsv_path.stat().st_size == 0 + with open(tsv_path, "a", newline="") as f: + w = csv.writer(f, delimiter="\t") + if new_file: + w.writerow(["Student ID", "Student Name", "Emailed Date"]) + w.writerow([student_id, name, when]) + + +def get_canvas_token(cfg): + """Return the Canvas API token. + + Resolution order: + 1. CANVAS_TOKEN env var (raw token). + 2. ``token`` (raw token) in [canvas] from config.toml. + 3. ``token_command`` (argv list) in [canvas] from config.toml, run + as a subprocess; stdout (stripped) is the token. + """ + if (env := os.environ.get("CANVAS_TOKEN")): + return env.strip() + raw = cfg["canvas"].get("token") + if raw: + return raw.strip() + cmd = cfg["canvas"].get("token_command") + if not cmd: + sys.exit("Canvas token not configured: set CANVAS_TOKEN, or add " + "[canvas].token or [canvas].token_command to config.toml.") + r = subprocess.run(cmd, capture_output=True, text=True, check=True) + return r.stdout.strip() + + +def get_enrolled_students(base_url, token, course_id): + """Return dict[str(user_id) -> name] for active StudentEnrollments.""" + url = f"{base_url}/api/v1/courses/{course_id}/enrollments" + params = {"type[]": "StudentEnrollment", "state[]": "active", "per_page": 100} + headers = {"Authorization": f"Bearer {token}"} + enrolled = {} + while url: + r = requests.get(url, params=params, headers=headers, timeout=30) + r.raise_for_status() + for e in r.json(): + enrolled[str(e["user_id"])] = e["user"]["name"] + params = None # subsequent paginated URLs already carry the query + url = None + link = r.headers.get("Link", "") + for part in link.split(","): + if 'rel="next"' in part: + url = part.split(";", 1)[0].strip().strip("<>") + break + return enrolled + + +def render_template(template, env): + """Render the template as a Python f-string with env in scope. + + Allows the template author to write inline expressions like + ``{"all" if must_attend >= sessions_remaining else must_attend}``. + Mako writes the template himself, so eval is acceptable. + """ + if '"""' in template: + raise ValueError("Template cannot contain triple-quotes (the renderer " + "wraps the file in a triple-quoted f-string).") + return eval(f'f"""{template}"""', {"__builtins__": {}}, env) + + +def send_canvas_message(base_url, token, course_id, user_id, subject, body): + resp = requests.post( + f"{base_url}/api/v1/conversations", + headers={"Authorization": f"Bearer {token}"}, + data={ + "recipients[]": str(user_id), + "context_code": f"course_{course_id}", + "subject": subject, + "body": body, + "force_new": "true", + }, + timeout=30, + ) + resp.raise_for_status() + return resp.json() + + +def print_grid(students, sessions, present_set): + """Print a students × sessions grid sorted by last name.""" + by_last = sorted(students.items(), key=lambda kv: (kv[1].rsplit(" ", 1)[-1], kv[1])) + width = max(len(n) for _, n in by_last) + header = " " * (width + 2) + " ".join(d[5:] for d in sessions) + print(header) + for sid, name in by_last: + cells = ["P" if (sid, d) in present_set else "-" for d in sessions] + print(f"{name:<{width}} " + " ".join(cells)) + print() + + +def print_totals(students, attended, sessions_held, contacted): + """Print an X/N attendance list sorted by attendance desc, then name.""" + rows = sorted( + ((sid, students[sid], attended.get(sid, 0)) for sid in students), + key=lambda r: (-r[2], r[1]), + ) + width = max(len(n) for _, n, _ in rows) + print("Totals (attended / sessions held):\n") + for sid, name, n in rows: + warned = f" [warned {contacted[sid]}]" if sid in contacted else "" + print(f" {name:<{width}} {n}/{sessions_held}{warned}") + print() + + +def print_final_report(students, attended, sessions_held, sessions_required, contacted): + """Print PASS/FAIL groupings for the end-of-quarter report.""" + rows = sorted( + ((sid, students[sid], attended.get(sid, 0)) for sid in students), + key=lambda r: (-r[2], r[1]), + ) + passing = [(sid, name, n) for sid, name, n in rows if n >= sessions_required] + failing = [(sid, name, n) for sid, name, n in rows if n < sessions_required] + width = max(len(n) for _, n, _ in rows) + + print(f"Final report (required: {sessions_required}/{sessions_held}):\n") + print(f" PASS ({len(passing)}):") + for sid, name, n in passing: + warned = f" [warned {contacted[sid]}]" if sid in contacted else "" + print(f" {name:<{width}} {n}/{sessions_held}{warned}") + print(f"\n FAIL ({len(failing)}):") + for sid, name, n in failing: + warned = f" [warned {contacted[sid]}]" if sid in contacted else "" + print(f" {name:<{width}} {n}/{sessions_held}{warned}") + print() + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--send-warning", action="store_true", + help="show warning-message preview (and with -f, send them)") + ap.add_argument("-f", "--force", action="store_true", + help="with --send-warning, actually POST messages to Canvas") + ap.add_argument("--final-report", action="store_true", + help="print end-of-quarter PASS/FAIL summary") + ap.add_argument("--csv", type=Path, + help="path to attendance CSV (default: newest in this dir)") + args = ap.parse_args() + + if args.force and not args.send_warning: + ap.error("-f/--force requires --send-warning") + if args.send_warning and args.final_report: + ap.error("--send-warning and --final-report are mutually exclusive") + + cfg = load_config() + sessions_required = cfg["attendance"]["sessions_required"] + sessions_expected = cfg["attendance"]["sessions_expected"] + threshold = sessions_expected - sessions_required + contacted_log = HERE / cfg["contact"]["contacted_log"] + template_path = HERE / cfg["contact"]["template_file"] + subject = cfg["contact"]["subject"] + + csv_path = args.csv or newest_attendance_csv() + print(f"Reading: {csv_path.name}") + + token = get_canvas_token(cfg) + enrolled = get_enrolled_students( + cfg["canvas"]["base_url"], token, cfg["course"]["canvas_id"] + ) + print(f"Canvas enrollment: {len(enrolled)} active student(s)\n") + + _, sessions, attended, present_set = parse_attendance(csv_path) + sessions_held = len(sessions) + + # Restrict to currently-enrolled students — dropped students still show + # up in the CSV from before they left. + students = dict(enrolled) + absences = {sid: sessions_held - attended.get(sid, 0) for sid in students} + + print(f"Sessions held: {sessions_held} ({', '.join(sessions)})\n") + + contacted = read_contacted(contacted_log) + + print_grid(students, sessions, present_set) + print_totals(students, attended, sessions_held, contacted) + + if args.final_report: + print_final_report(students, attended, sessions_held, + sessions_required, contacted) + return + + if not args.send_warning: + return + + print(f"Threshold: {threshold}+ absences " + f"(required {sessions_required}/{sessions_expected})\n") + targets = sorted( + [(sid, students[sid], n) for sid, n in absences.items() + if n >= threshold and sid not in contacted], + key=lambda x: (-x[2], x[1]), + ) + + if not targets: + print("No students to warn.") + already = [(sid, students.get(sid, "?")) for sid in contacted + if absences.get(sid, 0) >= threshold] + if already: + print("(Already warned: " + + ", ".join(n for _, n in already) + ")") + return + + template = template_path.read_text() if template_path.exists() else "" + template_ready = bool(template.strip()) + if not template_ready: + print(f"[!] {template_path.name} is empty — preview only; -f will refuse.\n") + + print(f"Will warn {len(targets)} student(s):\n") + for sid, name, n in targets: + print(f" - {name} (id={sid}, {attended.get(sid, 0)}/{sessions_held}, " + f"{n} absence{'s' if n != 1 else ''})") + print() + + if args.force and not template_ready: + sys.exit(f"Refusing to send: {template_path.name} is empty.") + + today = date.today().isoformat() + + for sid, name, n in targets: + if template_ready: + env = { + "name": name, + "first_name": name.split()[0], + "attended": attended.get(sid, 0), + "sessions_held": sessions_held, + "sessions_remaining": sessions_expected - sessions_held, + "sessions_required": sessions_required, + "sessions_expected": sessions_expected, + } + body = render_template(template, env) + else: + body = "[TEMPLATE EMPTY]" + + print(f"--- {name} (id={sid}) ---") + print(f"Subject: {subject}") + print(body) + print() + + if args.force: + send_canvas_message( + cfg["canvas"]["base_url"], token, + cfg["course"]["canvas_id"], sid, subject, body, + ) + append_contacted(contacted_log, sid, name, today) + print(f" -> sent, logged to {contacted_log.name}\n") + + if not args.force: + print("Preview only. Re-run with --send-warning -f to actually send.") + + +if __name__ == "__main__": + main()