1
0
Files
csss590-attendance/review_attendance.py
Benjamin Mako Hill 3814229f49 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 <noreply@anthropic.com>
2026-06-06 18:57:18 -07:00

370 lines
14 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()