Automating Weekly Engagement Reports with Python
This guide automates the report that most institutions still assemble by hand every week: a de-identified summary of course engagement, aggregated to a grain no one can drill through, rendered to a file, and ready to email. The task is bounded — take a week of normalized engagement events, aggregate them to the course-week grain, suppress any cell too small to publish, and render an HTML table and a CSV. The design rationale (why a materialized store, why suppression, why one snapshot feeds every format) lives in the parent Dashboard Automation for LMS Metrics reference; here we produce the artifact.
The value of automating it is not saving the twenty minutes of copy-paste. It is consistency and compliance: a scripted report applies the same suppression rule every week, stamps its own provenance, and never accidentally pastes a two-student section into a shared inbox.
Prerequisites
Confirm each of these before running — the script assumes all of them are in place.
Step-by-step implementation
1. Aggregate to the course-week grain, not the learner grain. Group the events by course and reduce to counts, means, and rates so the report never carries a per-learner row — the grain itself is the first line of de-identification.
2. Band continuous metrics. Round the mean engagement and active rate to one or two decimals so a raw fraction like 0.6667 (which reveals a denominator of 3) cannot pinpoint a tiny cohort.
3. Suppress cells below the threshold. Any course-week cell with fewer than the minimum number of learners is replaced with a suppression marker rather than published.
4. Apply complementary suppression. If suppressing one cell would let it be recovered by subtracting the shown cells from a published total, suppress a second cell so the arithmetic no longer works.
5. Stamp provenance. Record the period label and a materialized_at timestamp on the output so a reader can tell at a glance whether the report is fresh or stale.
6. Render the same snapshot to every format. Produce the HTML table and the CSV from one aggregated frame, so the emailed figure and the dashboard figure can never disagree.
7. Verify no raw identifiers survive. Assert the rendered output contains only tokens and aggregates before anything is written to a shared location.
Complete runnable code block
import logging
from datetime import datetime, timezone
from pathlib import Path
import pandas as pd
logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
logger = logging.getLogger("weekly_engagement_report")
MIN_CELL = 5 # institutional de-identification threshold
def aggregate(events: pd.DataFrame, period: str) -> pd.DataFrame:
"""Reduce learner-level events to a de-identified course-week frame."""
agg = (events
.groupby(["course_id", "course_name"], as_index=False)
.agg(n_learners=("learner_token", "nunique"),
mean_engagement=("engagement_index", "mean"),
active_rate=("was_active", "mean")))
agg["period"] = period
agg["mean_engagement"] = agg["mean_engagement"].round(2) # band the metric
agg["active_rate"] = agg["active_rate"].round(2)
return agg
def suppress(df: pd.DataFrame, min_cell: int = MIN_CELL) -> pd.DataFrame:
"""Small-cell suppression with a complementary pass across all rows."""
df = df.copy()
below = df["n_learners"] < min_cell
# If exactly one cell is below threshold, also hide the next-smallest shown
# cell so the suppressed value can't be recovered by subtraction from a total.
if below.sum() == 1 and (~below).sum() >= 1:
smallest_shown = df.loc[~below, "n_learners"].idxmin()
below.loc[smallest_shown] = True
for col in ("n_learners", "mean_engagement", "active_rate"):
df[col] = df[col].astype("object")
df.loc[below, col] = "—" # suppression marker
df["suppressed"] = below
logger.info("Suppressed %d of %d course cells", int(below.sum()), len(df))
return df
def render(df: pd.DataFrame, period: str, out_dir: Path) -> tuple[Path, Path]:
"""Render the SAME snapshot to HTML and CSV so formats can't diverge."""
out_dir.mkdir(parents=True, exist_ok=True)
stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
cols = ["course_name", "n_learners", "mean_engagement", "active_rate"]
table = df[cols].rename(columns={
"course_name": "Course", "n_learners": "Learners",
"mean_engagement": "Mean engagement", "active_rate": "Active rate"})
html = (f"<h1>Weekly Engagement Report — {period}</h1>"
f"<p>Materialized {stamp}. Cells below n={MIN_CELL} suppressed (—).</p>"
+ table.to_html(index=False, border=0))
html_path = out_dir / "report.html"
csv_path = out_dir / "report.csv"
html_path.write_text(html, encoding="utf-8")
table.to_csv(csv_path, index=False)
logger.info("Wrote %s and %s", html_path, csv_path)
return html_path, csv_path
def build_report(events: pd.DataFrame, period: str, out_dir: Path) -> pd.DataFrame:
# Guard: the report must never see a raw identifier column.
assert "student_id" not in events.columns, "raw id present — tokenize upstream"
snapshot = suppress(aggregate(events, period))
render(snapshot, period, out_dir)
return snapshot
if __name__ == "__main__":
# Placeholder events — tokens only, never real student numbers.
events = pd.DataFrame({
"learner_token": [f"stu_{i:04x}" for i in range(23)],
"course_id": [101] * 12 + [102] * 3 + [103] * 8,
"course_name": ["Intro Biology"] * 12 + ["Grad Seminar"] * 3 + ["Statistics"] * 8,
"engagement_index": [0.8] * 12 + [0.5] * 3 + [0.6] * 8,
"was_active": [True] * 12 + [True, False, True] + [True] * 6 + [False, False],
})
frame = build_report(events, period="2026-W28", out_dir=Path("./out"))
print(frame[["course_name", "n_learners", "mean_engagement", "suppressed"]].to_string(index=False))
Verification and output validation
Confirm the rendered report is correct and safe to distribute:
- Small cells are suppressed. The 3-learner “Grad Seminar” cell must show
—, not a number:assert frame.loc[frame["course_name"] == "Grad Seminar", "suppressed"].all(). - Complementary suppression fired. With exactly one below-threshold cell, a second cell should also be suppressed —
assert frame["suppressed"].sum() >= 2— so the hidden value cannot be recovered by subtraction. - No raw identifiers in the output. Grep the rendered file:
assert "student_id" not in Path("./out/report.html").read_text()and confirm every learner reference is astu_token or an aggregate. - Provenance is present. The HTML contains a
Materializedtimestamp and the period label, so a stale report is obvious to a reader. - Formats agree. Load
report.csvand diff its numbers against the HTML table — they render from one snapshot, so they must match exactly.
Troubleshooting
- A tiny section still shows a number. The threshold is set below institutional policy, or
suppressran beforeaggregatefinished grouping. ConfirmMIN_CELLmatches the compliance office’s value and that suppression runs on the aggregated frame. TypeErrorwhen rounding after suppression. Suppression replaced a numeric cell with the"—"string, then a later step tried arithmetic on the column. Do all banding and rounding inaggregate, beforesuppressintroduces the marker.- The suppressed value is recoverable by subtraction. Only one cell was hidden and a total is published elsewhere. Ensure the complementary pass runs, or omit the total row from any view that shows all-but-one section.
- Empty report /
KeyErroron a column. The event frame for the week is empty or missing a column. Assert the expected columns exist and short-circuit to a clearly-labelled “no data this period” report rather than a blank file. - Numbers change between two runs of the same week. The upstream engagement frame is still mutating because the week is not closed. Only build the final report for a closed week; mark an in-progress week provisional, as the parent Dashboard Automation for LMS Metrics reference describes.
- HTML renders raw
<and>as text. A course name contained markup and was not escaped.pandas.to_htmlescapes cell content by default; if you templated the table by hand, escape names before interpolation.
Related
- Dashboard Automation for LMS Metrics — the serving-layer design, refresh cadence, and suppression rules behind this report.
- Scheduling LMS Data Refreshes with Cron and Python — running this report unattended on a weekly cadence.
- Engagement Metric Normalization — produces the normalized engagement events this report aggregates.
- Early-Warning At-Risk Scoring — the sibling output whose aggregate trends can appear alongside engagement in a report.
Part of: Dashboard Automation for LMS Metrics