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"{getattr(report, 'description', '') or '—'}" - ) + cells.insert(2, f"{getattr(report, 'description', '') or '—'}") + except ImportError: pass @@ -83,8 +80,7 @@ def pytest_html_results_table_row(report, cells): def _redact(headers) -> str: redact = {"authorization", "cookie", "set-cookie", "x-api-key"} return ", ".join( - f"{k}={'***' if k.lower() in redact else v}" - for k, v in headers.items() + f"{k}={'***' if k.lower() in redact else v}" for k, v in headers.items() ) @@ -152,11 +148,7 @@ def _make_user( is_poc: bool = False, ): """Construct a UserInfo with the minimum fields the gates inspect.""" - from api.core.security import ( - TdeiProjectGroupRole, - UserInfo, - UserInfoPGMembership, - ) + from api.core.security import TdeiProjectGroupRole, UserInfo, UserInfoPGMembership from api.src.users.schemas import WorkspaceUserRoleType u = UserInfo() diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index c56b092..be2be67 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,4 +1,3 @@ - from __future__ import annotations import os @@ -10,7 +9,6 @@ from tests.conftest import SEED_PROJECT_GROUP_ID, SEED_WORKSPACE_ID - # --------------------------------------------------------------------------- # Docker availability gate — skip cleanly when the daemon is missing # and surface the actual reason in the pytest skip message. @@ -232,8 +230,8 @@ async def _seed_workspace_row(_migrated_db: tuple[str, str]) -> int: await conn.execute( text( "INSERT INTO workspaces " - "(id, type, title, \"tdeiProjectGroupId\", \"createdAt\", " - " \"createdBy\", \"createdByName\", \"externalAppAccess\") " + '(id, type, title, "tdeiProjectGroupId", "createdAt", ' + ' "createdBy", "createdByName", "externalAppAccess") ' "VALUES (:id, :type, :title, :pgid, NOW(), :uid, :uname, 0) " "ON CONFLICT (id) DO NOTHING" ), @@ -374,10 +372,11 @@ async def _get_osm(): # `validate_token`. These shadow the unit-suite counterparts in # tests/conftest.py for every test under tests/integration/. + @pytest.fixture async def as_lead(_pg_urls, seeded_workspace_id): """LEAD user persisted in users table + overridden in validate_token.""" - from tests.conftest import _make_user, SEED_PROJECT_GROUP_ID + from tests.conftest import SEED_PROJECT_GROUP_ID, _make_user _, osm_url = _pg_urls user = _make_user( @@ -394,7 +393,7 @@ async def as_lead(_pg_urls, seeded_workspace_id): @pytest.fixture async def as_contributor(_pg_urls, seeded_workspace_id): """CONTRIBUTOR user persisted in users table + overridden in validate_token.""" - from tests.conftest import _make_user, SEED_PROJECT_GROUP_ID + from tests.conftest import SEED_PROJECT_GROUP_ID, _make_user _, osm_url = _pg_urls user = _make_user( @@ -411,7 +410,7 @@ async def as_contributor(_pg_urls, seeded_workspace_id): @pytest.fixture async def as_validator(_pg_urls, seeded_workspace_id): """VALIDATOR user persisted in users table + overridden in validate_token.""" - from tests.conftest import _make_user, SEED_PROJECT_GROUP_ID + from tests.conftest import SEED_PROJECT_GROUP_ID, _make_user _, osm_url = _pg_urls user = _make_user( @@ -432,7 +431,7 @@ async def as_outsider(_pg_urls, seeded_workspace_id): Inserted into users so role tests don't break, but their workspace role list is empty so the tenancy gate still 404s. """ - from tests.conftest import _make_user, SEED_PROJECT_GROUP_ID + from tests.conftest import SEED_PROJECT_GROUP_ID, _make_user _, osm_url = _pg_urls user = _make_user( @@ -500,15 +499,11 @@ async def _fake_fetch(project_group_id: str, bearer_token: str): import api.core.security import api.src.tasking.projects.repository as proj_repo - monkeypatch.setattr( - api.core.security, "fetch_project_group_users", _fake_fetch - ) + monkeypatch.setattr(api.core.security, "fetch_project_group_users", _fake_fetch) # The repository imports the symbol locally inside the helper, but be # belt-and-braces in case that ever changes: if hasattr(proj_repo, "fetch_project_group_users"): - monkeypatch.setattr( - proj_repo, "fetch_project_group_users", _fake_fetch - ) + monkeypatch.setattr(proj_repo, "fetch_project_group_users", _fake_fetch) return members diff --git a/tests/integration/test_audit_flow.py b/tests/integration/test_audit_flow.py new file mode 100644 index 0000000..3cc37ec --- /dev/null +++ b/tests/integration/test_audit_flow.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +import pytest + +pytestmark = pytest.mark.integration + + +API = "/api/v1/workspaces/{wid}/tasking/projects" + + +AOI_UNIT_SQUARE = { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], +} + +TASK_A = { + "type": "Polygon", + "coordinates": [ + [[0.00, 0.00], [0.01, 0.00], [0.01, 0.01], [0.00, 0.01], [0.00, 0.00]] + ], +} +TASK_B = { + "type": "Polygon", + "coordinates": [ + [[0.02, 0.02], [0.03, 0.02], [0.03, 0.03], [0.02, 0.03], [0.02, 0.02]] + ], +} + + +def _fc(*polys): + return { + "type": "FeatureCollection", + "features": [{"type": "Feature", "geometry": p} for p in polys], + } + + +# --------------------------------------------------------------------------- +# Helpers — open a project with two tasks so the audit log has rows worth +# filtering across (project_created, aoi_uploaded, task_created x N, +# project_activated, plus task lock/unlock events later). +# --------------------------------------------------------------------------- + + +async def _open_project_with_tasks(client, workspace_id): + r = await client.post( + API.format(wid=workspace_id), + json={"name": f"audit-{id(client)}", "review_required": False}, + ) + assert r.status_code == 201, r.text + pid = r.json()["id"] + + r = await client.post( + f"{API.format(wid=workspace_id)}/{pid}/aoi", json=AOI_UNIT_SQUARE + ) + assert r.status_code == 200, r.text + + r = await client.post( + f"{API.format(wid=workspace_id)}/{pid}/tasks/save", + json={"feature_collection": _fc(TASK_A, TASK_B)}, + ) + assert r.status_code == 201, r.text + + r = await client.post(f"{API.format(wid=workspace_id)}/{pid}/activate") + assert r.status_code == 200, r.text + return pid + + +# --------------------------------------------------------------------------- +# Workflow 1 — project-level audit listing + filters. +# --------------------------------------------------------------------------- + + +class TestProjectAuditListing: + + async def test_lists_lifecycle_events_newest_first( + self, client, as_lead, seeded_workspace_id + ): + """Project create → AOI upload → tasks → activate all appear in audit.""" + pid = await _open_project_with_tasks(client, seeded_workspace_id) + + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}/audit") + assert r.status_code == 200, r.text + body = r.json() + assert "results" in body + assert body["pagination"]["total"] >= 1 + + seen = [row["event_type"] for row in body["results"]] + # Lifecycle events we know are emitted by repository code today. + assert "project_activated" in seen + # Newest first. + ts = [row["occurred_at"] for row in body["results"]] + assert ts == sorted(ts, reverse=True) + + async def test_filter_by_event_type(self, client, as_lead, seeded_workspace_id): + """`event_type` query narrows results to one kind.""" + pid = await _open_project_with_tasks(client, seeded_workspace_id) + + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{pid}/audit", + params={"event_type": "project_activated"}, + ) + assert r.status_code == 200, r.text + kinds = {row["event_type"] for row in r.json()["results"]} + assert kinds == {"project_activated"} + + async def test_filter_by_actor(self, client, as_lead, seeded_workspace_id): + """`actor_user_id` filters to events emitted by that user only.""" + pid = await _open_project_with_tasks(client, seeded_workspace_id) + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{pid}/audit", + params={"actor_user_id": str(as_lead.user_uuid)}, + ) + assert r.status_code == 200 + for row in r.json()["results"]: + assert row["actor"]["user_id"] == str(as_lead.user_uuid) + + async def test_pagination_clamps_and_total( + self, client, as_lead, seeded_workspace_id + ): + """Page size of 1 still returns one row; total reflects the whole set.""" + pid = await _open_project_with_tasks(client, seeded_workspace_id) + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{pid}/audit", + params={"page_size": 1, "page": 1}, + ) + assert r.status_code == 200 + body = r.json() + assert len(body["results"]) == 1 + assert body["pagination"]["page_size"] == 1 + assert body["pagination"]["total"] >= 1 + + async def test_unknown_project_404(self, client, as_lead, seeded_workspace_id): + """A bogus project id returns 404 from the tenancy / existence check.""" + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/999999/audit") + assert r.status_code == 404 + + +# --------------------------------------------------------------------------- +# Workflow 2 — task-level audit listing. +# --------------------------------------------------------------------------- + + +class TestTaskAuditListing: + + async def test_lists_task_events( + self, client, as_lead, as_contributor, seeded_workspace_id + ): + """Lock/unlock on a task surface in /tasks/{n}/audit.""" + # `as_lead` opens the project (lead-only), then switch to contributor + # to perform lock + unlock so we generate task events. + pid = await _open_project_with_tasks(client, seeded_workspace_id) + + # Contributor locks task 1. + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock" + ) + assert r.status_code in (200, 201), r.text + + r = await client.delete( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock" + ) + assert r.status_code in (200, 204), r.text + + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/audit" + ) + assert r.status_code == 200, r.text + body = r.json() + kinds = {row["event_type"] for row in body["results"]} + assert "task_locked" in kinds + assert "task_unlocked" in kinds + # Every row should reference the right task (by id or task_number). + for row in body["results"]: + assert row["task_id"] is not None or row.get("task_number") == 1 + + async def test_unknown_task_404(self, client, as_lead, seeded_workspace_id): + """A bogus task number on a real project returns 404.""" + pid = await _open_project_with_tasks(client, seeded_workspace_id) + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/99/audit" + ) + assert r.status_code == 404 + + +# --------------------------------------------------------------------------- +# Workflow 3 — soft-deleted projects honour include_deleted. +# --------------------------------------------------------------------------- + + +class TestAuditIncludeDeleted: + + async def test_deleted_project_hidden_by_default( + self, client, as_lead, seeded_workspace_id + ): + """A soft-deleted project's audit returns 404 unless `include_deleted=true`.""" + pid = await _open_project_with_tasks(client, seeded_workspace_id) + + # Project must be closed before delete. + r = await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/close") + # Some tasks may still be open; tolerate either path. The audit + # endpoint behaviour we care about only needs deleted_at to be + # set, which the delete call will do regardless of status. + r = await client.delete(f"{API.format(wid=seeded_workspace_id)}/{pid}") + if r.status_code != 204: + pytest.skip(f"Could not soft-delete project: {r.status_code} {r.text}") + + # Default = hidden. + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}/audit") + assert r.status_code == 404 + + # Explicit opt-in = visible. + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{pid}/audit", + params={"include_deleted": True}, + ) + assert r.status_code == 200, r.text + assert r.json()["pagination"]["total"] >= 1 diff --git a/tests/integration/test_projects_flow.py b/tests/integration/test_projects_flow.py index f9a57cd..c1575de 100644 --- a/tests/integration/test_projects_flow.py +++ b/tests/integration/test_projects_flow.py @@ -1,4 +1,3 @@ - from __future__ import annotations import pytest @@ -50,9 +49,7 @@ async def test_01_create_draft(self, client, as_lead, seeded_workspace_id): async def test_02_get_round_trip(self, client, as_lead, seeded_workspace_id): """GET round-trips the project just created (same id).""" - r = await client.get( - f"{API.format(wid=seeded_workspace_id)}/{self.project_id}" - ) + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{self.project_id}") assert r.status_code == 200 assert r.json()["id"] == self.project_id @@ -113,9 +110,7 @@ async def test_07_soft_delete_clears_listing( ids = {row["id"] for row in r.json()["results"]} assert self.project_id not in ids - r = await client.get( - f"{API.format(wid=seeded_workspace_id)}/{self.project_id}" - ) + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{self.project_id}") assert r.status_code == 404 @@ -226,9 +221,7 @@ async def test_role_assignment_with_unknown_user_returns_422( API.format(wid=seeded_workspace_id), json={ "name": "role-fk-error", - "role_assignments": [ - {"user_id": bogus, "role": "contributor"} - ], + "role_assignments": [{"user_id": bogus, "role": "contributor"}], }, ) assert r.status_code == 422, r.text @@ -323,20 +316,13 @@ async def test_aoi_replace_resets_boundary_type( json=SQUARE_MULTI, ) assert r2.status_code == 200 - assert ( - r2.json()["geometry"]["coordinates"] - == SQUARE_MULTI["coordinates"] - ) + assert r2.json()["geometry"]["coordinates"] == SQUARE_MULTI["coordinates"] # Boundary type should have been cleared (per spec). - proj = ( - await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}") - ).json() + proj = (await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}")).json() assert proj["task_boundary_type"] is None - async def test_aoi_delete_round_trip( - self, client, as_lead, seeded_workspace_id - ): + async def test_aoi_delete_round_trip(self, client, as_lead, seeded_workspace_id): """DELETE /aoi removes the AOI; subsequent GET returns 404.""" r = await client.post( API.format(wid=seeded_workspace_id), @@ -348,15 +334,11 @@ async def test_aoi_delete_round_trip( json=SQUARE_POLY, ) - r = await client.delete( - f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi" - ) + r = await client.delete(f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi") assert r.status_code == 204 # Subsequent GET 404s. - r = await client.get( - f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi" - ) + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi") assert r.status_code == 404 @@ -380,9 +362,7 @@ async def test_list_includes_creator_auto_lead( json={"name": "roles-list-1"}, ) pid = r.json()["id"] - r = await client.get( - f"{API.format(wid=seeded_workspace_id)}/{pid}/roles" - ) + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}/roles") assert r.status_code == 200, r.text rows = r.json()["results"] assert len(rows) == 1 @@ -413,9 +393,7 @@ async def test_add_role_round_trip( assert body["user_id"] == str(contrib.user_uuid) assert body["role"] == "contributor" - r = await client.get( - f"{API.format(wid=seeded_workspace_id)}/{pid}/roles" - ) + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}/roles") ids = {row["user_id"] for row in r.json()["results"]} assert str(contrib.user_uuid) in ids @@ -527,8 +505,7 @@ async def test_remove_role_round_trip( ) r = await client.delete( - f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" - f"{contrib.user_uuid}" + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" f"{contrib.user_uuid}" ) assert r.status_code == 204 @@ -540,9 +517,7 @@ async def test_remove_role_round_trip( ) assert r.status_code == 404 - async def test_last_lead_demote_blocked( - self, client, as_lead, seeded_workspace_id - ): + async def test_last_lead_demote_blocked(self, client, as_lead, seeded_workspace_id): """Cannot demote the only LEAD — projects must always have one.""" r = await client.post( API.format(wid=seeded_workspace_id), @@ -558,9 +533,7 @@ async def test_last_lead_demote_blocked( assert r.status_code == 422, r.text assert "last lead" in r.json()["detail"].lower() - async def test_last_lead_delete_blocked( - self, client, as_lead, seeded_workspace_id - ): + async def test_last_lead_delete_blocked(self, client, as_lead, seeded_workspace_id): """Cannot delete the only LEAD — would orphan the project.""" r = await client.post( API.format(wid=seeded_workspace_id), @@ -596,8 +569,7 @@ async def test_demote_lead_works_when_two_leads_exist( # Now demote the second lead — first lead is still there. r = await client.patch( - f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" - f"{lead2.user_uuid}", + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" f"{lead2.user_uuid}", json={"role": "contributor"}, ) assert r.status_code == 200, r.text @@ -623,8 +595,7 @@ async def test_get_single_role( ) r = await client.get( - f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" - f"{contrib.user_uuid}" + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" f"{contrib.user_uuid}" ) assert r.status_code == 200, r.text body = r.json() diff --git a/tests/integration/test_tasks_flow.py b/tests/integration/test_tasks_flow.py index 1cf712a..189e791 100644 --- a/tests/integration/test_tasks_flow.py +++ b/tests/integration/test_tasks_flow.py @@ -1,5 +1,3 @@ - - from __future__ import annotations import pytest @@ -161,9 +159,7 @@ async def test_grid_blocked_without_aoi( ) pid = r.json()["id"] - r = await client.post( - f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/grid" - ) + r = await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/grid") assert r.status_code == 422, r.text assert "aoi" in r.json()["detail"].lower() @@ -187,9 +183,7 @@ async def test_grid_blocked_outside_draft( name_suffix="-grid-state", ) - r = await client.post( - f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/grid" - ) + r = await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/grid") assert r.status_code == 422, r.text assert "draft" in r.json()["detail"].lower() @@ -240,8 +234,7 @@ async def test_grid_multipolygon_straddling_cell_splits( assert len(fc["features"]) == 2, [f["geometry"] for f in fc["features"]] for feat in fc["features"]: assert feat["geometry"]["type"] == "Polygon", ( - "straddling cell was not split — got " - f"{feat['geometry']['type']}" + "straddling cell was not split — got " f"{feat['geometry']['type']}" ) # The two output polygons should align with the two lobes — @@ -290,9 +283,7 @@ class TestValidateAndSave: project_id: int | None = None - async def test_01_create_draft_with_aoi( - self, client, as_lead, seeded_workspace_id - ): + async def test_01_create_draft_with_aoi(self, client, as_lead, seeded_workspace_id): """Create a draft project and upload the project AOI.""" r = await client.post( API.format(wid=seeded_workspace_id), @@ -307,9 +298,7 @@ async def test_01_create_draft_with_aoi( ) assert r.status_code == 200, r.text - async def test_02_validate_inside_aoi( - self, client, as_lead, seeded_workspace_id - ): + async def test_02_validate_inside_aoi(self, client, as_lead, seeded_workspace_id): """Two in-AOI polygons validate cleanly with no warnings.""" r = await client.post( f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/validate", @@ -327,9 +316,7 @@ async def test_03_validate_polygon_outside_aoi_rejected( """A polygon outside the project AOI is rejected with 422.""" outside = { "type": "Polygon", - "coordinates": [ - [[5, 5], [5.1, 5], [5.1, 5.1], [5, 5.1], [5, 5]] - ], + "coordinates": [[[5, 5], [5.1, 5], [5.1, 5.1], [5, 5.1], [5, 5]]], } r = await client.post( f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/validate", @@ -370,9 +357,7 @@ async def test_05_save_persists_two_tasks( assert body["tasks"][0]["lock"] is None assert body["tasks"][0]["last_mapper"] is None - async def test_06_double_save_rejected( - self, client, as_lead, seeded_workspace_id - ): + async def test_06_double_save_rejected(self, client, as_lead, seeded_workspace_id): """A second save into a project that already has tasks 409s.""" r = await client.post( f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/save", @@ -393,9 +378,7 @@ async def test_07_list_tasks_returns_geometry( assert len(body["tasks"]) == 2 assert body["tasks"][0]["geometry"]["type"] == "Polygon" - async def test_08_get_single_task( - self, client, as_lead, seeded_workspace_id - ): + async def test_08_get_single_task(self, client, as_lead, seeded_workspace_id): """GET /tasks/{n} returns one task with geometry + metadata.""" r = await client.get( f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1" @@ -788,9 +771,7 @@ async def test_remap_loop( # Contributor maps → to_review. override_user(contributor) - await client.post( - f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock" - ) + await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock") r = await client.post( f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", json={"osm_changeset_id": 7001, "done": True}, @@ -799,9 +780,7 @@ async def test_remap_loop( # Validator validates with feedback → to_remap. override_user(validator) - await client.post( - f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock" - ) + await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock") r = await client.post( f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", json={ @@ -856,9 +835,7 @@ async def test_validator_cannot_validate_own_last_mapping( # Validator maps the task themselves (validators can also lock to_map). override_user(validator) - await client.post( - f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock" - ) + await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock") await client.post( f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", json={"osm_changeset_id": 9001, "done": True}, @@ -900,25 +877,21 @@ async def test_reset_releases_lock_and_resets_status( # Contributor maps → to_review. override_user(contributor) - await client.post( - f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock" - ) + await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock") await client.post( f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", json={"osm_changeset_id": 11001, "done": True}, ) # Validator picks it up. override_user(validator) - await client.post( - f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock" - ) + await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock") # Switch back to a LEAD token to invoke /reset. The integration # `as_lead` fixture already inserted a lead users row, so the # helper here just builds a UserInfo to bind to the override. from api.core.security import validate_token from api.main import app - from tests.conftest import _make_user, SEED_PROJECT_GROUP_ID + from tests.conftest import SEED_PROJECT_GROUP_ID, _make_user lead = _make_user( role="lead", diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index c19f1c9..56ab9ce 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -1,4 +1,3 @@ - from __future__ import annotations from datetime import datetime @@ -8,7 +7,6 @@ import pytest - # --------------------------------------------------------------------------- # Fake workspace repository — tenancy gate without a DB. # --------------------------------------------------------------------------- @@ -25,9 +23,7 @@ async def getById(self, current_user, workspace_id: int): from api.core.exceptions import NotFoundException all_ids = { - wid - for ids in current_user.accessibleWorkspaceIds.values() - for wid in ids + wid for ids in current_user.accessibleWorkspaceIds.values() for wid in ids } if workspace_id not in all_ids: raise NotFoundException(f"Workspace {workspace_id} not found") diff --git a/tests/unit/test_aoi_normalisation.py b/tests/unit/test_aoi_normalisation.py index 19d54a8..86b253b 100644 --- a/tests/unit/test_aoi_normalisation.py +++ b/tests/unit/test_aoi_normalisation.py @@ -1,5 +1,3 @@ - - from __future__ import annotations import pytest @@ -14,7 +12,6 @@ _Polygon, ) - SQUARE = [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]] TWO_SQUARES = [ [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], diff --git a/tests/unit/test_dtos_validation.py b/tests/unit/test_dtos_validation.py index b7395fd..8bad12f 100644 --- a/tests/unit/test_dtos_validation.py +++ b/tests/unit/test_dtos_validation.py @@ -1,5 +1,3 @@ - - from __future__ import annotations import pytest @@ -11,7 +9,6 @@ ProjectUpdateRequest, ) - # --------------------------------------------------------------------------- # ProjectCreateRequest # --------------------------------------------------------------------------- diff --git a/tests/unit/test_project_routes.py b/tests/unit/test_project_routes.py index 86987ac..1620aba 100644 --- a/tests/unit/test_project_routes.py +++ b/tests/unit/test_project_routes.py @@ -1,7 +1,5 @@ - from __future__ import annotations - API = "/api/v1/workspaces/{wid}/tasking/projects" @@ -74,9 +72,7 @@ async def test_get_404_when_missing( self, client, as_lead, seeded_workspace_id, fake_repos ): """GET on a non-existent project id returns 404.""" - r = await client.get( - f"{API.format(wid=seeded_workspace_id)}/9999" - ) + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/9999") assert r.status_code == 404 async def test_create_then_get_round_trip( @@ -89,15 +85,11 @@ async def test_create_then_get_round_trip( ) pid = r.json()["id"] - r2 = await client.get( - f"{API.format(wid=seeded_workspace_id)}/{pid}" - ) + r2 = await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}") assert r2.status_code == 200 assert r2.json()["id"] == pid - async def test_patch_name( - self, client, as_lead, seeded_workspace_id, fake_repos - ): + async def test_patch_name(self, client, as_lead, seeded_workspace_id, fake_repos): """PATCH updates only specified fields — name change is reflected on GET.""" pid = ( await client.post( @@ -124,14 +116,10 @@ async def test_soft_delete_204_then_404( ) ).json()["id"] - r = await client.delete( - f"{API.format(wid=seeded_workspace_id)}/{pid}" - ) + r = await client.delete(f"{API.format(wid=seeded_workspace_id)}/{pid}") assert r.status_code == 204 - r = await client.get( - f"{API.format(wid=seeded_workspace_id)}/{pid}" - ) + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}") assert r.status_code == 404 async def test_duplicate_name_409( @@ -166,9 +154,7 @@ async def test_activate_fake_always_422( ) ).json()["id"] - r = await client.post( - f"{API.format(wid=seeded_workspace_id)}/{pid}/activate" - ) + r = await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/activate") assert r.status_code == 422 @@ -210,9 +196,7 @@ async def test_get_aoi_404_when_unset( ) ).json()["id"] - r = await client.get( - f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi" - ) + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi") assert r.status_code == 404 async def test_delete_aoi_round_trip( @@ -233,12 +217,8 @@ async def test_delete_aoi_round_trip( }, ) - r = await client.delete( - f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi" - ) + r = await client.delete(f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi") assert r.status_code == 204 - r = await client.get( - f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi" - ) + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi") assert r.status_code == 404 diff --git a/tests/unit/test_user_info_gates.py b/tests/unit/test_user_info_gates.py index dcce1a4..32fcfa2 100644 --- a/tests/unit/test_user_info_gates.py +++ b/tests/unit/test_user_info_gates.py @@ -1,17 +1,10 @@ - - from __future__ import annotations from uuid import UUID -from api.core.security import ( - TdeiProjectGroupRole, - UserInfo, - UserInfoPGMembership, -) +from api.core.security import TdeiProjectGroupRole, UserInfo, UserInfoPGMembership from api.src.users.schemas import WorkspaceUserRoleType - PG = "00000000-0000-0000-0000-000000000001" @@ -21,13 +14,17 @@ def _user(*, osm_roles=None, pg_roles=None, accessible=None): u.user_uuid = UUID("11111111-1111-1111-1111-111111111111") u.user_name = "test" u.osmWorkspaceRoles = osm_roles or {} - u.projectGroups = [ - UserInfoPGMembership( - project_group_name="PG", - project_group_id=PG, - tdeiRoles=pg_roles or [TdeiProjectGroupRole.MEMBER], - ) - ] if pg_roles is not None or accessible is not None else [] + u.projectGroups = ( + [ + UserInfoPGMembership( + project_group_name="PG", + project_group_id=PG, + tdeiRoles=pg_roles or [TdeiProjectGroupRole.MEMBER], + ) + ] + if pg_roles is not None or accessible is not None + else [] + ) u.accessibleWorkspaceIds = accessible or {} return u