Skip to content

#217 · Task 3 — batch campaign-definition endpoint (n reports + evaluator + campaign, one call) #220

Description

@romainkieffer

Part of #217 (admin screen to define a campaign). Depends on Task 2 (#219), now landed.

Design

POST /exercises/{eid}/campaigns already exists (create_campaign). Rather than adding a second, parallel campaign-creation endpoint, CampaignCreate gains an optional report_specs field. When provided (a non-empty list of {template_id, available_at, due_at}), the same endpoint fans each spec out once per team in the exercise — n specs × m teams = n×m Report rows — and links every one of them into the campaign it just created, all in one transaction. Omitting report_specs keeps today's exact behavior: an empty campaign.

The section-instantiation logic create_report already has (snapshot the template version, seed ReportSection rows, apply template-authored default_content) is extracted into a shared _instantiate_report(...) helper in reports.py, called once per (team, spec) pair by the fan-out and once by create_report itself — no behavior change for the existing single-report path, just de-duplicated.

No team_id and no evaluator_id in a spec — teams are every team in the exercise (fan-out), evaluators are resolved automatically per #219.

Files

  • Modify: backend/app/schemas/campaign.py (CampaignReportSpec, CampaignCreate.report_specs)
  • Modify: backend/app/routes/v1/reports.py (extract _instantiate_report, used by create_report and campaigns.py)
  • Modify: backend/app/routes/v1/campaigns.py (create_campaign fans out when report_specs is set)
  • Test: backend/tests/routes/test_campaigns.py
  • Test: backend/tests/routes/test_reports_instantiate.py (confirm create_report's own behavior is unchanged after the extraction)

Interfaces

  • Adds: CampaignReportSpec{template_id: str, available_at: datetime | None = None, due_at: datetime | None = None}.
  • Adds: CampaignCreate.report_specs: list[CampaignReportSpec] | None = None (non-empty if present, same _reject_empty_chain-style validator already used for approval_chain).
  • Adds: _instantiate_report(db, *, exercise_id, team, template, actor_id, name, description=None, due_at=None, available_at=None, approval_required=False, approval_chain=None, assigned_writer_id=None) -> Report — pure extraction, no new behavior.
  • create_campaign's response is unchanged (CampaignOut, with report_count now correctly reflecting the fan-out).

Error contract (all on POST /exercises/{eid}/campaigns when report_specs is set):

Condition Status detail
report_specs present but empty 422 pydantic validation error
A spec's template_id not found 404 "template not found"
A spec's template not published 409 "template is not published"
Exercise has zero teams 422 {"error": "exercise_has_no_teams"}
  • Step 1: Write the failing tests
# backend/tests/routes/test_campaigns.py — additions
async def _published_template(c, ah, name="T"):
    tid = (await c.post("/api/v1/templates", json={"name": name, "report_type": "spot"}, headers=ah)).json()["data"]["id"]
    await c.post(f"/api/v1/templates/{tid}/sections", json={"name": "S", "field_type": "rich_text", "is_required": True}, headers=ah)
    await c.post(f"/api/v1/templates/{tid}/publish", headers=ah)
    return tid


async def test_campaign_with_report_specs_fans_out_per_team(migrated_db: async_sessionmaker) -> None:
    ah, _ = await _ga(migrated_db)
    async with client(migrated_db) as c:
        ex = (await c.post("/api/v1/exercises", json={"name": "E"}, headers=ah)).json()["data"]["id"]
        for name in ("BT1", "BT2", "BT3"):
            await c.post(f"/api/v1/exercises/{ex}/teams", json={"name": name, "team_type": "blue"}, headers=ah)
        sitrep = await _published_template(c, ah, "SITREP")
        r = await c.post(
            f"/api/v1/exercises/{ex}/campaigns",
            json={"name": "SITREP campaign", "report_specs": [{"template_id": sitrep}, {"template_id": sitrep}]},
            headers=ah,
        )
        assert r.status_code == 201, r.text
        assert r.json()["data"]["report_count"] == 6  # 2 specs x 3 teams

        reports = (await c.get(f"/api/v1/exercises/{ex}/reports", headers=ah)).json()["data"]
        assert len(reports) == 6
        assert {rep["team_id"] for rep in reports} == set(
            t["id"] for t in (await c.get(f"/api/v1/exercises/{ex}/teams", headers=ah)).json()["data"]
        )


async def test_campaign_report_specs_carry_available_at_and_due_at(migrated_db: async_sessionmaker) -> None:
    ah, _ = await _ga(migrated_db)
    async with client(migrated_db) as c:
        ex = (await c.post("/api/v1/exercises", json={"name": "E"}, headers=ah)).json()["data"]["id"]
        await c.post(f"/api/v1/exercises/{ex}/teams", json={"name": "BT1", "team_type": "blue"}, headers=ah)
        tid = await _published_template(c, ah)
        due = "2026-12-01T00:00:00Z"
        available = "2026-11-25T00:00:00Z"
        await c.post(
            f"/api/v1/exercises/{ex}/campaigns",
            json={"name": "C", "report_specs": [{"template_id": tid, "available_at": available, "due_at": due}]},
            headers=ah,
        )
        reports = (await c.get(f"/api/v1/exercises/{ex}/reports", headers=ah)).json()["data"]
        assert reports[0]["due_at"].startswith("2026-12-01")
        assert reports[0]["available_at"].startswith("2026-11-25")


async def test_campaign_report_specs_rejects_empty_list(migrated_db: async_sessionmaker) -> None:
    ah, _ = await _ga(migrated_db)
    async with client(migrated_db) as c:
        ex = (await c.post("/api/v1/exercises", json={"name": "E"}, headers=ah)).json()["data"]["id"]
        r = await c.post(f"/api/v1/exercises/{ex}/campaigns", json={"name": "C", "report_specs": []}, headers=ah)
        assert r.status_code == 422


async def test_campaign_report_specs_rejects_unpublished_template(migrated_db: async_sessionmaker) -> None:
    ah, _ = await _ga(migrated_db)
    async with client(migrated_db) as c:
        ex = (await c.post("/api/v1/exercises", json={"name": "E"}, headers=ah)).json()["data"]["id"]
        await c.post(f"/api/v1/exercises/{ex}/teams", json={"name": "BT1", "team_type": "blue"}, headers=ah)
        tid = (await c.post("/api/v1/templates", json={"name": "T", "report_type": "spot"}, headers=ah)).json()["data"]["id"]
        r = await c.post(
            f"/api/v1/exercises/{ex}/campaigns", json={"name": "C", "report_specs": [{"template_id": tid}]}, headers=ah
        )
        assert r.status_code == 409


async def test_campaign_report_specs_rejects_no_teams(migrated_db: async_sessionmaker) -> None:
    ah, _ = await _ga(migrated_db)
    async with client(migrated_db) as c:
        ex = (await c.post("/api/v1/exercises", json={"name": "E"}, headers=ah)).json()["data"]["id"]
        tid = await _published_template(c, ah)
        r = await c.post(
            f"/api/v1/exercises/{ex}/campaigns", json={"name": "C", "report_specs": [{"template_id": tid}]}, headers=ah
        )
        assert r.status_code == 422
        assert r.json()["error"]["message"] == "exercise_has_no_teams"


async def test_campaign_without_report_specs_still_creates_an_empty_campaign(migrated_db: async_sessionmaker) -> None:
    # Backward compatibility — report_specs is optional.
    ah, _ = await _ga(migrated_db)
    async with client(migrated_db) as c:
        ex = (await c.post("/api/v1/exercises", json={"name": "E"}, headers=ah)).json()["data"]["id"]
        r = await c.post(f"/api/v1/exercises/{ex}/campaigns", json={"name": "C"}, headers=ah)
        assert r.status_code == 201, r.text
        assert r.json()["data"]["report_count"] == 0
  • Step 2: Run to verify fail.
cd backend && uv run pytest tests/routes/test_campaigns.py -k report_specs

Expected: FAIL — report_specs unknown field / report_count stays 0.

  • Step 3: Extract _instantiate_report, then fan out.

In reports.py, extract the report-row + section-seeding block already inside create_report (everything from report = Report(...) through the final section-seeding await db.flush()) into:

async def _instantiate_report(
    db: AsyncSession,
    *,
    exercise_id: uuid.UUID,
    team: Team,
    template: ReportTemplate,
    actor_id: uuid.UUID,
    name: str,
    description: str | None = None,
    due_at: datetime | None = None,
    available_at: datetime | None = None,
    approval_required: bool = False,
    approval_chain: list[dict[str, object]] | None = None,
    assigned_writer_id: uuid.UUID | None = None,
) -> Report:
    report = Report(
        exercise_id=exercise_id,
        team_id=team.id,
        template_id=template.id,
        template_version_at_creation=template.version,
        name=name,
        description=description,
        status="draft",
        approval_required=approval_required,
        approval_chain=approval_chain,
        due_at=due_at,
        available_at=available_at,
        assigned_writer_id=assigned_writer_id,
        created_by=actor_id,
    )
    db.add(report)
    await db.flush()
    defs = (
        (
            await db.execute(
                select(TemplateSectionDef).where(TemplateSectionDef.template_id == template.id).order_by(TemplateSectionDef.position)
            )
        )
        .scalars()
        .all()
    )
    for d in defs:
        if d.field_type == "rich_text" and d.default_content:
            clean = sanitize_html(d.default_content)
            plain = html_to_plain(d.default_content)
            db.add(ReportSection(report_id=report.id, section_def_id=d.id, position=d.position, version=1, content=clean, content_plain=plain, char_count=len(plain)))
        else:
            db.add(ReportSection(report_id=report.id, section_def_id=d.id, position=d.position, version=1, char_count=0))
    await db.flush()
    return report

create_report keeps its own validation (team lookup + exercise match, template lookup + published check, assigned-writer membership check) and just calls _instantiate_report(db, exercise_id=exercise_id, team=team, template=template, actor_id=actor.id, name=body.name, description=body.description, due_at=body.due_at, available_at=body.available_at, approval_required=body.approval_required, approval_chain=[...], assigned_writer_id=...) in place of its inline construction, then builds ReportDetailOut exactly as before.

In campaigns.py's create_campaign, after the campaign row is flushed:

if body.report_specs:
    teams = (await db.execute(select(Team).where(Team.exercise_id == exercise_id))).scalars().all()
    if not teams:
        raise HTTPException(status_code=422, detail={"error": "exercise_has_no_teams"})
    templates: dict[str, ReportTemplate] = {}
    for spec in body.report_specs:
        if spec.template_id not in templates:
            tpl = (await db.execute(select(ReportTemplate).where(ReportTemplate.id == uuid.UUID(spec.template_id)))).scalar_one_or_none()
            if tpl is None:
                raise HTTPException(status_code=404, detail="template not found")
            if tpl.status != "published":
                raise HTTPException(status_code=409, detail="template is not published")
            templates[spec.template_id] = tpl
    for team in teams:
        for spec in body.report_specs:
            tpl = templates[spec.template_id]
            report = await _instantiate_report(
                db, exercise_id=exercise_id, team=team, template=tpl, actor_id=actor.id,
                name=f"{tpl.name} — {team.name}", due_at=spec.due_at, available_at=spec.available_at,
            )
            db.add(CampaignReport(campaign_id=c.id, report_id=report.id))
    await db.flush()

Import _instantiate_report from reports.py into campaigns.py, same pattern as the other shared helpers already imported there.

  • Step 4: Refactor — none beyond the extraction itself, which Step 3 already is. No further abstraction warranted for two call sites.

  • Step 5: Run to verify pass + gate.

cd backend && uv run pytest tests/routes/test_campaigns.py tests/routes/test_reports_instantiate.py
just lint && just test

Expected: PASS.

  • Step 6: Commit
git add backend/app/schemas/campaign.py backend/app/routes/v1/reports.py backend/app/routes/v1/campaigns.py \
        backend/tests/routes/test_campaigns.py
git commit -m "feat(campaigns): fan out report specs per team when defining a campaign

Refs #220"

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    backendBackend (FastAPI / SQLAlchemy / Postgres)wp5Work Package 5

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions