Part of #217 (admin screen to define a campaign).
Files:
- Create:
backend/alembic/versions/0015_report_available_at.py
- Modify:
backend/app/models/report.py (add available_at)
- Modify:
backend/app/schemas/report.py (ReportCreate, ReportUpdate, ReportOut, ReportDetailOut)
- Modify:
backend/app/routes/v1/reports.py (_require_available, create_report, save_section)
- Test:
backend/tests/routes/test_reports_sections.py
- Test:
backend/tests/routes/test_reports_instantiate.py (create with available_at round-trips)
Interfaces:
- Adds:
Report.available_at: datetime | None.
- Adds:
ReportCreate.available_at: datetime | None = None, ReportUpdate.available_at: datetime | None = None (the latter needs no extra handling — update_report already applies ReportUpdate.model_dump(exclude_unset=True) generically via setattr).
- Adds:
_require_available(r: Report) -> None, called in save_section right after the existing _require_draft(report).
Error contract:
| Condition |
Status |
detail |
now() < report.available_at on section save |
403 |
{"error": "report_not_yet_available", "available_at": "<iso>"} |
# backend/tests/routes/test_reports_sections.py — additions
from datetime import UTC, datetime, timedelta
async def _mk_report_with_available_at(c, ah, *, available_at):
tid = (await c.post("/api/v1/templates", json={"name": "T", "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)
ex = (await c.post("/api/v1/exercises", json={"name": "E"}, headers=ah)).json()["data"]["id"]
team = (await c.post(f"/api/v1/exercises/{ex}/teams", json={"name": "A", "team_type": "blue"}, headers=ah)).json()["data"]["id"]
detail = (
await c.post(
f"/api/v1/exercises/{ex}/reports",
json={"template_id": tid, "team_id": team, "name": "R", "available_at": available_at.isoformat()},
headers=ah,
)
).json()["data"]
return ex, detail["id"], detail["sections"][0]["id"]
async def test_create_report_round_trips_available_at(migrated_db: async_sessionmaker) -> None:
ah = await _ga(migrated_db)
async with client(migrated_db) as c:
future = datetime.now(UTC) + timedelta(days=1)
ex, rid, _ = await _mk_report_with_available_at(c, ah, available_at=future)
r = await c.get(f"/api/v1/exercises/{ex}/reports/{rid}", headers=ah)
assert r.json()["data"]["available_at"] is not None
async def test_save_section_before_available_at_returns_403(migrated_db: async_sessionmaker) -> None:
ah = await _ga(migrated_db)
async with client(migrated_db) as c:
future = datetime.now(UTC) + timedelta(days=1)
ex, rid, sid = await _mk_report_with_available_at(c, ah, available_at=future)
r = await c.patch(
f"/api/v1/exercises/{ex}/reports/{rid}/sections/{sid}",
json={"version": 1, "body": {"kind": "rich_text", "content": "<p>too early</p>"}},
headers=ah,
)
assert r.status_code == 403
assert r.json()["error"]["message"] == "report_not_yet_available"
async def test_save_section_after_available_at_succeeds(migrated_db: async_sessionmaker) -> None:
ah = await _ga(migrated_db)
async with client(migrated_db) as c:
past = datetime.now(UTC) - timedelta(days=1)
ex, rid, sid = await _mk_report_with_available_at(c, ah, available_at=past)
r = await c.patch(
f"/api/v1/exercises/{ex}/reports/{rid}/sections/{sid}",
json={"version": 1, "body": {"kind": "rich_text", "content": "<p>on time</p>"}},
headers=ah,
)
assert r.status_code == 200, r.text
async def test_save_section_with_no_available_at_set_succeeds(migrated_db: async_sessionmaker) -> None:
# Default behavior: no available_at means immediately fillable, same as before this field existed.
ah = await _ga(migrated_db)
async with client(migrated_db) as c:
ex, rid, sid = await _mk_report(c, ah)
r = await c.patch(
f"/api/v1/exercises/{ex}/reports/{rid}/sections/{sid}",
json={"version": 1, "body": {"kind": "rich_text", "content": "<p>fine</p>"}},
headers=ah,
)
assert r.status_code == 200, r.text
cd backend && uv run pytest tests/routes/test_reports_sections.py
Expected: FAIL — available_at unknown field on create; no 403 on the early-save test.
# backend/alembic/versions/0015_report_available_at.py
"""report: add available_at, when a report becomes fillable by its writers"""
import sqlalchemy as sa
from alembic import op
revision = "0015"
down_revision = "0014"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("report", sa.Column("available_at", sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
op.drop_column("report", "available_at")
backend/app/models/report.py — alongside due_at:
available_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
backend/app/schemas/report.py — add available_at: datetime | None = None to ReportCreate and ReportUpdate, and available_at: datetime | None to ReportOut/ReportDetailOut plus their from_model/from_models (mirroring every due_at line exactly).
backend/app/routes/v1/reports.py:
def _require_available(r: Report) -> None:
if r.available_at is not None and datetime.now(UTC) < r.available_at:
raise HTTPException(
status_code=403,
detail={"error": "report_not_yet_available", "available_at": r.available_at.isoformat()},
)
Called in save_section immediately after _require_draft(report). create_report passes available_at=body.available_at into the Report(...) constructor, mirroring due_at.
cd backend && uv run pytest tests/routes/test_reports_sections.py tests/routes/test_reports_instantiate.py
just lint && just test
Expected: PASS.
git add backend/alembic/versions/0015_report_available_at.py backend/app/models/report.py \
backend/app/schemas/report.py backend/app/routes/v1/reports.py \
backend/tests/routes/test_reports_sections.py
git commit -m "feat(reports): available_at gates when a report becomes fillable
Refs #217"
Status
Implemented on feature/wp5-campaign-two-pane (commits bba7532, 029c33f for the OpenAPI/type regeneration that followed). Full gate green: backend pytest (931 passed), mypy, ruff, frontend vitest (328 passed), tsc, eslint, prettier.
Files:
backend/alembic/versions/0015_report_available_at.pybackend/app/models/report.py(addavailable_at)backend/app/schemas/report.py(ReportCreate,ReportUpdate,ReportOut,ReportDetailOut)backend/app/routes/v1/reports.py(_require_available,create_report,save_section)backend/tests/routes/test_reports_sections.pybackend/tests/routes/test_reports_instantiate.py(create withavailable_atround-trips)Interfaces:
Report.available_at: datetime | None.ReportCreate.available_at: datetime | None = None,ReportUpdate.available_at: datetime | None = None(the latter needs no extra handling —update_reportalready appliesReportUpdate.model_dump(exclude_unset=True)generically viasetattr)._require_available(r: Report) -> None, called insave_sectionright after the existing_require_draft(report).Error contract:
detailnow() < report.available_aton section save{"error": "report_not_yet_available", "available_at": "<iso>"}Expected: FAIL —
available_atunknown field on create; no 403 on the early-save test.backend/app/models/report.py— alongsidedue_at:backend/app/schemas/report.py— addavailable_at: datetime | None = NonetoReportCreateandReportUpdate, andavailable_at: datetime | NonetoReportOut/ReportDetailOutplus theirfrom_model/from_models(mirroring everydue_atline exactly).backend/app/routes/v1/reports.py:Called in
save_sectionimmediately after_require_draft(report).create_reportpassesavailable_at=body.available_atinto theReport(...)constructor, mirroringdue_at.Step 4: Refactor — none needed. One pure guard function, same shape as
_require_draft; no extraction warranted.Step 5: Run to verify pass + gate.
Expected: PASS.
git add backend/alembic/versions/0015_report_available_at.py backend/app/models/report.py \ backend/app/schemas/report.py backend/app/routes/v1/reports.py \ backend/tests/routes/test_reports_sections.py git commit -m "feat(reports): available_at gates when a report becomes fillable Refs #217"Status
Implemented on
feature/wp5-campaign-two-pane(commitsbba7532,029c33ffor the OpenAPI/type regeneration that followed). Full gate green: backend pytest (931 passed), mypy, ruff, frontend vitest (328 passed), tsc, eslint, prettier.