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"} |
# 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
cd backend && uv run pytest tests/routes/test_campaigns.py -k report_specs
Expected: FAIL — report_specs unknown field / report_count stays 0.
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.
cd backend && uv run pytest tests/routes/test_campaigns.py tests/routes/test_reports_instantiate.py
just lint && just test
Expected: PASS.
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"
Design
POST /exercises/{eid}/campaignsalready exists (create_campaign). Rather than adding a second, parallel campaign-creation endpoint,CampaignCreategains an optionalreport_specsfield. 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×mReportrows — and links every one of them into the campaign it just created, all in one transaction. Omittingreport_specskeeps today's exact behavior: an empty campaign.The section-instantiation logic
create_reportalready has (snapshot the template version, seedReportSectionrows, apply template-authoreddefault_content) is extracted into a shared_instantiate_report(...)helper inreports.py, called once per (team, spec) pair by the fan-out and once bycreate_reportitself — no behavior change for the existing single-report path, just de-duplicated.No
team_idand noevaluator_idin a spec — teams are every team in the exercise (fan-out), evaluators are resolved automatically per #219.Files
backend/app/schemas/campaign.py(CampaignReportSpec,CampaignCreate.report_specs)backend/app/routes/v1/reports.py(extract_instantiate_report, used bycreate_reportand campaigns.py)backend/app/routes/v1/campaigns.py(create_campaignfans out whenreport_specsis set)backend/tests/routes/test_campaigns.pybackend/tests/routes/test_reports_instantiate.py(confirmcreate_report's own behavior is unchanged after the extraction)Interfaces
CampaignReportSpec{template_id: str, available_at: datetime | None = None, due_at: datetime | None = None}.CampaignCreate.report_specs: list[CampaignReportSpec] | None = None(non-empty if present, same_reject_empty_chain-style validator already used forapproval_chain)._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, withreport_countnow correctly reflecting the fan-out).Error contract (all on
POST /exercises/{eid}/campaignswhenreport_specsis set):report_specspresent but emptytemplate_idnot found"template not found""template is not published"{"error": "exercise_has_no_teams"}Expected: FAIL —
report_specsunknown field /report_countstays 0._instantiate_report, then fan out.In
reports.py, extract the report-row + section-seeding block already insidecreate_report(everything fromreport = Report(...)through the final section-seedingawait db.flush()) into:create_reportkeeps 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 buildsReportDetailOutexactly as before.In
campaigns.py'screate_campaign, after the campaign row is flushed:Import
_instantiate_reportfromreports.pyintocampaigns.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.
Expected: PASS.
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"