diff --git a/backend/tests/test_optimization.py b/backend/tests/test_optimization.py index 75cf4e8..e6a3563 100644 --- a/backend/tests/test_optimization.py +++ b/backend/tests/test_optimization.py @@ -249,6 +249,38 @@ def test_last_review_updates_batch_and_keeps_zero(sqlite_db): assert sheet["D2"].value == 0 +@pytest.mark.parametrize("batch_count", [0, 20]) +def test_batch_list_status_statistics_use_constant_query_count(sqlite_db, batch_count): + from wisegrade.api import list_batches + db = sqlite_db + item = project(db) + expected = {} + states = ["scored", "reviewed", "needs_review", "failed", "unscorable"] + for index in range(batch_count): + created = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db) + batch = db.get(ScoringBatch, created["id"]) + criterion = batch.rubric_snapshot["criteria"][0]["criterion_id"] + state = states[index % len(states)] + db.add(CriterionScore(batch_id=batch.id, project_id=item.id, criterion_id=criterion, status=state)) + expected[batch.id] = {state: 1} + db.commit() + queries = [] + def trace(_conn, _cursor, statement, _parameters, _context, _many): + if statement.lstrip().upper().startswith("SELECT"): + queries.append(statement) + event.listen(db.get_bind(), "before_cursor_execute", trace) + try: + listed = list_batches(db) + finally: + event.remove(db.get_bind(), "before_cursor_execute", trace) + assert len(queries) <= (2 if batch_count else 1), len(queries) + assert [row["id"] for row in listed] == sorted(expected, reverse=True) + for row in listed: + assert row["status_counts"] == expected[row["id"]] + for state in ("needs_review", "failed", "unscorable"): + assert row[state] == expected[row["id"]].get(state, 0) + + def test_pdf_html_preserves_zero_and_none(): batch = SimpleNamespace(id=1, course_version_id=1, rubric_version_id=1, model_config_snapshot={}, embedding_model_name="test", embedding_model_version="test") diff --git a/backend/wisegrade/api.py b/backend/wisegrade/api.py index 7341ba2..03f0fe8 100644 --- a/backend/wisegrade/api.py +++ b/backend/wisegrade/api.py @@ -468,12 +468,15 @@ def create_batch(data: BatchInput, db: Session = Depends(get_db)): return services.create_batch(data, db) +def status_count_dict(counts: dict) -> dict: + return {"status_counts": counts, "needs_review": counts.get("needs_review", 0), + "unscorable": counts.get("unscorable", 0), "failed": counts.get("failed", 0)} + + def batch_status_counts(db, batch_id: int) -> dict: rows = db.execute(select(CriterionScore.status, func.count(CriterionScore.id)) .where(CriterionScore.batch_id == batch_id).group_by(CriterionScore.status)).all() - counts = {status: count for status, count in rows} - return {"status_counts": counts, "needs_review": counts.get("needs_review", 0), - "unscorable": counts.get("unscorable", 0), "failed": counts.get("failed", 0)} + return status_count_dict({status: count for status, count in rows}) def batch_version_metadata(batch: ScoringBatch) -> dict: """Display the immutable rule/course versions, not their database IDs.""" @@ -485,12 +488,20 @@ def batch_version_metadata(batch: ScoringBatch) -> dict: @router.get("/scoring-batches") def list_batches(db: Session = Depends(get_db)): + batches = db.scalars(select(ScoringBatch).order_by(ScoringBatch.id.desc())).all() + counts_by_batch = {} + if batches: + rows = db.execute(select(CriterionScore.batch_id, CriterionScore.status, func.count(CriterionScore.id)) + .where(CriterionScore.batch_id.in_([batch.id for batch in batches])) + .group_by(CriterionScore.batch_id, CriterionScore.status)).all() + for batch_id, status, count in rows: + counts_by_batch.setdefault(batch_id, {})[status] = count return [{**{key: getattr(batch, key) for key in ("id", "course_version_id", "rubric_version_id", "status", "progress_done", "progress_total", "error", "created_at", "finished_at")}, "status_label": BATCH_STATUS_LABELS.get(batch.status, batch.status), "project_ids": batch.project_ids, "legacy_membership": batch.project_ids is None, **batch_version_metadata(batch), - **batch_status_counts(db, batch.id)} - for batch in db.scalars(select(ScoringBatch).order_by(ScoringBatch.id.desc()))] + **status_count_dict(counts_by_batch.get(batch.id, {}))} + for batch in batches] @router.get("/scoring-batches/{batch_id}/progress")