diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c72a0ff..2de6a02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ main ] + branches: [ main, develop ] pull_request: - branches: [ main ] + branches: [ main, develop ] jobs: lint: diff --git a/.gitignore b/.gitignore index cf7489e..4516af1 100644 --- a/.gitignore +++ b/.gitignore @@ -169,3 +169,4 @@ docs/tasking-mvp/tasking-mvp.postman_collection.json docs/tasking-mvp/tasking-mvp.postman_environment.json docs/tasking-mvp/_enrich_postman.py docs/tasking-mvp/feature-coverage.md +.idea/ diff --git a/alembic_osm/versions/9221408912dd_add_user_role_table.py b/alembic_osm/versions/9221408912dd_add_user_role_table.py index 640453e..7d7fa57 100644 --- a/alembic_osm/versions/9221408912dd_add_user_role_table.py +++ b/alembic_osm/versions/9221408912dd_add_user_role_table.py @@ -13,7 +13,6 @@ from sqlalchemy import inspect, text from sqlalchemy.dialects import postgresql - # revision identifiers, used by Alembic. revision: str = "9221408912dd" down_revision: Union[str, None] = None @@ -37,9 +36,7 @@ def upgrade() -> None: "users", # `id` matches the Rails `users.id` numeric PK so the FK # from `team_user.user_id` in the next migration can attach. - sa.Column( - "id", sa.BigInteger(), autoincrement=True, nullable=False - ), + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), sa.Column("auth_uid", sa.String(), nullable=False), sa.Column("email", sa.String(), nullable=True), sa.Column("display_name", sa.String(), nullable=True), diff --git a/alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py b/alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py index 8606ce9..d792f4c 100644 --- a/alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py +++ b/alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py @@ -1,5 +1,3 @@ - - from typing import Sequence, Union import sqlalchemy as sa @@ -244,9 +242,7 @@ def upgrade() -> None: ), sa.Column("project_id", sa.BigInteger(), nullable=False), sa.Column("task_number", sa.Integer(), nullable=False), - sa.Column( - "area_sqkm", sa.Numeric(precision=10, scale=4), nullable=False - ), + sa.Column("area_sqkm", sa.Numeric(precision=10, scale=4), nullable=False), sa.Column( "status", postgresql.ENUM( @@ -287,13 +283,8 @@ def upgrade() -> None: "ON tasking_tasks USING GIST (geometry)" ) else: - op.execute( - "ALTER TABLE tasking_tasks " - "ADD COLUMN geometry BYTEA" - ) - op.create_index( - "tasking_tasks_project_idx", "tasking_tasks", ["project_id"] - ) + op.execute("ALTER TABLE tasking_tasks " "ADD COLUMN geometry BYTEA") + op.create_index("tasking_tasks_project_idx", "tasking_tasks", ["project_id"]) # ---- tasking_locks ------------------------------------------------ @@ -439,13 +430,9 @@ def upgrade() -> None: sa.ForeignKeyConstraint( ["project_id"], ["tasking_projects.id"], ondelete="CASCADE" ), - sa.ForeignKeyConstraint( - ["author_user_auth_uid"], ["users.auth_uid"] - ), - ) - op.create_index( - "tasking_feedback_task_idx", "tasking_feedback", ["task_id"] + sa.ForeignKeyConstraint(["author_user_auth_uid"], ["users.auth_uid"]), ) + op.create_index("tasking_feedback_task_idx", "tasking_feedback", ["task_id"]) op.create_index( "tasking_feedback_project_idx", "tasking_feedback", ["project_id"] ) diff --git a/alembic_task/versions/c5121cbba124_initial_task_schema.py b/alembic_task/versions/c5121cbba124_initial_task_schema.py index ef9befa..a06f085 100644 --- a/alembic_task/versions/c5121cbba124_initial_task_schema.py +++ b/alembic_task/versions/c5121cbba124_initial_task_schema.py @@ -1,4 +1,3 @@ - from typing import Sequence, Union import sqlalchemy as sa @@ -54,7 +53,9 @@ def upgrade() -> None: sa.Column("tdeiProjectGroupId", sa.Uuid(), nullable=False), sa.Column("tdeiRecordId", sa.Uuid(), nullable=True), sa.Column("tdeiServiceId", sa.Uuid(), nullable=True), - sa.Column("tdeiMetadata", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column( + "tdeiMetadata", postgresql.JSONB(astext_type=sa.Text()), nullable=True + ), sa.Column("createdAt", sa.DateTime(), nullable=False), sa.Column("createdBy", sa.Uuid(), nullable=False), sa.Column("createdByName", sa.String(), nullable=False), @@ -98,7 +99,6 @@ def upgrade() -> None: ) - def downgrade() -> None: bind = op.get_bind() assert bind is not None diff --git a/api/core/security.py b/api/core/security.py index 9919699..6a8f350 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -130,9 +130,7 @@ async def fetch_project_group_users( TdeiProjectGroupUser( auth_uid=str(uid), email=row.get("email"), - display_name=( - row.get("username") - ), + display_name=(row.get("username")), ) ) diff --git a/api/main.py b/api/main.py index cbe1007..fcff505 100644 --- a/api/main.py +++ b/api/main.py @@ -22,6 +22,7 @@ init_tdei_client, validate_token, ) +from api.src.tasking.audit.routes import router as tasking_audit_router from api.src.tasking.projects.routes import me_router as tasking_me_router from api.src.tasking.projects.routes import router as tasking_projects_router from api.src.tasking.tasks.routes import router as tasking_tasks_router @@ -97,6 +98,7 @@ async def lifespan(_app: FastAPI): app.include_router(tasking_projects_router, prefix="/api/v1") app.include_router(tasking_me_router, prefix="/api/v1") app.include_router(tasking_tasks_router, prefix="/api/v1") +app.include_router(tasking_audit_router, prefix="/api/v1") @app.get("/health") diff --git a/api/src/tasking/audit/__init__.py b/api/src/tasking/audit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/src/tasking/audit/dtos.py b/api/src/tasking/audit/dtos.py new file mode 100644 index 0000000..b40f0f3 --- /dev/null +++ b/api/src/tasking/audit/dtos.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Optional +from uuid import UUID + +from api.src.tasking.audit.schemas import AuditEventType +from api.src.tasking.projects.dtos import Pagination, WireModel + + +class ActorRef(WireModel): + """Resolved actor for an audit event.""" + + user_id: UUID + display_name: Optional[str] = None + + +class AuditEvent(WireModel): + """One row in `tasking_audit_events`, with `actor` joined for display.""" + + id: int + event_type: AuditEventType + project_id: int + task_id: Optional[int] = None + task_number: Optional[int] = None + actor: ActorRef + occurred_at: datetime + details: dict[str, Any] + project_deleted: bool = False + + +class AuditEventListResponse(WireModel): + results: list[AuditEvent] + pagination: Pagination + + +__all__ = [ + "ActorRef", + "AuditEvent", + "AuditEventListResponse", +] diff --git a/api/src/tasking/audit/repository.py b/api/src/tasking/audit/repository.py new file mode 100644 index 0000000..4f589b4 --- /dev/null +++ b/api/src/tasking/audit/repository.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional +from uuid import UUID + +from sqlalchemy import text +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.exceptions import NotFoundException +from api.src.tasking.audit.dtos import ActorRef, AuditEvent, AuditEventListResponse +from api.src.tasking.audit.schemas import AuditEventType +from api.src.tasking.projects.dtos import Pagination + +_ALLOWED_ORDER_DIR = {"ASC", "DESC"} + + +def _normalise_dir(order_dir: str) -> str: + return order_dir.upper() if order_dir.upper() in _ALLOWED_ORDER_DIR else "DESC" + + +def _clamp_page(page: int, page_size: int, max_page_size: int) -> tuple[int, int]: + return max(page, 1), max(min(page_size, max_page_size), 1) + + +class TaskingAuditRepository: + """Read-side queries for `tasking_audit_events`. + + Writes are owned by the task and project repositories — see + `_audit()` in `api/src/tasking/tasks/repository.py`. This module + only reads. + """ + + def __init__(self, session: AsyncSession): + self.session = session + + # ---- internal helpers ------------------------------------------------- + + async def _assert_project_visible( + self, workspace_id: int, project_id: int, *, include_deleted: bool + ) -> None: + """Confirm the project lives in the workspace; honour soft-delete + unless the caller explicitly opted into deleted projects. + """ + clause = ( + "SELECT 1 FROM tasking_projects " "WHERE id = :pid AND workspace_id = :wid" + ) + if not include_deleted: + clause += " AND deleted_at IS NULL" + + result = await self.session.execute( + text(clause), {"pid": project_id, "wid": workspace_id} + ) + if result.scalar() is None: + raise NotFoundException(f"Project {project_id} not found") + + async def _task_id_from_number(self, project_id: int, task_number: int) -> int: + result = await self.session.execute( + text( + "SELECT id FROM tasking_tasks " + "WHERE project_id = :pid AND task_number = :tn" + ), + {"pid": project_id, "tn": task_number}, + ) + task_id = result.scalar() + if task_id is None: + raise NotFoundException( + f"Task {task_number} not found on project {project_id}" + ) + return int(task_id) + + async def _resolve_actor_names( + self, auth_uids: set[str] + ) -> dict[str, Optional[str]]: + """Batch-load `users.display_name` for every distinct actor. + + Avoids the N+1 fetch when rendering long event lists. UIDs with + no `users` row map to None (the field is optional). + """ + if not auth_uids: + return {} + rows = await self.session.execute( + text( + "SELECT auth_uid, display_name FROM users " + "WHERE auth_uid = ANY(:uids)" + ), + {"uids": list(auth_uids)}, + ) + mapping = {row[0]: row[1] for row in rows.all()} + # Unknown actors still need a None entry so callers can `.get(uid)`. + for uid in auth_uids: + mapping.setdefault(uid, None) + return mapping + + @staticmethod + def _row_to_event( + row, + names: dict[str, Optional[str]], + ) -> AuditEvent: + ( + event_id, + event_type, + project_id, + task_id, + actor_uid, + occurred_at, + details, + project_deleted, + ) = row + details = details or {} + task_number = details.get("task_number") if isinstance(details, dict) else None + return AuditEvent( + id=event_id, + event_type=AuditEventType(event_type), + project_id=project_id, + task_id=task_id, + task_number=task_number, + actor=ActorRef( + user_id=UUID(str(actor_uid)), + display_name=names.get(str(actor_uid)), + ), + occurred_at=occurred_at, + details=details, + project_deleted=bool(project_deleted), + ) + + # ---- listing queries -------------------------------------------------- + + async def list_project_events( + self, + workspace_id: int, + project_id: int, + *, + event_type: Optional[AuditEventType] = None, + task_number: Optional[int] = None, + actor_user_id: Optional[UUID] = None, + occurred_from: Optional[datetime] = None, + occurred_to: Optional[datetime] = None, + include_deleted: bool = False, + page: int = 1, + page_size: int = 50, + order_dir: str = "DESC", + ) -> AuditEventListResponse: + await self._assert_project_visible( + workspace_id, project_id, include_deleted=include_deleted + ) + + page, page_size = _clamp_page(page, page_size, max_page_size=200) + order_dir = _normalise_dir(order_dir) + + # `task_number` is stored inside `details` JSONB rather than as a + # column, so filter via the typed accessor. + where = ["project_id = :pid"] + params: dict = {"pid": project_id} + if event_type is not None: + where.append("event_type = :et") + params["et"] = event_type.value + if task_number is not None: + where.append("(details->>'task_number')::int = :tn") + params["tn"] = task_number + if actor_user_id is not None: + where.append("actor_user_auth_uid = :au") + params["au"] = str(actor_user_id) + if occurred_from is not None: + where.append("occurred_at >= :from_ts") + params["from_ts"] = occurred_from + if occurred_to is not None: + where.append("occurred_at <= :to_ts") + params["to_ts"] = occurred_to + where_sql = " AND ".join(where) + + total_q = await self.session.execute( + text(f"SELECT COUNT(*) FROM tasking_audit_events WHERE {where_sql}"), + params, + ) + total = int(total_q.scalar() or 0) + + rows = await self.session.execute( + text( + "SELECT id, event_type, project_id, task_id, " + " actor_user_auth_uid, occurred_at, details, project_deleted " + "FROM tasking_audit_events " + f"WHERE {where_sql} " + f"ORDER BY occurred_at {order_dir}, id {order_dir} " + "LIMIT :limit OFFSET :offset" + ), + {**params, "limit": page_size, "offset": (page - 1) * page_size}, + ) + raw_rows = list(rows.all()) + actor_uids = {str(r[4]) for r in raw_rows} + names = await self._resolve_actor_names(actor_uids) + + return AuditEventListResponse( + results=[self._row_to_event(r, names) for r in raw_rows], + pagination=Pagination(page=page, page_size=page_size, total=total), + ) + + async def list_task_events( + self, + workspace_id: int, + project_id: int, + task_number: int, + *, + event_type: Optional[AuditEventType] = None, + actor_user_id: Optional[UUID] = None, + occurred_from: Optional[datetime] = None, + occurred_to: Optional[datetime] = None, + page: int = 1, + page_size: int = 25, + order_dir: str = "DESC", + ) -> AuditEventListResponse: + # Task-level listings only ever surface live projects. + await self._assert_project_visible( + workspace_id, project_id, include_deleted=False + ) + task_id = await self._task_id_from_number(project_id, task_number) + + page, page_size = _clamp_page(page, page_size, max_page_size=200) + order_dir = _normalise_dir(order_dir) + + # Match either `task_id = :tid` or the `task_number` in `details` + # — early audit rows for task creation set task_id but later + # rows that reference a task (e.g. project-level lock-extensions) + # may only persist the task_number. Both forms point at the + # same task, so OR them. + where = [ + "project_id = :pid", + "(task_id = :tid OR (details->>'task_number')::int = :tn)", + ] + params: dict = {"pid": project_id, "tid": task_id, "tn": task_number} + if event_type is not None: + where.append("event_type = :et") + params["et"] = event_type.value + if actor_user_id is not None: + where.append("actor_user_auth_uid = :au") + params["au"] = str(actor_user_id) + if occurred_from is not None: + where.append("occurred_at >= :from_ts") + params["from_ts"] = occurred_from + if occurred_to is not None: + where.append("occurred_at <= :to_ts") + params["to_ts"] = occurred_to + where_sql = " AND ".join(where) + + total_q = await self.session.execute( + text(f"SELECT COUNT(*) FROM tasking_audit_events WHERE {where_sql}"), + params, + ) + total = int(total_q.scalar() or 0) + + rows = await self.session.execute( + text( + "SELECT id, event_type, project_id, task_id, " + " actor_user_auth_uid, occurred_at, details, project_deleted " + "FROM tasking_audit_events " + f"WHERE {where_sql} " + f"ORDER BY occurred_at {order_dir}, id {order_dir} " + "LIMIT :limit OFFSET :offset" + ), + {**params, "limit": page_size, "offset": (page - 1) * page_size}, + ) + raw_rows = list(rows.all()) + actor_uids = {str(r[4]) for r in raw_rows} + names = await self._resolve_actor_names(actor_uids) + + return AuditEventListResponse( + results=[self._row_to_event(r, names) for r in raw_rows], + pagination=Pagination(page=page, page_size=page_size, total=total), + ) + + +__all__ = ["TaskingAuditRepository"] diff --git a/api/src/tasking/audit/routes.py b/api/src/tasking/audit/routes.py new file mode 100644 index 0000000..e6ab06f --- /dev/null +++ b/api/src/tasking/audit/routes.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status # noqa: F401 +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.database import get_osm_session, get_task_session +from api.core.security import UserInfo, validate_token +from api.src.tasking.audit.dtos import AuditEventListResponse +from api.src.tasking.audit.repository import TaskingAuditRepository +from api.src.tasking.audit.schemas import AuditEventType +from api.src.workspaces.repository import WorkspaceRepository + +router = APIRouter( + prefix="/workspaces/{workspace_id}/tasking/projects", + tags=["tasking-audit"], +) + + +# --------------------------------------------------------------------------- +# Dependencies +# --------------------------------------------------------------------------- + + +def get_audit_repo( + session: AsyncSession = Depends(get_osm_session), +) -> TaskingAuditRepository: + return TaskingAuditRepository(session) + + +def get_workspace_repo( + session: AsyncSession = Depends(get_task_session), +) -> WorkspaceRepository: + return WorkspaceRepository(session) + + +async def assert_workspace_visible( + workspace_id: int, + current_user: UserInfo, + workspace_repo: WorkspaceRepository, +) -> None: + """Tenancy gate: 404 if the caller's project groups don't own the + workspace (matches `WorkspaceRepository.getById`'s convention). + """ + await workspace_repo.getById(current_user, workspace_id) + + +# --------------------------------------------------------------------------- +# Project-level audit +# --------------------------------------------------------------------------- + + +@router.get( + "/{project_id}/audit", + response_model=AuditEventListResponse, +) +async def list_project_audit( + workspace_id: int, + project_id: int, + event_type: Optional[AuditEventType] = Query(default=None), + task_number: Optional[int] = Query(default=None, ge=1), + actor_user_id: Optional[UUID] = Query(default=None), + occurred_from: Optional[datetime] = Query(default=None), + occurred_to: Optional[datetime] = Query(default=None), + include_deleted: bool = Query(default=False), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=50, ge=1, le=200), + order_by_type: str = Query(default="DESC"), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + audit_repo: TaskingAuditRepository = Depends(get_audit_repo), +): + """Paginated project-level audit listing, newest first.""" + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await audit_repo.list_project_events( + workspace_id, + project_id, + event_type=event_type, + task_number=task_number, + actor_user_id=actor_user_id, + occurred_from=occurred_from, + occurred_to=occurred_to, + include_deleted=include_deleted, + page=page, + page_size=page_size, + order_dir=order_by_type, + ) + + +# --------------------------------------------------------------------------- +# Task-level audit +# --------------------------------------------------------------------------- + + +@router.get( + "/{project_id}/tasks/{task_number}/audit", + response_model=AuditEventListResponse, +) +async def list_task_audit( + workspace_id: int, + project_id: int, + task_number: int, + event_type: Optional[AuditEventType] = Query(default=None), + actor_user_id: Optional[UUID] = Query(default=None), + occurred_from: Optional[datetime] = Query(default=None), + occurred_to: Optional[datetime] = Query(default=None), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=25, ge=1, le=200), + order_by_type: str = Query(default="DESC"), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + audit_repo: TaskingAuditRepository = Depends(get_audit_repo), +): + """Paginated audit listing for a single task, newest first.""" + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await audit_repo.list_task_events( + workspace_id, + project_id, + task_number, + event_type=event_type, + actor_user_id=actor_user_id, + occurred_from=occurred_from, + occurred_to=occurred_to, + page=page, + page_size=page_size, + order_dir=order_by_type, + ) + + +__all__ = ["router"] diff --git a/api/src/tasking/audit/schemas.py b/api/src/tasking/audit/schemas.py new file mode 100644 index 0000000..b24c405 --- /dev/null +++ b/api/src/tasking/audit/schemas.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum +from typing import Any, Optional +from uuid import UUID + +from sqlalchemy import Column +from sqlalchemy.dialects.postgresql import JSONB +from sqlmodel import Field, SQLModel + + +class AuditEventType(StrEnum): + """Closed set of event types written to `tasking_audit_events`. + + Mirrors the spec's `AuditEventType` enum; values match the literal + strings persisted in the `event_type` column. + """ + + PROJECT_CREATED = "project_created" + PROJECT_ACTIVATED = "project_activated" + PROJECT_CLOSED = "project_closed" + PROJECT_EDITED = "project_edited" + PROJECT_DELETED = "project_deleted" + PROJECT_RESET = "project_reset" + AOI_UPLOADED = "aoi_uploaded" + AOI_DELETED = "aoi_deleted" + TASK_CREATED = "task_created" + TASK_STATE_CHANGED = "task_state_changed" + TASK_LOCKED = "task_locked" + TASK_LOCK_EXTENDED = "task_lock_extended" + TASK_LOCK_RENEWED = "task_lock_renewed" + TASK_UNLOCKED = "task_unlocked" + TASK_RESET = "task_reset" + CHANGESET_SUBMITTED = "changeset_submitted" + FEEDBACK_SUBMITTED = "feedback_submitted" + + +class TaskingAuditEvent(SQLModel, table=True): + """Read-only mirror of `tasking_audit_events`. + + Writes happen via raw SQL in the task/project repositories so the + insert path stays decoupled from this module. The table is FK-free + by design (it must survive a project soft-delete + child hard-delete). + """ + + __tablename__ = "tasking_audit_events" # type: ignore[assignment] + + id: Optional[int] = Field(default=None, primary_key=True) + event_type: str = Field(max_length=64, nullable=False) + project_id: int = Field(nullable=False, index=True) + task_id: Optional[int] = Field(default=None, nullable=True) + actor_user_auth_uid: UUID = Field(nullable=False) + occurred_at: datetime = Field(nullable=False) + details: dict[str, Any] = Field( + default_factory=dict, + sa_column=Column(JSONB, nullable=False), + ) + project_deleted: bool = Field(default=False, nullable=False) + + +__all__ = ["AuditEventType", "TaskingAuditEvent"] diff --git a/api/src/tasking/projects/dtos.py b/api/src/tasking/projects/dtos.py index ef8848f..488e981 100644 --- a/api/src/tasking/projects/dtos.py +++ b/api/src/tasking/projects/dtos.py @@ -4,7 +4,9 @@ from typing import Any, Literal, Optional from uuid import UUID -from pydantic import BaseModel, ConfigDict, Field as PydField, field_validator +from pydantic import BaseModel, ConfigDict +from pydantic import Field as PydField +from pydantic import field_validator from api.src.tasking.projects.schemas import ( AoiInput, @@ -14,14 +16,13 @@ _MultiPolygon, ) - # --------------------------------------------------------------------------- # Shared DTO base. # --------------------------------------------------------------------------- class WireModel(BaseModel): - + model_config = ConfigDict(extra="forbid") diff --git a/api/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py index a19089a..087a978 100644 --- a/api/src/tasking/projects/repository.py +++ b/api/src/tasking/projects/repository.py @@ -45,7 +45,6 @@ _Polygon, ) - # --------------------------------------------------------------------------- # AOI helpers # --------------------------------------------------------------------------- @@ -184,9 +183,7 @@ def __init__(self, session: AsyncSession): # ---- internal helpers -------------------------------------------- - async def _get_active( - self, workspace_id: int, project_id: int - ) -> TaskingProject: + async def _get_active(self, workspace_id: int, project_id: int) -> TaskingProject: """Fetch a non-deleted project scoped to a workspace; raise 404 otherwise.""" result = await self.session.execute( select(TaskingProject).where( @@ -233,13 +230,12 @@ async def _provision_users_from_tdei( a user who is known to TDEI but has not yet performed any action that would write them into `users` (e.g. first-time sign-in). """ - from api.core.security import fetch_project_group_users from sqlalchemy import text + from api.core.security import fetch_project_group_users + try: - members = await fetch_project_group_users( - project_group_id, bearer_token - ) + members = await fetch_project_group_users(project_group_id, bearer_token) except HTTPException: raise except Exception as e: @@ -276,9 +272,7 @@ async def _provision_users_from_tdei( return [u for u in missing_uuids if u not in resolved] - async def _missing_user_auth_uids( - self, uuids: list[UUID] - ) -> list[str]: + async def _missing_user_auth_uids(self, uuids: list[UUID]) -> list[str]: """Return the subset of `uuids` without a matching `users` row. Preflight for the `tasking_project_roles.user_auth_uid` FK so @@ -291,9 +285,7 @@ async def _missing_user_auth_uids( from sqlalchemy import text rows = await self.session.execute( - text( - "SELECT auth_uid FROM users WHERE auth_uid = ANY(:uids)" - ), + text("SELECT auth_uid FROM users WHERE auth_uid = ANY(:uids)"), {"uids": [str(u) for u in uuids]}, ) existing = {row[0] for row in rows.all()} @@ -305,9 +297,7 @@ async def _task_count(self, project_id: int) -> int: from sqlalchemy import text result = await self.session.execute( - text( - "SELECT COUNT(*) FROM tasking_tasks WHERE project_id = :pid" - ), + text("SELECT COUNT(*) FROM tasking_tasks WHERE project_id = :pid"), {"pid": project_id}, ) return int(result.scalar() or 0) @@ -626,9 +616,7 @@ async def soft_delete(self, workspace_id: int, project_id: int) -> None: # ---- lifecycle transitions --------------------------------------- - async def activate( - self, workspace_id: int, project_id: int - ) -> ProjectResponse: + async def activate(self, workspace_id: int, project_id: int) -> ProjectResponse: project = await self._get_active(workspace_id, project_id) if project.status != ProjectStatus.DRAFT: raise HTTPException( @@ -679,9 +667,7 @@ async def activate( await self.session.refresh(project) return self._to_response(project, task_count=tc) - async def close( - self, workspace_id: int, project_id: int - ) -> ProjectResponse: + async def close(self, workspace_id: int, project_id: int) -> ProjectResponse: project = await self._get_active(workspace_id, project_id) if project.status != ProjectStatus.OPEN: raise HTTPException( @@ -726,9 +712,7 @@ async def close( tc = await self._task_count(project.id) # type: ignore[arg-type] return self._to_response(project, task_count=tc) - async def reset( - self, workspace_id: int, project_id: int - ) -> ProjectResponse: + async def reset(self, workspace_id: int, project_id: int) -> ProjectResponse: """LEAD reset — see spec §projects.""" project = await self._get_active(workspace_id, project_id) if project.status == ProjectStatus.DRAFT: @@ -772,9 +756,7 @@ async def reset( # ---- AOI --------------------------------------------------------- - async def get_aoi( - self, workspace_id: int, project_id: int - ) -> AoiFeature: + async def get_aoi(self, workspace_id: int, project_id: int) -> AoiFeature: project = await self._get_active(workspace_id, project_id) if project.aoi is None: raise NotFoundException("AOI is not set on this project") @@ -823,9 +805,7 @@ async def upload_aoi( # violations on `user_auth_uid` are caught with a preflight so the # caller gets a 422 listing the missing user id. - async def _is_project_lead( - self, project_id: int, user_uuid: UUID - ) -> bool: + async def _is_project_lead(self, project_id: int, user_uuid: UUID) -> bool: """True if the user holds a `lead` role on the given project.""" from sqlalchemy import text @@ -858,8 +838,7 @@ async def assert_can_manage_roles( if await self._is_project_lead(project_id, current_user.user_uuid): return raise ForbiddenException( - "Only a workspace lead or project lead can manage roles " - "on this project." + "Only a workspace lead or project lead can manage roles " "on this project." ) async def _lead_count(self, project_id: int) -> int: @@ -1200,9 +1179,7 @@ async def remove_role( ) await self.session.commit() - async def _get_role( - self, project_id: int, user_id: UUID - ) -> ProjectRoleItem: + async def _get_role(self, project_id: int, user_id: UUID) -> ProjectRoleItem: item = await self._get_role_or_none(project_id, user_id) if item is None: # pragma: no cover — only called after insert/update raise NotFoundException( diff --git a/api/src/tasking/projects/routes.py b/api/src/tasking/projects/routes.py index 81f06f4..e7367f1 100644 --- a/api/src/tasking/projects/routes.py +++ b/api/src/tasking/projects/routes.py @@ -1,13 +1,13 @@ from __future__ import annotations from typing import Annotated +from uuid import UUID from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response, status from sqlmodel.ext.asyncio.session import AsyncSession from api.core.database import get_osm_session, get_task_session from api.core.security import UserInfo, validate_token -from api.src.tasking.projects.repository import TaskingProjectRepository from api.src.tasking.projects.dtos import ( AoiFeature, ProjectCreateRequest, @@ -20,11 +20,8 @@ ProjectUpdateRequest, SelfProjectRolesResponse, ) -from api.src.tasking.projects.schemas import ( - AoiInput, - ProjectStatus, -) -from uuid import UUID +from api.src.tasking.projects.repository import TaskingProjectRepository +from api.src.tasking.projects.schemas import AoiInput, ProjectStatus from api.src.workspaces.repository import WorkspaceRepository router = APIRouter( @@ -84,9 +81,7 @@ def assert_workspace_lead(workspace_id: int, current_user: UserInfo) -> None: @router.get("", response_model=ProjectListResponse) async def list_projects( workspace_id: int, - status_filter: Annotated[ - ProjectStatus | None, Query(alias="status") - ] = None, + status_filter: Annotated[ProjectStatus | None, Query(alias="status")] = None, text_search: str | None = Query(default=None, max_length=255), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=200), @@ -280,9 +275,7 @@ async def add_project_role( project_repo: TaskingProjectRepository = Depends(get_project_repo), ): await assert_workspace_visible(workspace_id, current_user, workspace_repo) - await project_repo.assert_can_manage_roles( - workspace_id, project_id, current_user - ) + await project_repo.assert_can_manage_roles(workspace_id, project_id, current_user) return await project_repo.add_role(workspace_id, project_id, body) @@ -318,9 +311,7 @@ async def put_project_role( ): """Idempotent upsert. 201 on insert, 200 on update. Last-LEAD guarded.""" await assert_workspace_visible(workspace_id, current_user, workspace_repo) - await project_repo.assert_can_manage_roles( - workspace_id, project_id, current_user - ) + await project_repo.assert_can_manage_roles(workspace_id, project_id, current_user) item, created = await project_repo.upsert_role( workspace_id, project_id, user_id, body ) @@ -343,12 +334,8 @@ async def update_project_role( project_repo: TaskingProjectRepository = Depends(get_project_repo), ): await assert_workspace_visible(workspace_id, current_user, workspace_repo) - await project_repo.assert_can_manage_roles( - workspace_id, project_id, current_user - ) - return await project_repo.update_role( - workspace_id, project_id, user_id, body - ) + await project_repo.assert_can_manage_roles(workspace_id, project_id, current_user) + return await project_repo.update_role(workspace_id, project_id, user_id, body) @router.delete( @@ -364,9 +351,7 @@ async def remove_project_role( project_repo: TaskingProjectRepository = Depends(get_project_repo), ): await assert_workspace_visible(workspace_id, current_user, workspace_repo) - await project_repo.assert_can_manage_roles( - workspace_id, project_id, current_user - ) + await project_repo.assert_can_manage_roles(workspace_id, project_id, current_user) await project_repo.remove_role(workspace_id, project_id, user_id) @@ -409,6 +394,4 @@ async def list_self_project_roles( Single round-trip for the project-list page. """ await assert_workspace_visible(workspace_id, current_user, workspace_repo) - return await project_repo.list_self_project_roles( - workspace_id, current_user - ) + return await project_repo.list_self_project_roles(workspace_id, current_user) diff --git a/api/src/tasking/projects/schemas.py b/api/src/tasking/projects/schemas.py index f0b4695..901d5f6 100644 --- a/api/src/tasking/projects/schemas.py +++ b/api/src/tasking/projects/schemas.py @@ -6,11 +6,12 @@ from uuid import UUID from geoalchemy2 import Geometry -from pydantic import BaseModel, Field as PydField -from sqlalchemy import Column, Enum as SAEnum +from pydantic import BaseModel +from pydantic import Field as PydField +from sqlalchemy import Column +from sqlalchemy import Enum as SAEnum from sqlmodel import Field, SQLModel - # --------------------------------------------------------------------------- # Enums (mirrors of postgres enums in the migration) # --------------------------------------------------------------------------- diff --git a/api/src/tasking/tasks/dtos.py b/api/src/tasking/tasks/dtos.py index edfec34..374bed2 100644 --- a/api/src/tasking/tasks/dtos.py +++ b/api/src/tasking/tasks/dtos.py @@ -7,11 +7,7 @@ from pydantic import Field as PydField from api.src.tasking.projects.dtos import Pagination, WireModel -from api.src.tasking.tasks.schemas import ( - FeedbackReason, - TaskStatus, -) - +from api.src.tasking.tasks.schemas import FeedbackReason, TaskStatus # --------------------------------------------------------------------------- # Task boundary GeoJSON (input for /tasks/validate and /tasks/save) diff --git a/api/src/tasking/tasks/repository.py b/api/src/tasking/tasks/repository.py index bb6cf41..0e785be 100644 --- a/api/src/tasking/tasks/repository.py +++ b/api/src/tasking/tasks/repository.py @@ -24,10 +24,7 @@ ) from api.core.security import UserInfo from api.src.tasking.projects.dtos import Pagination -from api.src.tasking.projects.schemas import ( - ProjectStatus, - TaskingProject, -) +from api.src.tasking.projects.schemas import ProjectStatus, TaskingProject from api.src.tasking.tasks.dtos import ( ExistingLockSummary, FeedbackInput, @@ -53,7 +50,6 @@ TaskStatus, ) - # Equirectangular approximation for area calculations on small # EPSG:4326 polygons: 1 degree latitude ≈ 111.32 km. Sufficient for # the grid-size warning threshold; precise areas need a metric @@ -149,9 +145,7 @@ def _generate_grid_over_aoi( minx, miny, maxx, maxy = aoi.bounds center_lat = (miny + maxy) / 2.0 lat_step = cell_size_m / 111_320.0 - lon_step = cell_size_m / ( - 111_320.0 * max(math.cos(math.radians(center_lat)), 0.01) - ) + lon_step = cell_size_m / (111_320.0 * max(math.cos(math.radians(center_lat)), 0.01)) cells: list[ShapelyPolygon] = [] # Safety cap for accidental large-AOI + small-cell combinations. @@ -176,15 +170,10 @@ def _generate_grid_over_aoi( # `intersection` can return a Polygon, MultiPolygon, # or GeometryCollection; retain polygon pieces only. geoms = ( - list(clipped.geoms) - if hasattr(clipped, "geoms") - else [clipped] + list(clipped.geoms) if hasattr(clipped, "geoms") else [clipped] ) for piece in geoms: - if ( - isinstance(piece, ShapelyPolygon) - and piece.area > 0 - ): + if isinstance(piece, ShapelyPolygon) and piece.area > 0: cells.append(piece) if len(cells) >= cell_cap: return cells @@ -212,9 +201,7 @@ def __init__(self, session: AsyncSession): # ---- common helpers --------------------------------------------------- - async def _get_project( - self, workspace_id: int, project_id: int - ) -> TaskingProject: + async def _get_project(self, workspace_id: int, project_id: int) -> TaskingProject: rs = await self.session.execute( select(TaskingProject).where( (TaskingProject.id == project_id) @@ -227,9 +214,7 @@ async def _get_project( raise NotFoundException(f"Project {project_id} not found") return project - async def _get_task( - self, project_id: int, task_number: int - ) -> TaskingTask: + async def _get_task(self, project_id: int, task_number: int) -> TaskingTask: rs = await self.session.execute( select(TaskingTask).where( (TaskingTask.project_id == project_id) @@ -243,13 +228,10 @@ async def _get_task( ) return task - async def _get_active_lock( - self, task_id: int - ) -> Optional[TaskingLock]: + async def _get_active_lock(self, task_id: int) -> Optional[TaskingLock]: rs = await self.session.execute( select(TaskingLock).where( - (TaskingLock.task_id == task_id) - & (TaskingLock.released_at.is_(None)) + (TaskingLock.task_id == task_id) & (TaskingLock.released_at.is_(None)) ) ) return rs.scalar_one_or_none() @@ -320,15 +302,11 @@ async def _audit( }, ) - async def _lookup_user_display( - self, user_auth_uid: Optional[str] - ) -> Optional[str]: + async def _lookup_user_display(self, user_auth_uid: Optional[str]) -> Optional[str]: if not user_auth_uid: return None rs = await self.session.execute( - text( - "SELECT display_name FROM users WHERE auth_uid = :uid" - ), + text("SELECT display_name FROM users WHERE auth_uid = :uid"), {"uid": user_auth_uid}, ) return rs.scalar_one_or_none() @@ -498,9 +476,7 @@ async def save( detail="Project AOI is required before saving tasks", ) - body_bytes = json.dumps( - body.model_dump(mode="json"), sort_keys=True - ).encode() + body_bytes = json.dumps(body.model_dump(mode="json"), sort_keys=True).encode() body_hash = hashlib.sha256(body_bytes).hexdigest() # Idempotent replay path. @@ -530,9 +506,7 @@ async def save( # Refuse if tasks already exist (re-upload AOI to wipe). existing = await self.session.execute( - text( - "SELECT 1 FROM tasking_tasks WHERE project_id = :pid LIMIT 1" - ), + text("SELECT 1 FROM tasking_tasks WHERE project_id = :pid LIMIT 1"), {"pid": project.id}, ) if existing.scalar() is not None: @@ -692,9 +666,7 @@ async def lock_task( task = await self._get_task(project_id, task_number) # Eligibility table. - role = await self._project_role( - project_id, current_user, workspace_id - ) + role = await self._project_role(project_id, current_user, workspace_id) if role is None: raise ForbiddenException("User has no access to this project") @@ -708,13 +680,10 @@ async def lock_task( raise ForbiddenException( "Role does not permit locking this task for validation" ) - if ( - task.last_mapper_id - and task.last_mapper_id == str(current_user.user_uuid) + if task.last_mapper_id and task.last_mapper_id == str( + current_user.user_uuid ): - raise ForbiddenException( - "Cannot validate a task you last mapped" - ) + raise ForbiddenException("Cannot validate a task you last mapped") else: raise ForbiddenException("Task is in a terminal state") diff --git a/api/src/tasking/tasks/routes.py b/api/src/tasking/tasks/routes.py index 1d38565..24bf14e 100644 --- a/api/src/tasking/tasks/routes.py +++ b/api/src/tasking/tasks/routes.py @@ -3,7 +3,16 @@ from typing import Annotated, Optional from uuid import UUID -from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Response, status +from fastapi import ( + APIRouter, + Body, + Depends, + Header, + HTTPException, + Query, + Response, + status, +) from sqlmodel.ext.asyncio.session import AsyncSession from api.core.database import get_osm_session, get_task_session @@ -81,9 +90,7 @@ async def generate_grid( """ await assert_workspace_visible(workspace_id, current_user, workspace_repo) assert_workspace_lead(workspace_id, current_user) - return await task_repo.generate_grid( - workspace_id, project_id, cell_size_meters - ) + return await task_repo.generate_grid(workspace_id, project_id, cell_size_meters) @router.post("/tasks/validate", response_model=ValidatePreviewResponse) @@ -118,9 +125,7 @@ async def save_tasks( payload, replayed = await task_repo.save( workspace_id, project_id, current_user, body, idempotency_key ) - response.status_code = ( - status.HTTP_200_OK if replayed else status.HTTP_201_CREATED - ) + response.status_code = status.HTTP_200_OK if replayed else status.HTTP_201_CREATED return payload @@ -128,9 +133,7 @@ async def save_tasks( async def list_tasks( workspace_id: int, project_id: int, - status_filter: Annotated[ - Optional[TaskStatus], Query(alias="status") - ] = None, + status_filter: Annotated[Optional[TaskStatus], Query(alias="status")] = None, locked_by_user_id: Optional[UUID] = Query(default=None), last_mapper_id: Optional[UUID] = Query(default=None), page: int = Query(1, ge=1), diff --git a/api/src/tasking/tasks/schemas.py b/api/src/tasking/tasks/schemas.py index 40e5799..f3c114b 100644 --- a/api/src/tasking/tasks/schemas.py +++ b/api/src/tasking/tasks/schemas.py @@ -1,4 +1,3 @@ - from __future__ import annotations from datetime import datetime @@ -7,10 +6,10 @@ from typing import Any, Optional from geoalchemy2 import Geometry -from sqlalchemy import Column, Enum as SAEnum +from sqlalchemy import Column +from sqlalchemy import Enum as SAEnum from sqlmodel import Field, SQLModel - # --------------------------------------------------------------------------- # Enums (mirrors of postgres enums in the migration) # --------------------------------------------------------------------------- diff --git a/docs/tasking-mvp/tasking-mvp.openapi.json b/docs/tasking-mvp/tasking-mvp.openapi.json index 6c003b8..6e066dc 100644 --- a/docs/tasking-mvp/tasking-mvp.openapi.json +++ b/docs/tasking-mvp/tasking-mvp.openapi.json @@ -1652,19 +1652,19 @@ "audit" ], "summary": "List project-level audit events.", - "description": "Paginated, newest first. Any workspace contributor. Soft-deleted\nprojects are visible only with `includeDeleted=true`.\n", + "description": "Paginated, newest first. Any workspace contributor. Soft-deleted\nprojects are visible only with `include_deleted=true`.\n", "operationId": "listProjectAudit", "parameters": [ { "in": "query", - "name": "eventType", + "name": "event_type", "schema": { "$ref": "#/components/schemas/AuditEventType" } }, { "in": "query", - "name": "taskNumber", + "name": "task_number", "schema": { "type": "integer", "minimum": 1 @@ -1672,7 +1672,7 @@ }, { "in": "query", - "name": "actorUserId", + "name": "actor_user_id", "schema": { "type": "string", "format": "uuid" @@ -1680,7 +1680,7 @@ }, { "in": "query", - "name": "occurredFrom", + "name": "occurred_from", "schema": { "type": "string", "format": "date-time" @@ -1688,7 +1688,7 @@ }, { "in": "query", - "name": "occurredTo", + "name": "occurred_to", "schema": { "type": "string", "format": "date-time" @@ -1696,7 +1696,7 @@ }, { "in": "query", - "name": "includeDeleted", + "name": "include_deleted", "schema": { "type": "boolean", "default": false @@ -1713,7 +1713,7 @@ }, { "in": "query", - "name": "pageSize", + "name": "page_size", "schema": { "type": "integer", "minimum": 1, @@ -1723,7 +1723,7 @@ }, { "in": "query", - "name": "orderByType", + "name": "order_by_type", "schema": { "type": "string", "enum": [ @@ -1776,14 +1776,14 @@ "parameters": [ { "in": "query", - "name": "eventType", + "name": "event_type", "schema": { "$ref": "#/components/schemas/AuditEventType" } }, { "in": "query", - "name": "actorUserId", + "name": "actor_user_id", "schema": { "type": "string", "format": "uuid" @@ -1791,7 +1791,7 @@ }, { "in": "query", - "name": "occurredFrom", + "name": "occurred_from", "schema": { "type": "string", "format": "date-time" @@ -1799,7 +1799,7 @@ }, { "in": "query", - "name": "occurredTo", + "name": "occurred_to", "schema": { "type": "string", "format": "date-time" @@ -1816,7 +1816,7 @@ }, { "in": "query", - "name": "pageSize", + "name": "page_size", "schema": { "type": "integer", "minimum": 1, @@ -1826,7 +1826,7 @@ }, { "in": "query", - "name": "orderByType", + "name": "order_by_type", "schema": { "type": "string", "enum": [ @@ -2983,14 +2983,14 @@ "ActorRef": { "type": "object", "required": [ - "userId" + "user_id" ], "properties": { - "userId": { + "user_id": { "type": "string", "format": "uuid" }, - "displayName": { + "display_name": { "type": "string", "nullable": true } @@ -3000,10 +3000,10 @@ "type": "object", "required": [ "id", - "eventType", - "projectId", + "event_type", + "project_id", "actor", - "occurredAt", + "occurred_at", "details" ], "properties": { @@ -3011,19 +3011,19 @@ "type": "integer", "format": "int64" }, - "eventType": { + "event_type": { "$ref": "#/components/schemas/AuditEventType" }, - "projectId": { + "project_id": { "type": "integer", "format": "int64" }, - "taskId": { + "task_id": { "type": "integer", "format": "int64", "nullable": true }, - "taskNumber": { + "task_number": { "type": "integer", "nullable": true, "description": "Convenience copy from `details` (so list renderers don't peek inside JSONB)." @@ -3031,7 +3031,7 @@ "actor": { "$ref": "#/components/schemas/ActorRef" }, - "occurredAt": { + "occurred_at": { "type": "string", "format": "date-time" }, @@ -3039,7 +3039,7 @@ "type": "object", "additionalProperties": true }, - "projectDeleted": { + "project_deleted": { "type": "boolean", "default": false } diff --git a/docs/tasking-mvp/tasking-mvp.openapi.yaml b/docs/tasking-mvp/tasking-mvp.openapi.yaml index 6eb502b..8076441 100644 --- a/docs/tasking-mvp/tasking-mvp.openapi.yaml +++ b/docs/tasking-mvp/tasking-mvp.openapi.yaml @@ -1114,35 +1114,35 @@ paths: summary: List project-level audit events. description: | Paginated, newest first. Any workspace contributor. Soft-deleted - projects are visible only with `includeDeleted=true`. + projects are visible only with `include_deleted=true`. operationId: listProjectAudit parameters: - in: query - name: eventType + name: event_type schema: { $ref: "#/components/schemas/AuditEventType" } - in: query - name: taskNumber + name: task_number schema: { type: integer, minimum: 1 } - in: query - name: actorUserId + name: actor_user_id schema: { type: string, format: uuid } - in: query - name: occurredFrom + name: occurred_from schema: { type: string, format: date-time } - in: query - name: occurredTo + name: occurred_to schema: { type: string, format: date-time } - in: query - name: includeDeleted + name: include_deleted schema: { type: boolean, default: false } - in: query name: page schema: { type: integer, minimum: 1, default: 1 } - in: query - name: pageSize + name: page_size schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - in: query - name: orderByType + name: order_by_type schema: { type: string, enum: [ASC, DESC], default: DESC } responses: "200": @@ -1165,25 +1165,25 @@ paths: operationId: listTaskAudit parameters: - in: query - name: eventType + name: event_type schema: { $ref: "#/components/schemas/AuditEventType" } - in: query - name: actorUserId + name: actor_user_id schema: { type: string, format: uuid } - in: query - name: occurredFrom + name: occurred_from schema: { type: string, format: date-time } - in: query - name: occurredTo + name: occurred_to schema: { type: string, format: date-time } - in: query name: page schema: { type: integer, minimum: 1, default: 1 } - in: query - name: pageSize + name: page_size schema: { type: integer, minimum: 1, maximum: 200, default: 25 } - in: query - name: orderByType + name: order_by_type schema: { type: string, enum: [ASC, DESC], default: DESC } responses: "200": @@ -1860,32 +1860,32 @@ components: ActorRef: type: object - required: [userId] + required: [user_id] properties: - userId: { type: string, format: uuid } - displayName: { type: string, nullable: true } + user_id: { type: string, format: uuid } + display_name: { type: string, nullable: true } AuditEvent: type: object - required: [id, eventType, projectId, actor, occurredAt, details] + required: [id, event_type, project_id, actor, occurred_at, details] properties: id: { type: integer, format: int64 } - eventType: { $ref: "#/components/schemas/AuditEventType" } - projectId: { type: integer, format: int64 } - taskId: + event_type: { $ref: "#/components/schemas/AuditEventType" } + project_id: { type: integer, format: int64 } + task_id: type: integer format: int64 nullable: true - taskNumber: + task_number: type: integer nullable: true description: Convenience copy from `details` (so list renderers don't peek inside JSONB). actor: { $ref: "#/components/schemas/ActorRef" } - occurredAt: { type: string, format: date-time } + occurred_at: { type: string, format: date-time } details: type: object additionalProperties: true - projectDeleted: + project_deleted: type: boolean default: false diff --git a/tests/conftest.py b/tests/conftest.py index fbbf416..309c430 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,3 @@ - from __future__ import annotations from collections.abc import AsyncIterator, Iterator @@ -7,7 +6,6 @@ import pytest - # Constants — referenced by both unit and integration suites. SEED_WORKSPACE_ID = 1899 SEED_PROJECT_GROUP_ID = UUID("00000000-0000-0000-0000-000000001899") @@ -73,9 +71,8 @@ def pytest_html_results_table_header(cells): def pytest_html_results_table_row(report, cells): """Inject the docstring as the matching cell on each row.""" - cells.insert( - 2, f"