Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions core/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,34 @@ def get_unit_code(user_id: int, project_id: int) -> str | None:
return row[0] if row else None


def get_active_project_ids(user_id: int) -> set[int]:
"""Project IDs currently stored for the user. The projects ingest only keeps
non-ended units (and prune_ended_projects drops the rest), so this is the set
of *active* projects — used to reject task pushes for past-trimester projects
that an older extension build would otherwise still sweep."""
with _connection() as conn:
cur = conn.cursor()
cur.execute(f"SELECT project_id FROM projects WHERE user_id = {_P}", (user_id,))
return {row[0] for row in cur.fetchall()}


def delete_tasks_for_inactive_projects(user_id: int) -> int:
"""Delete task rows whose project is no longer in the user's active projects
(e.g. past-trimester tasks left behind after prune_ended_projects). Returns the
number of rows removed."""
with _connection() as conn:
cur = conn.cursor()
cur.execute(
f"""DELETE FROM tasks
WHERE user_id = {_P}
AND project_id NOT IN (
SELECT project_id FROM projects WHERE user_id = {_P}
)""",
(user_id, user_id),
)
return cur.rowcount


def get_pending_tasks(user_id: int, today_iso: str, end_iso: str) -> list[dict]:
"""Tasks due in the inclusive window [today, end], ordered by deadline. Status
filtering (HIDE_SET) is applied by the brief layer, not here — this just bounds
Expand Down
21 changes: 12 additions & 9 deletions core/ontrack/normalize.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@

from __future__ import annotations

from datetime import date

_GRADE_LABELS = {
0: "P (Pass)",
1: "C (Credit)",
Expand All @@ -29,20 +27,25 @@ def enrich_tasks(tasks: list[dict], task_defs: list[dict]) -> None:
)
t["target_grade_label"] = _GRADE_LABELS.get(t.get("target_grade"), "P (Pass)")
t["due_date"] = t.get("due_date") or td.get("target_date") or td.get("due_date")
t["deadline"] = t.get("deadline") or td.get("due_date")
# Rank/window by the target_date the student actually sees in OnTrack, not
# the hard due_date (often the late-penalty cutoff / end of trimester). Fall
# back to due_date when a definition has no target.
t["deadline"] = t.get("deadline") or td.get("target_date") or td.get("due_date")
t["status_label"] = t.get("status_label") or t.get("status", "").replace("_", " ").title()


def append_missing_tasks(tasks: list[dict], task_defs: list[dict]) -> None:
"""Synthesise not-yet-started task rows for any released definition the
student hasn't engaged with yet. Mutates ``tasks`` in place."""
"""Synthesise not-yet-started task rows for any definition the student hasn't
engaged with yet. Mutates ``tasks`` in place.

Includes definitions whose ``start_date`` is still in the future: this is a
plan-ahead brief, so an upcoming task should surface as soon as it has a
target date. The deadline window (in the snapshot/brief) is what bounds which
of these actually display, so synthesising them here never over-shows."""
submitted_def_ids = {t["task_definition_id"] for t in tasks}
today = date.today().isoformat()
for td in task_defs:
if td["id"] in submitted_def_ids:
continue
if td.get("start_date", "0000") > today:
continue
tasks.append(
{
"id": None,
Expand All @@ -54,7 +57,7 @@ def append_missing_tasks(tasks: list[dict], task_defs: list[dict]) -> None:
"target_grade": td.get("target_grade"),
"target_grade_label": _GRADE_LABELS.get(td.get("target_grade"), "P (Pass)"),
"due_date": td.get("target_date") or td.get("due_date"),
"deadline": td.get("due_date"),
"deadline": td.get("target_date") or td.get("due_date"),
"submission_date": None,
"completion_date": None,
"extensions": 0,
Expand Down
39 changes: 38 additions & 1 deletion extension/public/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,43 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
return true; // keep message channel open for async response
});

// Dedup identical captures: OnTrack's SPA re-fetches the same project/unit data
// on every navigation, so without this the extension re-POSTs unchanged payloads
// and a single browsing session can trip the server's /ingest rate limit. Keyed
// by kind + the captured identifier; value is a hash of the payload so a real
// data change still pushes. Lives in the (ephemeral) service-worker scope — a
// worker restart just allows one harmless re-send.
const lastIngestHash = new Map();

function ingestDedupKey(kind, payload) {
const p = payload || {};
if (kind === "project_tasks") return `project_tasks:${p.project_id}`;
if (kind === "feedback") return `feedback:${p.project_id}:${p.task_def_id}`;
return kind; // "projects" — one per session
}

// Small, fast, collision-tolerant string hash (djb2). Exact equality isn't
// required — a missed dedup only costs one extra POST.
function hashPayload(payload) {
const s = JSON.stringify(payload);
let h = 5381;
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
return `${s.length}:${h}`;
}

// Forward captured OnTrack data to /ingest. Separate listener so the token path
// above stays untouched; both run on every message and ignore kinds not theirs.
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg.type !== "ingest") return false;

const dedupKey = ingestDedupKey(msg.kind, msg.payload);
const hash = hashPayload(msg.payload);
if (lastIngestHash.get(dedupKey) === hash) {
sendResponse({ ok: true, deduped: true });
return true;
}
lastIngestHash.set(dedupKey, hash);

fetch(`${APP_URL}/ingest`, {
method: "POST",
headers: { "Content-Type": "application/json" },
Expand All @@ -83,7 +115,12 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
})
.then((r) => r.json())
.then(() => sendResponse({ ok: true }))
.catch(() => sendResponse({ ok: false }));
.catch(() => {
// Push failed — drop the cached hash so the next identical capture retries
// instead of being silently deduped away.
lastIngestHash.delete(dedupKey);
sendResponse({ ok: false });
});

return true; // async response
});
8 changes: 8 additions & 0 deletions extension/public/injected.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,21 @@
function sweepProjectTasks(projects) {
if (_swept || !lastToken || !lastUsername) return;
_swept = true;
const today = new Date().toISOString().slice(0, 10);
const headers = {
"Auth-Token": lastToken,
"Username": lastUsername,
"Accept": "application/json",
};
projects.forEach(function (proj) {
if (!proj.id) return;
// /api/projects returns every project the student has ever enrolled in,
// across all trimesters. Sweeping them all floods /ingest with stale tasks
// (tripping the server rate limit) and crowds out the current trimester's
// data. Skip ended units — same filter the server applies when storing the
// projects list — so the sweep only fetches active units.
const endDate = proj.unit && proj.unit.end_date;
if (endDate && endDate < today) return;
const url = `${location.origin}/api/projects/${proj.id}`;
// Use window.fetch (our overridden version) so the response flows through
// handleData automatically — no manual wiring needed.
Expand Down
3 changes: 2 additions & 1 deletion extension/src/components/TaskList.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,14 @@ export default function TaskList({ days }) {
.sort((a, b) => a.offset - b.offset)

const count = allTasks.length
const windowDays = (days || []).length || 7

return (
<div className="task-list-section">
<div className="task-list-header"></div>

{count === 0 ? (
<div className="task-empty">Nothing due in the next 7 days</div>
<div className="task-empty">Nothing due in the next {windowDays} days</div>
) : (
allTasks.map((t, i) => {
const cfg = GRADE_CONFIG[t.grade] || FALLBACK_CFG
Expand Down
9 changes: 7 additions & 2 deletions extension/src/hooks/useSnapshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,21 @@ export function useSnapshot({ isLoaded, isSignedIn, getToken }) {
const cached = (await getLocal([SNAPSHOT_KEY]))[SNAPSHOT_KEY]
const hasCache = !!cached?.data
const fresh = hasCache && Date.now() - cached.ts < SNAPSHOT_TTL_MS
// The cache isn't keyed by window size, so a fresh 7-day snapshot must not
// satisfy a 14-day request — otherwise switching to the 2-week strip paints
// the stale 7-day data and silently drops days 8–14 (and their tasks).
const coversWindow = hasCache && (cached.data.days?.length || 0) >= numDays

if (hasCache) {
// Stale-while-revalidate: paint cached data instantly (any age), no
// blocking spinner. Skip the network entirely while still fresh.
// blocking spinner. Skip the network entirely while still fresh AND the
// cached window is at least as wide as the one we need.
setDays(cached.data.days)
setFeedback(cached.data.feedback || [])
if (typeof cached.data.subscribed === 'boolean') setSubscribed(cached.data.subscribed)
setStripLoading(false)
setFooterSync(syncLabel(cached.ts))
if (!force && fresh) return
if (!force && fresh && coversWindow) return
setFooterSync('Refreshing…') // background revalidate cue
} else {
// First load on this device — nothing to show yet.
Expand Down
35 changes: 34 additions & 1 deletion routes/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@
import requests
from apscheduler.triggers.date import DateTrigger
from flask import Blueprint, g, jsonify, request
from flask_limiter.util import get_remote_address

from core.brief.builder import is_hidden
from core.clerk_auth import require_clerk_auth
from core.db import (
delete_tasks_for_inactive_projects,
get_active_project_ids,
get_capture_meta,
get_feedback_entries,
get_pending_tasks,
Expand Down Expand Up @@ -196,8 +199,23 @@ def refresh_credential():
return {"ok": True}


def _ingest_rate_key():
"""Per-student rate-limit bucket for /ingest.

The endpoint is unauthenticated and, in Docker and behind Railway's proxy,
every session arrives from one shared IP (the gateway / proxy). Keying the
limit on IP (the limiter default) therefore lets one busy session — whose
project sweep fans out into many small pushes — exhaust the limit for every
other student. Key on the body-supplied username so each student gets their
own bucket, falling back to IP when the body has no username (malformed
request, which the handler rejects anyway)."""
data = request.get_json(silent=True) or {}
username = (data.get("username") or "").strip()
return f"ingest:{username}" if username else get_remote_address()


@main_bp.route("/ingest", methods=["POST"])
@limiter.limit("120 per minute")
@limiter.limit("120 per minute", key_func=_ingest_rate_key)
def ingest():
"""Receive OnTrack data captured off the student's own session and store it.

Expand Down Expand Up @@ -259,12 +277,27 @@ def ingest():
projects = [p for p in projects if p["project_id"] is not None]
stored = upsert_projects(user_id, projects)
prune_ended_projects(user_id, today.isoformat())
# Drop tasks left behind by now-ended projects (past trimesters) so the
# snapshot/brief never read stale rows. Pairs with the project_tasks guard
# below — together they keep the DB to the active trimester even if an old
# extension build keeps sweeping every project.
removed = delete_tasks_for_inactive_projects(user_id)
if removed:
log.info("ingest: pruned %d task(s) for ended projects (user %s)", removed, user_id)
return {"ok": True, "stored": stored}

if kind == "project_tasks":
project_id = payload.get("project_id")
if project_id is None:
return {"ok": False, "error": "missing project_id"}, 400
# Reject pushes for past-trimester projects. The projects ingest only keeps
# active (non-ended) units, so once the user has any projects stored, a
# project_id absent from that set is an ended unit an older extension build
# is still sweeping — store nothing. The `active_ids` empty case allows the
# race where project_tasks arrives before the projects list has landed.
active_ids = get_active_project_ids(user_id)
if active_ids and project_id not in active_ids:
return {"ok": True, "stored": 0, "skipped": "inactive_project"}
# Guard: enrich_tasks and append_missing_tasks both use
# t["task_definition_id"] (hard key access). Drop any task dicts the
# extension sent without that field before passing them in.
Expand Down
Loading