Skip to content

#217 · Task 1 — available_at on Report, enforced on section save #218

Description

@romainkieffer

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>"}
  • Step 1: Write the failing tests
# 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
  • Step 2: Run to verify fail.
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.

  • Step 3: Migration, model, schemas, guard.
# 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.

  • Step 4: Refactor — none needed. One pure guard function, same shape as _require_draft; no extraction warranted.

  • Step 5: Run to verify pass + gate.

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

Expected: PASS.

  • Step 6: Commit
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.

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